Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- [update](cli/update.md)
- [query](cli/query.md)
- [validate](cli/validate.md)
- [model](cli/model.md)
- [multi-query](cli/multi-query.md)
- [history](cli/history.md)
- [export](cli/export.md)
Expand Down
1 change: 1 addition & 0 deletions docs/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ fluree query 'SELECT ?name WHERE { ?s <http://example.org/name> ?name }'
| [`load`](load.md) | Stream a CSV into a ledger as batched Cypher/JSON-LD upserts (`LOAD CSV`) |
| [`query`](query.md) | Query a ledger |
| [`validate`](validate.md) | Validate data against SHACL shapes (report) |
| [`model`](model.md) | Governance model tooling — access profiles, SHACL entity shapes, class hierarchy |
| [`history`](history.md) | Show change history for an entity |
| [`export`](export.md) | Export ledger data |
| [`log`](log.md) | Show commit log |
Expand Down
125 changes: 125 additions & 0 deletions docs/cli/model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# fluree model

Governance model tooling — declare what things are, who may do what, and how classes relate, as ordinary data in the target ledger.

A governance model has three facets:

| Facet | Subcommand | Compiles to |
|-------|------------|-------------|
| **Entity** (what things are) | `model entity` | SHACL node shapes |
| **Access** (who may do what) | `model access` | Thin class-targeted policies using write verbs |
| **Reasoning** (what follows) | `model class` | `rdfs:Class` / `rdfs:subClassOf` vocabulary |

The commands are **compilers to data**: they transform declared intent into ordinary JSON-LD transactions and queries against the target ledger. There is no bespoke server API behind them, so they work identically against local ledgers, `fluree-db-server`, and hosted stacks. There is no stored intent language and nothing to sync — the compiled artifacts are small, self-describing, and hand-editing them afterward is legitimate authorship, not drift.

## Usage

```bash
fluree model <FACET> <COMMAND> [OPTIONS]
```

All write commands accept `--dry-run` (print the compiled JSON-LD without transacting) and `--remote <r>` (run against a configured remote instead of the local ledger).

## fluree model access

Compile an access profile into policies on a dataset.

```bash
fluree model access enable <DATASET> --profile <read|write|intake> --class <IRI> [OPTIONS]
fluree model access show <DATASET>
```

Profiles map intent onto the policy engine's write verbs (`f:create` / `f:update` / `f:delete`):

| Profile | Grants | Compiled policies |
|---------|--------|-------------------|
| `read` | View instances of the class | View policy (`f:onClass` + `f:allow`, or an `f:query` relationship gate) |
| `write` | Full instance ownership: view + create/update/delete | The view policy plus a write policy carrying `f:action [create, update, delete]` on the class |
| `intake` | Submit without read-back (the anonymous-form shape) | A create-only policy |

Verb semantics make the class grant exact: class targeting matches pre ∪ post state, and `rdf:type` writes match by the class they mint, so a Lead grant can never create a Contract. No property allow-list is needed — the property *surface* of a class belongs to its SHACL shape (`model entity define --closed`); validation owns "what a valid X looks like", policy owns "who may cause which state transitions".

### Options

| Option | Description |
|--------|-------------|
| `--profile <p>` | `read`, `write`, or `intake` |
| `--class <iri>` | Target class IRI whose instances the profile governs (absolute) — compiles to `f:onClass` |
| `--property <iri>` | Narrow the **write** policy to a column set (`f:onProperty` conjunction; repeatable) — "may edit status of Leads, nothing else" |
| `--connected <path>` | Relationship gate (**read** profile only): a SPARQL property path from the requesting identity to the instance, e.g. `"^<https://example.org/owner>"` (I see what I own). Stored verbatim in the policy via the engine's `@path` context term |
| `--policy-class <iri>` | Policy class IRI override (default `{class}/access/{profile}`). The policy class is the *assignment unit* grants and tokens carry — how a request selects its policy set, not a data restriction |
| `--space <id>` | Attach the policy class to this space's grant on the dataset (hosted stacks; requires `--remote`). Merges with existing grant classes, never clobbers |
| `--dry-run` | Print the compiled JSON-LD without transacting |

Three class-shaped things are in play, with distinct roles: `--class` restricts *what data* is governed, `--property` optionally narrows it to columns, and the **policy class** selects *which policy set a request runs under* (via space grants / token `policy-class`).

### Semantics of re-running

`enable` **replaces** the two policy nodes it owns (`{policy-class}/view`, `{policy-class}/write`) atomically: one transaction wildcard-deletes both ids and inserts the fresh compilation. A property from a previous run cannot linger (the policy loader gives `f:allow` precedence over `f:query`, so a stale `f:allow: true` would silently disable a newly added `--connected` gate), and profile switches on the same policy class are exact — switching `write` → `read` revokes `{policy-class}/write`.

### Examples

```bash
# Apps may fully own Lead instances
fluree model access enable crm --profile write --class https://example.org/Lead

# Column-narrowed write: may edit status of Leads, nothing else
fluree model access enable crm --profile write --class https://example.org/Lead \
--property https://example.org/status

# Relationship-gated read: I see Leads whose team I'm a member of
fluree model access enable crm --profile read --class https://example.org/Lead \
--connected "<https://example.org/memberOf>/^<https://example.org/team>"

# Hosted stack: transact the policies AND attach the policy class to a space grant
fluree model access enable crm --profile write --class https://example.org/Lead \
--space 01hx... --remote prod
```

## fluree model entity

Define an entity as a SHACL node shape — the single source of truth for the property surface of a class.

```bash
fluree model entity define <DATASET> --entity <IRI> --property "<spec>" [--property "<spec>"...] [OPTIONS]
fluree model entity show <DATASET>
```

Each `--property` spec is `"<iri> [type] [required] [in[v1,v2,...]]"` where type is one of `string`, `integer`, `decimal`, `boolean`, `date`, `datetime`, `iri` (omitted = untyped).

| Option | Description |
|--------|-------------|
| `--entity <iri>` | Entity class IRI (absolute) |
| `--property "<spec>"` | Property spec (repeatable, at least one) |
| `--label <text>` | Human label for the class |
| `--closed` | Closed shape: instances may carry ONLY the declared properties (`rdf:type` excepted). Recommended for app-writable entities |
| `--dry-run` | Print the compiled JSON-LD without transacting |

> **Enforcement note:** Fluree runs SHACL at transaction time once any shapes exist in a ledger (reject mode by default). Defining an entity is not just documentation — it activates validation for its class. Existing data is not retro-validated; `fluree validate` produces a full report.

Re-running `define` replaces the shape and its property shapes atomically — constraints from a previous definition (`sh:closed`, `sh:minCount`, `sh:in`, …) do not survive a re-run that dropped them. The entity class node itself is shared authorship with `model class define` and stays additive.

```bash
fluree model entity define crm --entity https://example.org/Lead --closed \
--property "https://example.org/name string required" \
--property "https://example.org/status string in[new,qualified,won,lost]" \
--property "https://example.org/owner iri"
```

## fluree model class

Define classes and their `rdfs:subClassOf` hierarchy.

```bash
fluree model class define <DATASET> --class <IRI> [--subclass-of <IRI>...] [--label <text>] [OPTIONS]
fluree model class show <DATASET>
```

When RDFS entailment is enabled on a dataset, the engine follows the hierarchy in **both query and policy**: a view policy on `ex:Contact` covers `ex:Lead` if `ex:Lead rdfs:subClassOf ex:Contact`. A widened hierarchy widens every grant on the parent — which is why the hierarchy lives under governance tooling and not ad-hoc transacts.

Re-running `define` with `--subclass-of` replaces the parent set (list every parent each time). `show` lists domain classes only — policy classes minted by `model access` are excluded.

```bash
fluree model class define crm --class https://example.org/Lead \
--subclass-of https://example.org/Contact --label "Lead"
```
242 changes: 242 additions & 0 deletions fluree-db-api/tests/it_policy_fquery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -554,3 +554,245 @@ async fn required_policies_are_and_gated() {
"required AND gate must be order-independent, got {rows_b:?}"
);
}

/// Tests that a `@path` context term (property path) works inside `f:query`,
/// exercised through the production shape: policies STORED in the ledger
/// under a policy class, request carrying `identity` (bind-only) +
/// `policy-class` (selects the policy set).
///
/// This is the storage form governance tooling uses for relationship gates
/// ("I can see entities I'm connected to via path P"): the author's path
/// expression is kept verbatim as an `@path` term in the policy query's own
/// `@context`, instead of being pre-expanded client-side into anchored
/// where-patterns. Enforcement must follow the path — here
/// `ex:memberOf/^ex:team`: the identity is a member of a team, and the
/// entity belongs (via `ex:team`) to that same team.
#[tokio::test]
async fn policy_fquery_with_path_context_term() {
assert_index_defaults();
let fluree = FlureeBuilder::memory().build_memory();

let ledger_id = "policy/fquery-path:main";
let ledger0 = genesis_ledger(&fluree, ledger_id);

let u1 = "http://example.org/identity/u1";
let u2 = "http://example.org/identity/u2";

// Angle-bracket absolute-IRI form — exactly what `fluree model access
// enable --connected` stores (the path survives verbatim in @path).
let gate = serde_json::to_string(&json!({
"@context": {
"connected": {
"@path": "<http://example.org/ns/memberOf>/^<http://example.org/ns/team>"
}
},
"where": [
{ "@id": "?$identity", "connected": { "@id": "?$this" } }
]
}))
.unwrap();

let txn = json!({
"@context": {
"ex": "http://example.org/ns/",
"f": "https://ns.flur.ee/db#"
},
"@graph": [
{ "@id": "ex:teamA", "@type": "ex:Team", "ex:name": "Alpha" },
{ "@id": "ex:teamB", "@type": "ex:Team", "ex:name": "Beta" },
{ "@id": u1, "ex:memberOf": { "@id": "ex:teamA" } },
{ "@id": u2, "ex:memberOf": { "@id": "ex:teamB" } },
{
"@id": "ex:item-alpha",
"@type": "ex:Item",
"ex:name": "Alpha Item",
"ex:team": { "@id": "ex:teamA" }
},
{
"@id": "ex:item-beta",
"@type": "ex:Item",
"ex:name": "Beta Item",
"ex:team": { "@id": "ex:teamB" }
},
// The gate as governance tooling stores it: a classed view
// policy whose f:query holds ONE triple over an @path term —
// the author's path string survives verbatim in the artifact.
{
"@id": "ex:connected-view",
"@type": ["f:AccessPolicy", "ex:ConnectedAccess"],
"f:action": { "@id": "f:view" },
"f:query": gate
}
]
});
let _ = fluree.insert(ledger0, &txn).await.expect("insert");

let items_visible_to = |identity: &'static str| {
let query = json!({
"@context": { "ex": "http://example.org/ns/" },
"from": ledger_id,
"opts": {
"identity": identity,
"policy-class": ["http://example.org/ns/ConnectedAccess"],
"default-allow": false
},
"select": "?name",
"where": [{ "@id": "?item", "@type": "ex:Item", "ex:name": "?name" }]
});
let fluree = &fluree;
async move {
let result = fluree.query_connection(&query).await.expect("query");
let ledger = fluree.ledger(ledger_id).await.expect("ledger");
normalize_rows(&result.to_jsonld(&ledger.snapshot).expect("to_jsonld"))
}
};

assert_eq!(
items_visible_to(u1).await,
normalize_rows(&json!(["Alpha Item"])),
"u1 (member of teamA) must see only teamA's item through the path gate"
);
assert_eq!(
items_visible_to(u2).await,
normalize_rows(&json!(["Beta Item"])),
"u2 (member of teamB) must see only teamB's item through the path gate"
);
}

/// Re-running `fluree model access enable` with `--connected` must actually
/// narrow a previously allow-all read profile. The CLI installs policies with
/// an atomic replace transaction (wildcard-delete the owned policy ids +
/// insert the fresh compilation) — byte-for-byte the shape tested here.
///
/// This pins the failure mode the replace semantics exist to prevent: the
/// policy loader gives `f:allow` precedence over `f:query`, so a
/// property-merge upsert that left the old `f:allow: true` on the view
/// policy would silently disable the new relationship gate and keep the
/// policy allow-all.
#[tokio::test]
async fn policy_replace_swaps_allow_for_path_gate() {
assert_index_defaults();
let fluree = FlureeBuilder::memory().build_memory();

let ledger_id = "policy/replace-gate:main";
let ledger0 = genesis_ledger(&fluree, ledger_id);

let u1 = "http://example.org/identity/u1";
let class = "http://example.org/ns/Item/access/read";
let view_id = format!("{class}/view");
let write_id = format!("{class}/write");

// Data + the scaffolder's FIRST compilation: a plain read profile
// (f:allow: true view policy on the class).
let seed = json!({
"@context": {
"ex": "http://example.org/ns/",
"f": "https://ns.flur.ee/db#"
},
"@graph": [
{ "@id": "ex:teamA", "@type": "ex:Team" },
{ "@id": "ex:teamB", "@type": "ex:Team" },
{ "@id": u1, "ex:memberOf": { "@id": "ex:teamA" } },
{
"@id": "ex:item-alpha",
"@type": "ex:Item",
"ex:name": "Alpha Item",
"ex:team": { "@id": "ex:teamA" }
},
{
"@id": "ex:item-beta",
"@type": "ex:Item",
"ex:name": "Beta Item",
"ex:team": { "@id": "ex:teamB" }
},
{
"@id": class,
"@type": "http://www.w3.org/2000/01/rdf-schema#Class"
},
{
"@id": view_id,
"@type": ["f:AccessPolicy", class],
"f:action": { "@id": "f:view" },
"f:onClass": { "@id": "ex:Item" },
"f:allow": true
}
]
});
let ledger = fluree.insert(ledger0, &seed).await.expect("seed").ledger;

let items_visible_to_u1 = || {
let query = json!({
"@context": { "ex": "http://example.org/ns/" },
"from": ledger_id,
"opts": {
"identity": u1,
"policy-class": [class],
"default-allow": false
},
"select": "?name",
"where": [{ "@id": "?item", "@type": "ex:Item", "ex:name": "?name" }]
});
let fluree = &fluree;
async move {
let result = fluree.query_connection(&query).await.expect("query");
let ledger = fluree.ledger(ledger_id).await.expect("ledger");
normalize_rows(&result.to_jsonld(&ledger.snapshot).expect("to_jsonld"))
}
};

assert_eq!(
items_visible_to_u1().await,
normalize_rows(&json!(["Alpha Item", "Beta Item"])),
"plain read profile must be allow-all before the switch"
);

// The SECOND compilation: same profile with --connected. The CLI's
// replace transaction — optional wildcard match + delete on both owned
// policy ids, insert the fresh nodes ({class}/write is owned even
// though read never emits it).
let gate = serde_json::to_string(&json!({
"@context": {
"connected": {
"@path": "<http://example.org/ns/memberOf>/^<http://example.org/ns/team>"
}
},
"where": [
{ "@id": "?$identity", "connected": { "@id": "?$this" } }
]
}))
.unwrap();
let replace = json!({
"@context": { "f": "https://ns.flur.ee/db#" },
"where": [
["optional", { "@id": view_id, "?p0": "?o0" }],
["optional", { "@id": write_id, "?p1": "?o1" }]
],
"delete": [
{ "@id": view_id, "?p0": "?o0" },
{ "@id": write_id, "?p1": "?o1" }
],
"insert": [
{
"@id": class,
"@type": "http://www.w3.org/2000/01/rdf-schema#Class"
},
{
"@id": view_id,
"@type": ["f:AccessPolicy", class],
"f:action": { "@id": "f:view" },
"f:onClass": { "@id": "http://example.org/ns/Item" },
"f:query": gate
}
]
});
let _ = fluree.update(ledger, &replace).await.expect("replace");

// The stale f:allow is gone, so the gate governs: u1 sees only the
// item connected to their team.
assert_eq!(
items_visible_to_u1().await,
normalize_rows(&json!(["Alpha Item"])),
"after the switch the relationship gate must govern — a stale \
f:allow would keep this allow-all"
);
}
Loading
Loading