Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions docs/design/cross-ledger-model-enforcement.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,14 @@ mode can be added without rewriting the failure taxonomy.
- `f:policySource` cross-ledger via `resolve_graph_ref`. The
policy IR carries definitional/contextual term references
separately so the model ledger contributes rules while the
data ledger contributes identity binding.
data ledger contributes identity binding. Enforced on both
the read path (`wrap_policy`) and the write path
(`build_transact_policy_context`, used by the consensus
transact pipeline, push replication, credentialed
transactions, and the CLI); the two share one restriction
resolver (`resolve_cross_ledger_policy_restrictions`) so the
identity-mode rejection and `f:policyClass` filter cannot
drift.
- `f:constraintsSource` cross-ledger via the same shared
resolver. M's `f:enforceUnique true` annotations on
properties apply to D's transactions; a tx that would
Expand All @@ -477,10 +484,15 @@ mode can be added without rewriting the failure taxonomy.
- Reserved-feature rejection: `f:atT`, `f:trustPolicy`, and
`f:rollbackGuard` are surfaced as `UnsupportedFeature` rather
than silently ignored.
- Identity-mode + cross-ledger policy combination fails closed
with a config error — the design's "M contributes rules, D
contributes identity" boundary is enforced at the request
surface.
- Identity binding under cross-ledger policy: a request identity
is bind-only — it resolves against D to populate `?$identity`
for M's `f:query` rules and never selects rules (the design's
"M contributes rules, D contributes identity binding"
boundary). Rule selection is exclusively the policy-class
filter chain (request → config → `{f:AccessPolicy}` for
anonymous requests); an identity-carrying request with no
policy class anywhere fails closed with a config error rather
than silently defaulting.

### Reserved

Expand Down
2 changes: 2 additions & 0 deletions docs/ledger-config/setting-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ Controls default policy enforcement behavior.

When `f:policySource` is set, the policy loader scans the specified graph for policy rules instead of the default graph. This keeps policy rules separate from end-user data. If `f:policySource` is not set, policies are loaded from the default graph (backward compatible).

`f:policySource` and the policy defaults are honored on **both reads and writes**: queries load view rules from the configured graph, and transactions load `f:modify` rules from the same graph before staging. Config-declared `f:policyClass` / `f:defaultAllow` defaults apply to transactions even when the request itself carries no policy inputs — an operator who relocates policy into a named graph (or a model ledger) gets the same enforcement on writes as on reads.

**Cross-ledger references are supported on `f:policySource`.** The graph source can name another ledger via `f:ledger`, so a single model ledger can hold policy rules that govern many data ledgers. See [Cross-ledger policy](../security/cross-ledger-policy.md) for the configuration pattern and the contract on `f:policyClass` filtering, baseline `f:AccessPolicy` semantics, and the failure modes.

**Not yet honored on `f:policySource`** (parsed by the config layer but rejected at request time with a clear error): `f:atT` temporal pinning, `f:trustPolicy` verification, `f:rollbackGuard` freshness constraints. Cross-ledger references are also supported on `f:constraintsSource`, `f:schemaSource` (single graph only — transitive `owl:imports` recursion across ledgers is not yet supported), `f:shapesSource`, and `f:rulesSource`. See [Cross-ledger policy](../security/cross-ledger-policy.md) for the end-to-end configuration patterns and failure modes shared by all five subsystems.
Expand Down
86 changes: 71 additions & 15 deletions docs/security/cross-ledger-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ All five `f:GraphRef`-shaped governance predicates support
cross-ledger references today:

- **Cross-ledger policy** (`f:policySource` with `f:ledger`) —
M's policy rule set is applied to queries against D.
M's policy rule set is applied to queries (`f:view`) and
transactions (`f:modify`) against D.
- **Cross-ledger constraints** (`f:constraintsSource` with
`f:ledger`) — M's `f:enforceUnique` annotations are applied
to transactions against D.
Expand Down Expand Up @@ -154,30 +155,85 @@ rules are opt-in — operators name the class to enroll them.

## Engaging policy enforcement

There's a subtlety in how the server's JSON-LD query route
chooses whether to invoke policy enforcement at all. Requests
without an `fluree-policy-class`, `fluree-identity`, or inline
`opts.policy` go through a no-policy fast path that bypasses the
cross-ledger dispatch. A configured `f:policySource` in `#config`
is **not** enough on its own to force enforcement at the HTTP
layer today.

To engage cross-ledger policy via HTTP, send a request with at
least one of:
**Transactions engage cross-ledger policy automatically.** The
transact path (JSON-LD / SPARQL UPDATE / Turtle / TriG through
the server, push replication, credentialed transactions, and the
CLI's local mode with policy flags) resolves D's config before
staging: a cross-ledger `f:policySource` always builds a policy
context, and M's `f:modify` rules are enforced on the staged
flakes even when the request carries no policy inputs at all.
Config `f:defaultAllow` / `f:policyClass` defaults merge in the
same way they do for reads.

For **queries**, there's a subtlety in how the server's JSON-LD
query route chooses whether to invoke policy enforcement at all.
Requests without an `fluree-policy-class`, `fluree-identity`, or
inline `opts.policy` go through a no-policy fast path that
bypasses the cross-ledger dispatch. A configured `f:policySource`
in `#config` is **not** enough on its own to force enforcement at
the HTTP query layer today.

To engage cross-ledger policy on an HTTP query, send a request
with at least one of:

- `fluree-policy-class: <iri>` — the policy class header (the
cleanest way to declare "use the configured policy"). Matching
the class in D's config (e.g., `f:AccessPolicy`) is the
natural choice.
- `fluree-identity: <iri>` — an identity header. Identity-mode
has a different contract; see below.
- `fluree-identity: <iri>` — an identity header. Under
cross-ledger the identity is bind-only; see
[Identity binding](#identity-binding-under-cross-ledger-policy).
- `opts.policy` in the body — inline JSON-LD policy. This still
merges with cross-ledger rules.

When using the in-process Rust API, calling
`fluree.db_with_policy(ledger_id, &opts)` always engages the
policy path, even with empty opts. Programmatic users don't see
this gating.
this gating. The write-side equivalent is
`build_transact_policy_context` — see
[Programmatic policy API (Rust)](programmatic-policy.md).

## Identity binding under cross-ledger policy

An identity on the request (`fluree-identity` header,
`opts.identity`, or a verified credential's DID) is **bind-only**
under a cross-ledger `f:policySource`:

- The identity resolves against **D** (identities are a
data-ledger concept — M never contributes identity records) and
populates `?$identity` for any `f:query` rules in M's policy
set. An owner-only rule authored in M therefore works across
every governed data ledger, with each D binding its own
identities.
- The identity **never selects rules**. Same-ledger identity-mode
loads policies via the identity's `f:policyClass` triples;
under cross-ledger those D-local triples are intentionally not
consulted — declaring a cross-ledger `f:policySource` makes M
the policy authority, and rule selection is exclusively the
policy-class filter chain:

1. the request's `policy_class` (when present),
2. else the config's `f:policyClass`,
3. else — for anonymous requests only — `{f:AccessPolicy}`.

- Because the identity can't select rules, an identity-carrying
request with **no policy class anywhere** (request or config)
fails closed: the operator must name which classes govern.
In practice, setting `f:policyClass` in D's config (as in the
configuration example above) makes authenticated requests work
with no per-request changes.
- An identity IRI with no subject node in D yields an unbound
`?$identity`: `f:query` rules referencing it match nothing, so
`f:required` rules deny — the same contract as same-ledger
identity-mode's unknown-identity case.

One merge subtlety: an identity counts as a request policy input,
so under the default `f:overrideControl` (`f:OverrideAll`) the
request's options take precedence and the config's
`f:defaultAllow` is **not** merged for identity-carrying requests
(same long-standing contract as same-ledger reads). Send the
`fluree-default-allow` header explicitly, or set a stricter
override control if the config should always win.

## Cross-ledger uniqueness constraints

Expand Down Expand Up @@ -451,7 +507,7 @@ closed when configured:
| `f:atT` (temporal pinning of M) | Request fails with `UnsupportedFeature { feature: "f:atT", phase: "Phase 3" }`. |
| `f:trustPolicy` (commit-signer allowlist) | Request fails with `UnsupportedFeature`. |
| `f:rollbackGuard` (freshness constraints) | Request fails with `UnsupportedFeature`. |
| `opts.identity` + cross-ledger `f:policySource` | Request fails with a config error. Identity-mode loads policies via the identity's `f:policyClass` triples, which would have to resolve in D (the identity isn't an M concept); combining the two modes ambiguously is rejected rather than silently choosing one. Use `opts.policy_class` with cross-ledger configs. |
| `opts.identity` + cross-ledger `f:policySource` **with no policy class anywhere** | Request fails with a config error. The identity is bind-only under cross-ledger (see [Identity binding](#identity-binding-under-cross-ledger-policy)) and can't select rules, so a policy class must be named on the request or in D's config. With a class available, identity-carrying requests work normally. |
| `f:policySource` with `f:graphSelector` naming M's `#config` or `#txn-meta` | Request fails with `ReservedGraphSelected` before any storage read on M. |
| Transitive `owl:imports` across model ledgers (`f:schemaSource` recursion) | Not yet honored. Imports inside M's schema graph are projected but the resolver doesn't follow them across ledger boundaries. |

Expand Down
12 changes: 12 additions & 0 deletions docs/security/policy-in-transactions.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ Crucially, the policy is checked against the **flakes**, not the operation type.

Enforcement is also independent of the **wire format**: the check runs on the staged flakes, so JSON-LD, SPARQL UPDATE, and Turtle / TriG / N-Triples writes are all governed by the same `f:modify` policy. Sending data as Turtle is not a way to bypass write policy.

## Config-driven write enforcement

The ledger's `#config` graph governs writes the same way it governs reads:

- **Policy defaults apply without request inputs.** When `f:policyDefaults` declares `f:policyClass` (and optionally `f:defaultAllow`), transactions build a policy context from those defaults even when the request carries no `fluree-identity` / `fluree-policy-class` headers or inline `opts.policy`. A ledger configured with a modify-deny rule rejects violating writes from anonymous requests, matching read-side behavior.
- **`f:policySource` redirects the rule lookup.** Policy rules relocated into a named graph (or a cross-ledger model ledger via `f:ledger`) are loaded from the configured source at transaction time — never silently from the default graph. Unknown graph selectors fail closed.
- **Cross-ledger sources always engage.** A cross-ledger `f:policySource` builds a policy context unconditionally (mirroring the read path): the model ledger's `f:modify` rules apply to every transaction against the data ledger. See [Cross-ledger policy](cross-ledger-policy.md).
- **Identities are bind-only under cross-ledger.** A request identity (header, `opts.identity`, or a verified credential's DID) resolves against the data ledger and populates `?$identity` for the model ledger's `f:query` rules — it never selects rules the way same-ledger identity-mode does. Rule selection is the policy-class chain (request `policy_class` → config `f:policyClass` → `{f:AccessPolicy}` for anonymous requests). An identity-carrying request with no policy class anywhere fails closed; declaring `f:policyClass` in the config makes authenticated writes work with no per-request changes. See [Cross-ledger policy → Identity binding](cross-ledger-policy.md#identity-binding-under-cross-ledger-policy).
- **Override control gates request-time overrides.** A request that supplies its own policy inputs replaces the config defaults only when the config's `f:overrideControl` permits it — see [Override control](../ledger-config/override-control.md).

This applies uniformly across the server transact routes (local and Raft consensus), push replication, credentialed transactions, and the CLI's local mode with policy flags.

## Targeting patterns

### Whitelist a property to a role
Expand Down
24 changes: 23 additions & 1 deletion docs/security/programmatic-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,29 @@ When multiple policies match a flake, they are combined using **Deny Overrides**

## Transactions with Policy

Policies can also be applied to transactions using the builder API:
Policies can also be applied to transactions using the builder API. The
recommended entry point is `build_transact_policy_context`, which honors
the ledger's `#config` graph the same way the server transact path does:
it merges config policy defaults (`f:policyClass`, `f:defaultAllow`) into
the supplied options and resolves `f:policySource` — same-ledger named
graphs and cross-ledger model references — before building the context.
It returns `None` when neither the request nor the config supplies any
policy input (run as root):

```rust
let policy_ctx = fluree_db_api::build_transact_policy_context(
&fluree,
&ledger.snapshot,
ledger.novelty.as_ref(),
Some(ledger.novelty.as_ref()),
ledger.t(),
&qc_opts,
).await?; // -> Option<PolicyContext>
```

The lower-level `build_policy_context_from_opts` remains available when
you want to control the policy graphs yourself (it does no config
resolution and no defaults merge):

```rust
use fluree_db_api::policy_builder;
Expand Down
20 changes: 18 additions & 2 deletions fluree-db-api/src/block_fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,11 +381,27 @@ pub async fn apply_policy_filter(

let overlay: &dyn OverlayProvider = &NoOverlay;

let policy_ctx =
policy_builder::build_policy_context_from_opts(snapshot, overlay, None, to_t, &opts, &[0])
// Config-aware: a configured `f:policySource` redirects the policy-rule
// lookup to the declared graph. No `Fluree` handle is available on this
// path, so a cross-ledger `f:policySource` fails closed here (the
// resolver rejects `f:ledger`) rather than silently falling back to the
// default graph.
let policy_graphs =
crate::policy_view::resolve_policy_graphs_from_config(snapshot, overlay, to_t)
.await
.map_err(|e| BlockFetchError::PolicyBuild(e.to_string()))?;

let policy_ctx = policy_builder::build_policy_context_from_opts(
snapshot,
overlay,
None,
to_t,
&opts,
&policy_graphs,
)
.await
.map_err(|e| BlockFetchError::PolicyBuild(e.to_string()))?;

if policy_ctx.wrapper().is_root() {
return Ok((flakes, false));
}
Expand Down
22 changes: 13 additions & 9 deletions fluree-db-api/src/commit_transfer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
use crate::dataset::GovernanceOptions;
use crate::error::{ApiError, Result};
use crate::ledger_manager::LedgerWriteGuard;
use crate::policy_builder::build_policy_context_from_opts;
use crate::tx::{IndexingMode, IndexingStatus};
use crate::{Fluree, IndexConfig, LedgerHandle};
use base64::Engine as _;
Expand Down Expand Up @@ -421,15 +420,16 @@ impl Fluree {

// 4.2 Policy enforcement: build policy context from opts against current state.
let policy_ctx =
build_policy_ctx_for_push(&base_state, &evolving_novelty, current_t, opts).await?;
build_policy_ctx_for_push(self, &base_state, &evolving_novelty, current_t, opts)
.await?;

// 4.3 Stage flakes (policy/backpressure). No WHERE/cancellation; flakes are prebuilt.
let evolving_state = base_state.clone_with_novelty(Arc::new(evolving_novelty.clone()));
let staged_view = stage_commit_flakes(
evolving_state,
&c.commit.flakes,
index_config,
&policy_ctx,
policy_ctx.as_ref(),
&routing.graph_sids,
)
.await
Expand Down Expand Up @@ -885,19 +885,23 @@ fn validate_required_blobs(
}

async fn build_policy_ctx_for_push(
fluree: &Fluree,
base: &LedgerState,
evolving: &Novelty,
current_t: i64,
opts: &GovernanceOptions,
) -> Result<PolicyContext> {
// Build policy context from opts against current state (db + evolving novelty).
build_policy_context_from_opts(
) -> Result<Option<PolicyContext>> {
// Build policy context from opts merged with the ledger's #config policy
// defaults, against current state (db + evolving novelty). Config-aware:
// honors f:policySource (same-ledger named graphs and cross-ledger model
// references) instead of assuming policy rules live in the default graph.
crate::policy_view::build_transact_policy_context(
fluree,
&base.snapshot,
evolving,
Some(evolving),
current_t,
opts,
&[0],
)
.await
}
Expand All @@ -906,13 +910,13 @@ async fn stage_commit_flakes(
ledger: LedgerState,
flakes: &[Flake],
index_config: &IndexConfig,
policy_ctx: &PolicyContext,
policy_ctx: Option<&PolicyContext>,
graph_sids: &HashMap<GraphId, Sid>,
) -> std::result::Result<fluree_db_ledger::StagedLedger, PushError> {
let mut options = fluree_db_transact::StageOptions::new()
.with_index_config(index_config)
.with_graph_sids(graph_sids);
if !policy_ctx.wrapper().is_root() {
if let Some(policy_ctx) = policy_ctx.filter(|p| !p.wrapper().is_root()) {
options = options.with_policy(policy_ctx);
}
fluree_db_transact::stage_flakes(ledger, flakes.to_vec(), options)
Expand Down
15 changes: 10 additions & 5 deletions fluree-db-api/src/graph_commit_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use crate::dataset::GovernanceOptions;
use crate::format::iri::IriCompactor;
use crate::graph::Graph;
use crate::ledger_view::CommitRef;
use crate::{policy_builder, ApiError, Result};
use crate::{ApiError, Result};
use fluree_db_core::commit::codec::read_commit;
use fluree_db_core::{ContentId, ContentStore, FlakeValue, OverlayProvider, Tracker};
use fluree_db_novelty::Commit;
Expand Down Expand Up @@ -282,19 +282,24 @@ impl<'a, 'g> CommitBuilder<'a, 'g> {
..Default::default()
};
// Use the novelty overlay so policy rules in uncommitted
// transactions are visible to the policy builder.
// transactions are visible to the policy builder. Config-aware:
// a configured `f:policySource` (same-ledger named graph or
// cross-ledger model reference) redirects the policy-rule
// lookup instead of assuming the default graph. The
// identity/policy_class gate above is unchanged — commit-detail
// filtering stays opt-in per request.
let overlay: &dyn OverlayProvider = snapshot.novelty.as_ref();
let policy_ctx = policy_builder::build_policy_context_from_opts(
let policy_ctx = crate::policy_view::build_transact_policy_context(
self.graph.fluree,
&snapshot.snapshot,
overlay,
Some(snapshot.novelty.as_ref()),
commit.t,
&opts,
&[0],
)
.await?;

if !policy_ctx.wrapper().is_root() {
if let Some(policy_ctx) = policy_ctx.filter(|p| !p.wrapper().is_root()) {
let enforcer = QueryPolicyEnforcer::new(Arc::new(policy_ctx));
let tracker = Tracker::disabled();

Expand Down
Loading