diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index f37b634225..cfed94cd4f 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -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) diff --git a/docs/cli/README.md b/docs/cli/README.md index 210f4cab05..a9dc67c016 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -61,6 +61,7 @@ fluree query 'SELECT ?name WHERE { ?s ?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 | diff --git a/docs/cli/model.md b/docs/cli/model.md new file mode 100644 index 0000000000..bb2602417b --- /dev/null +++ b/docs/cli/model.md @@ -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 [OPTIONS] +``` + +All write commands accept `--dry-run` (print the compiled JSON-LD without transacting) and `--remote ` (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 --profile --class [OPTIONS] +fluree model access show +``` + +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

` | `read`, `write`, or `intake` | +| `--class ` | Target class IRI whose instances the profile governs (absolute) — compiles to `f:onClass` | +| `--property ` | Narrow the **write** policy to a column set (`f:onProperty` conjunction; repeatable) — "may edit status of Leads, nothing else" | +| `--connected ` | Relationship gate (**read** profile only): a SPARQL property path from the requesting identity to the instance, e.g. `"^"` (I see what I own). Stored verbatim in the policy via the engine's `@path` context term | +| `--policy-class ` | 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 ` | 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 "/^" + +# 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 --entity --property "" [--property ""...] [OPTIONS] +fluree model entity show +``` + +Each `--property` spec is `" [type] [required] [in[v1,v2,...]]"` where type is one of `string`, `integer`, `decimal`, `boolean`, `date`, `datetime`, `iri` (omitted = untyped). + +| Option | Description | +|--------|-------------| +| `--entity ` | Entity class IRI (absolute) | +| `--property ""` | Property spec (repeatable, at least one) | +| `--label ` | 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 --class [--subclass-of ...] [--label ] [OPTIONS] +fluree model class show +``` + +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" +``` diff --git a/fluree-db-api/tests/it_policy_fquery.rs b/fluree-db-api/tests/it_policy_fquery.rs index 0e1699eca2..e76d3c5e06 100644 --- a/fluree-db-api/tests/it_policy_fquery.rs +++ b/fluree-db-api/tests/it_policy_fquery.rs @@ -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": "/^" + } + }, + "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": "/^" + } + }, + "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" + ); +} diff --git a/fluree-db-api/tests/it_policy_verbs.rs b/fluree-db-api/tests/it_policy_verbs.rs index 14ae3edb51..49c5242ecd 100644 --- a/fluree-db-api/tests/it_policy_verbs.rs +++ b/fluree-db-api/tests/it_policy_verbs.rs @@ -729,3 +729,130 @@ async fn post_state_condition_sees_staged_flakes() { "pre-state (default) condition must not see staged flakes" ); } + +/// End-to-end contract for `fluree model access enable --profile write`: +/// the scaffolder's exact output — a policy class + a thin view policy + +/// one create/update/delete policy on the class, STORED in the ledger and +/// selected via `policy-class` with a bind-only identity — grants full +/// instance ownership of the class and nothing else. No property +/// allow-list, no rdf:type entry: verb semantics alone make it exact. +#[tokio::test] +async fn scaffolder_write_profile_grants_class_ownership_only() { + assert_index_defaults(); + let fluree = FlureeBuilder::memory().build_memory(); + let ledger = seed(&fluree, "verbs_scaffolder_write").await; + + // Byte-for-byte the shape `fluree model access enable` emits, hand-copied + // because importing the CLI compiler here would invert the crate + // dependency. SYNC with fluree-db-cli + // access::tests::write_profile_emits_class_view_and_verb_policy — if + // `access::compile()` changes, update both. + let class = "http://example.org/ns/Lead/access/write"; + let stored = json!({ + "@context": {"f": "https://ns.flur.ee/db#"}, + "@graph": [ + { + "@id": class, + "@type": "http://www.w3.org/2000/01/rdf-schema#Class", + "http://www.w3.org/2000/01/rdf-schema#label": "write access: http://example.org/ns/Lead" + }, + { + "@id": format!("{class}/view"), + "@type": ["f:AccessPolicy", class], + "f:action": {"@id": "f:view"}, + "f:onClass": {"@id": "http://example.org/ns/Lead"}, + "f:allow": true + }, + { + "@id": format!("{class}/write"), + "@type": ["f:AccessPolicy", class], + "f:action": [ + {"@id": "f:create"}, + {"@id": "f:update"}, + {"@id": "f:delete"} + ], + "f:onClass": {"@id": "http://example.org/ns/Lead"}, + "f:allow": true + } + ] + }); + let ledger = fluree + .insert(ledger, &stored) + .await + .expect("store scaffolder policies") + .ledger; + + // Production selection shape: bind-only identity + policy-class. + let qc_opts = GovernanceOptions { + identity: Some("app-user@example.com".to_string()), + policy_class: Some(vec![class.to_string()]), + default_allow: false, + ..Default::default() + }; + let ctx = policy_builder::build_policy_context_from_opts( + &ledger.snapshot, + ledger.novelty.as_ref(), + Some(ledger.novelty.as_ref()), + ledger.t(), + &qc_opts, + &[0], + ) + .await + .expect("build policy context"); + + // 1. Creating a new Lead (with a never-seen property) is allowed. + let create_lead = json!({ + "@context": {"ex": "http://example.org/ns/"}, + "@id": "ex:lead9", + "@type": "ex:Lead", + "ex:name": "Scaffolded", + "ex:score": 42 + }); + let ledger = try_txn(&fluree, ledger, TxnType::Insert, &create_lead, &ctx) + .await + .expect("write profile must allow creating a Lead"); + + // 2. Updating an existing Lead is allowed. + let update_lead = json!({ + "@context": {"ex": "http://example.org/ns/"}, + "where": [{"@id": "ex:lead1", "ex:stage": "?s"}], + "delete": [{"@id": "ex:lead1", "ex:stage": "?s"}], + "insert": [{"@id": "ex:lead1", "ex:stage": "qualified"}] + }); + let ledger = try_txn(&fluree, ledger, TxnType::Update, &update_lead, &ctx) + .await + .expect("write profile must allow updating a Lead"); + + // 3. Minting a different class is denied (class-mint constraint). + let create_person = json!({ + "@context": {"ex": "http://example.org/ns/"}, + "@id": "ex:mallory", + "@type": "ex:Person", + "ex:nickname": "Mallory" + }); + let denied = try_txn( + &fluree, + ledger.clone(), + TxnType::Insert, + &create_person, + &ctx, + ) + .await; + assert!( + denied.is_err(), + "Lead write profile must NOT mint a Person: {denied:?}" + ); + + // 4. Updating an instance of another class is denied. + let update_person = json!({ + "@context": {"ex": "http://example.org/ns/"}, + "where": [{"@id": "ex:bob", "ex:nickname": "?n"}], + "delete": [{"@id": "ex:bob", "ex:nickname": "?n"}], + "insert": [{"@id": "ex:bob", "ex:nickname": "Robert"}] + }); + let denied = try_txn(&fluree, ledger, TxnType::Update, &update_person, &ctx).await; + assert!( + denied.is_err(), + "Lead write profile must NOT edit a Person: {denied:?}" + ); +} diff --git a/fluree-db-cli/src/cli.rs b/fluree-db-cli/src/cli.rs index 35caceab44..da79ba2945 100644 --- a/fluree-db-cli/src/cli.rs +++ b/fluree-db-cli/src/cli.rs @@ -1094,6 +1094,12 @@ pub enum Commands { action: ServerAction, }, + /// Governance model tooling — access profiles, entities, reasoning + Model { + #[command(subcommand)] + action: ModelAction, + }, + /// Developer memory — store and recall facts, decisions, constraints Memory { #[command(subcommand)] @@ -1483,6 +1489,201 @@ pub enum ClusterAction { }, } +/// Governance model subcommands. +#[derive(Subcommand)] +pub enum ModelAction { + /// Access control — compile intent into ledger policies + Access { + #[command(subcommand)] + action: ModelAccessAction, + }, + + /// Entity definitions — author SHACL shapes (the single source of truth + /// that access profiles, validation, and codegen derive from) + Entity { + #[command(subcommand)] + action: ModelEntityAction, + }, + + /// Class hierarchy — RDFS subclass relations (the reasoning facet's + /// vocabulary; entailment follows rdfs:subClassOf in query and policy) + Class { + #[command(subcommand)] + action: ModelClassAction, + }, +} + +/// Class-facet subcommands of `fluree model`. +#[derive(Subcommand)] +pub enum ModelClassAction { + /// Define (or update) a class and its place in the hierarchy + Define { + /// Target dataset (ledger alias) + dataset: String, + + /// Class IRI (absolute, e.g. https://example.org/Lead) + #[arg(long)] + class: String, + + /// Parent class IRI (repeatable) — becomes rdfs:subClassOf + #[arg(long = "subclass-of")] + subclass_of: Vec, + + /// Remove ALL parents (deletes every rdfs:subClassOf edge). With + /// RDFS entailment, a stale parent widens every grant on it — use + /// this to sever the hierarchy; --subclass-of cannot express empty. + #[arg(long = "clear-subclass-of", conflicts_with = "subclass_of")] + clear_subclass_of: bool, + + /// Human label for the class + #[arg(long)] + label: Option, + + /// Print the compiled JSON-LD without transacting + #[arg(long)] + dry_run: bool, + + /// Remote to run against + #[arg(long)] + remote: Option, + }, + + /// Show the class hierarchy on a dataset (policy classes excluded) + Show { + /// Target dataset (ledger alias) + dataset: String, + + /// Remote to run against + #[arg(long)] + remote: Option, + }, +} + +/// Entity-facet subcommands of `fluree model`. +#[derive(Subcommand)] +pub enum ModelEntityAction { + /// Define (or update) an entity: compiles to a SHACL node shape + /// + /// NOTE: Fluree enforces SHACL at transaction time once any shapes exist + /// in a ledger (reject mode by default) — defining an entity activates + /// validation for its class. + Define { + /// Target dataset (ledger alias) + dataset: String, + + /// Entity class IRI (absolute, e.g. https://example.org/Lead) + #[arg(long)] + entity: String, + + /// Property spec: " [string|integer|decimal|boolean|date|datetime|iri] [required] [in[v1,v2,...]]" + /// (repeatable; type omitted = untyped) + #[arg(long = "property", required = true)] + properties: Vec, + + /// Human label for the class + #[arg(long)] + label: Option, + + /// Closed shape: instances may carry ONLY the declared properties + /// (rdf:type excepted). Recommended for app-writable entities — + /// validation owns the property surface, so access grants stay + /// thin class policies. + #[arg(long)] + closed: bool, + + /// Print the compiled JSON-LD without transacting + #[arg(long)] + dry_run: bool, + + /// Remote to run against + #[arg(long)] + remote: Option, + }, + + /// Show entity definitions (SHACL node shapes) on a dataset + Show { + /// Target dataset (ledger alias) + dataset: String, + + /// Remote to run against + #[arg(long)] + remote: Option, + }, +} + +/// Access-facet subcommands of `fluree model`. +#[derive(Subcommand)] +pub enum ModelAccessAction { + /// Enable an access profile on a dataset (emits thin verb policies) + /// + /// Declares WHO may cause which state transitions on a class, in the + /// engine's own vocabulary: read → a view policy; write → view + + /// create/update/delete on the class; intake → create-only. Verb + /// semantics are exact (class targeting matches pre ∪ post state and + /// rdf:type writes match the class they mint), so no property + /// allow-list is needed — the property SURFACE of the class belongs + /// to its SHACL shape (`model entity define --closed`). Re-running is + /// idempotent (deterministic policy ids); there is no stored intent + /// node and nothing to sync. + Enable { + /// Target dataset (ledger alias) + dataset: String, + + /// Profile: read | write | intake + #[arg(long)] + profile: String, + + /// Target class IRI whose instances the profile governs (absolute, + /// e.g. https://example.org/Lead) — compiles to f:onClass + #[arg(long)] + class: String, + + /// Optional COLUMN narrowing for the write policy (absolute IRIs): + /// the grant covers only these properties of the class ("may edit + /// status of Leads, nothing else"). Omit for whole-instance access. + #[arg(long = "property")] + properties: Vec, + + /// 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. + #[arg(long)] + policy_class: Option, + + /// Attach the policy class to this space's grant on the dataset + /// (hosted stacks; requires --remote). Merges with existing classes. + #[arg(long)] + space: Option, + + /// Relationship gate (read profile only): a SPARQL property path + /// from the requesting identity to the instance, with angle-bracketed + /// IRIs. e.g. "^" (I see what I own) or + /// "/^" + /// (I see entities whose team I'm a member of). Stored verbatim in + /// the policy via the engine's @path context term. + #[arg(long)] + connected: Option, + + /// Print the compiled JSON-LD without transacting + #[arg(long)] + dry_run: bool, + + /// Remote to run against + #[arg(long)] + remote: Option, + }, + + /// Show compiled access policies on a dataset (grouped by policy class) + Show { + /// Target dataset (ledger alias) + dataset: String, + + /// Remote to run against + #[arg(long)] + remote: Option, + }, +} + /// Memory subcommands. #[derive(Subcommand)] pub enum MemoryAction { diff --git a/fluree-db-cli/src/commands/mod.rs b/fluree-db-cli/src/commands/mod.rs index 8803a9138e..1d8fda9357 100644 --- a/fluree-db-cli/src/commands/mod.rs +++ b/fluree-db-cli/src/commands/mod.rs @@ -22,6 +22,7 @@ pub mod load; pub mod log; pub mod mcp; pub mod memory; +pub mod model; pub mod multi_query; pub mod prefix; pub mod query; diff --git a/fluree-db-cli/src/commands/model/access.rs b/fluree-db-cli/src/commands/model/access.rs new file mode 100644 index 0000000000..47016d682d --- /dev/null +++ b/fluree-db-cli/src/commands/model/access.rs @@ -0,0 +1,703 @@ +//! `fluree model access` — a stateless policy scaffolder. +//! +//! Users declare intent ("apps may write Leads"); this module emits the +//! engine's own vocabulary — nothing else. With the policy engine's write +//! verbs (`f:create` / `f:update` / `f:delete`), intent maps directly to +//! thin class-targeted policies: +//! +//! * **read** → a view policy (`f:onClass` + `f:allow`, or an `f:query` +//! relationship gate); +//! * **write** → the view policy plus a write policy carrying +//! `f:action [create, update, delete]` on the class — full ownership of +//! instances. Verb semantics make this 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. +//! * **intake** → a create-only policy (submit without read-back — the +//! anonymous-form shape). +//! +//! `--property` optionally narrows the write policy to a column set +//! (`f:onProperty` conjunction). The property *surface* of a class is +//! otherwise SHACL's job (`model entity define`, closed shapes) — +//! validation owns "what a valid X looks like", policy owns "who may +//! cause which state transitions". +//! +//! There is NO stored intent language: the compiled policies are small and +//! self-describing, the `--connected` path survives verbatim inside the +//! stored `f:query`'s `@path` context term, and hand-editing a policy is +//! legitimate authorship — not drift. +//! +//! Re-running `enable` REPLACES the two policy nodes it owns +//! (`{class}/view`, `{class}/write`) atomically: one transaction +//! wildcard-deletes both ids and inserts the fresh compilation. Anything +//! less is unsafe — the policy loader gives `f:allow` precedence over +//! `f:query`, so a property-merge that left a stale `f:allow: true` behind +//! would silently disable a newly added `--connected` gate. Owning both +//! ids on every run also makes profile switches on the same class exact +//! (write → read revokes `{class}/write`; intake drops `{class}/view`). + +use serde_json::{json, Value}; + +use super::{query, replace_nodes, require_absolute_iri, resolve_mode}; +use crate::cli::ModelAccessAction; +use crate::error::{CliError, CliResult}; +use fluree_db_api::server_defaults::FlureeDir; + +const F: &str = "https://ns.flur.ee/db#"; +const RDFS_CLASS: &str = "http://www.w3.org/2000/01/rdf-schema#Class"; +const RDFS_LABEL: &str = "http://www.w3.org/2000/01/rdf-schema#label"; + +/// Access profiles: what the compiled policy set permits. +#[derive(Clone, Copy, PartialEq)] +enum Profile { + /// View instances of the class. + Read, + /// Full instance ownership: view + create/update/delete. + Write, + /// Submit without reading back (create-only; the anonymous-form shape). + Intake, +} + +impl Profile { + fn parse(s: &str) -> CliResult { + match s { + "read" => Ok(Self::Read), + "write" => Ok(Self::Write), + "intake" => Ok(Self::Intake), + other => Err(CliError::Usage(format!( + "profile must be read | write | intake (got '{other}')" + ))), + } + } + + fn as_str(&self) -> &'static str { + match self { + Self::Read => "read", + Self::Write => "write", + Self::Intake => "intake", + } + } + + fn wants_view(&self) -> bool { + matches!(self, Self::Read | Self::Write) + } + + /// Write verbs granted by this profile (empty = no write policy). + fn write_verbs(&self) -> &'static [&'static str] { + match self { + Self::Read => &[], + Self::Write => &["create", "update", "delete"], + Self::Intake => &["create"], + } + } +} + +pub async fn run(action: &ModelAccessAction, dirs: &FlureeDir, direct: bool) -> CliResult<()> { + match action { + ModelAccessAction::Enable { + dataset, + profile, + class, + properties, + policy_class, + space, + connected, + dry_run, + remote, + } => { + run_enable( + dataset, + profile, + class, + properties, + policy_class.as_deref(), + space.as_deref(), + connected.as_deref(), + *dry_run, + remote.as_deref(), + dirs, + direct, + ) + .await + } + ModelAccessAction::Show { dataset, remote } => { + run_show(dataset, remote.as_deref(), dirs, direct).await + } + } +} + +// ── enable ────────────────────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +async fn run_enable( + dataset: &str, + profile_str: &str, + class: &str, + properties: &[String], + policy_class_override: Option<&str>, + space: Option<&str>, + connected: Option<&str>, + dry_run: bool, + remote: Option<&str>, + dirs: &FlureeDir, + direct: bool, +) -> CliResult<()> { + let profile = Profile::parse(profile_str)?; + require_absolute_iri("--class", class)?; + for p in properties { + require_absolute_iri("--property", p)?; + } + if space.is_some() && remote.is_none() { + return Err(CliError::Usage( + "--space attaches the policy class to a hosted stack's grant; pass --remote ".into(), + )); + } + if let Some(path) = connected { + if profile != Profile::Read { + return Err(CliError::Usage( + "--connected is supported with --profile read only: relationship \ + gates evaluate the connection triples, which don't exist yet for \ + a subject being created. Verb-scoped write gates are future work." + .into(), + )); + } + validate_connected_path(path)?; + } + if !properties.is_empty() && profile == Profile::Read { + return Err(CliError::Usage( + "--property narrows WRITE policies to a column set; the read profile \ + has no write policy. Property-level read filtering is a policy the \ + engine supports (f:onProperty view) but this scaffolder keeps read \ + class-scoped — author it directly if you need it." + .into(), + )); + } + + let policy_class = policy_class_override.map(String::from).unwrap_or_else(|| { + format!( + "{}/access/{}", + class.trim_end_matches('/'), + profile.as_str() + ) + }); + let graph = compile(&policy_class, class, profile, properties, connected); + + println!("Profile: {} {}", profile.as_str(), class); + println!("Policy class: {policy_class}"); + match profile { + Profile::Read => println!("Grants: view"), + Profile::Write => println!("Grants: view + create/update/delete"), + Profile::Intake => println!("Grants: create only (no read-back)"), + } + if !properties.is_empty() { + println!( + "Columns: write narrowed to {} propert{} (f:onProperty)", + properties.len(), + if properties.len() == 1 { "y" } else { "ies" } + ); + } + if let Some(path) = connected { + println!("Connected: {path} (view gated by relationship to the requesting identity)"); + } + println!( + "note: the property SURFACE of {class} is governed by its SHACL shape\n\ + \x20 (model entity define, ideally --closed) — validation owns what a valid\n\ + \x20 instance looks like; this policy owns who may create/change them." + ); + + if dry_run { + println!("\n-- dry run; compiled JSON-LD --"); + println!("{}", serde_json::to_string_pretty(&graph)?); + return Ok(()); + } + + // Transact (data plane — policies live in the ledger). Policies land + // BEFORE the grant so a partial failure leaves unused policies + // (harmless) rather than a grant naming classes that don't exist. + // Both policy ids are replaced atomically whether or not this profile + // emits them — see the module doc for why merge semantics are unsafe. + let mode = resolve_mode(dataset, remote, dirs, direct).await?; + let view_id = format!("{policy_class}/view"); + let write_id = format!("{policy_class}/write"); + replace_nodes(&mode, &graph, &[&view_id, &write_id], &[]).await?; + println!("\nPolicies transacted to '{dataset}'."); + + if let (Some(space_id), Some(remote_name)) = (space, remote) { + attach_grant( + dirs, + remote_name, + dataset, + space_id, + &policy_class, + !profile.write_verbs().is_empty(), + ) + .await?; + } else { + println!( + "Attach to a space grant so app tokens carry it:\n \ + fluree model access enable {dataset} --profile {} --class {class} \ + --space --remote ", + profile.as_str() + ); + } + Ok(()) +} + +/// Validate a `--connected` SPARQL property path (light: the engine's +/// `@path` parser is authoritative; we only catch obvious mangling). +fn validate_connected_path(path: &str) -> CliResult<()> { + if path.trim().is_empty() { + return Err(CliError::Usage("--connected path is empty".into())); + } + let opens = path.matches('<').count(); + let closes = path.matches('>').count(); + if opens != closes { + return Err(CliError::Usage(format!( + "--connected path has unbalanced angle brackets: {path}" + ))); + } + Ok(()) +} + +// ── compile ───────────────────────────────────────────────────────────── + +/// Compile the profile into its JSON-LD artifacts: the policy class (the +/// assignment unit grants and tokens carry) and one or two thin policies +/// targeting the governed class (`f:onClass`). Deterministic ids +/// ({policy_class}/view, {policy_class}/write) make re-running idempotent +/// — `enable` atomically replaces both owned nodes with the fresh +/// compilation. +fn compile( + policy_class: &str, + class: &str, + profile: Profile, + properties: &[String], + connected: Option<&str>, +) -> Value { + let mut nodes: Vec = Vec::new(); + + nodes.push(json!({ + "@id": policy_class, + "@type": RDFS_CLASS, + RDFS_LABEL: format!("{} access: {}", profile.as_str(), class), + })); + + if profile.wants_view() { + let mut view = json!({ + "@id": format!("{policy_class}/view"), + "@type": [format!("{F}AccessPolicy"), policy_class], + format!("{F}action"): {"@id": format!("{F}view")}, + format!("{F}onClass"): {"@id": class}, + }); + match connected { + Some(path) => { + // Relationship gate: the flake is visible when the query + // returns rows. The path rides the engine's `@path` + // context-term feature VERBATIM — readable in the stored + // policy and reversible, no client-side expansion. + let gate = json!({ + "@context": {"connected": {"@path": path}}, + "where": [ + {"@id": "?$identity", "connected": {"@id": "?$this"}} + ] + }); + view[format!("{F}query")] = + json!(serde_json::to_string(&gate).expect("static JSON serializes")); + } + None => { + view[format!("{F}allow")] = json!(true); + } + } + nodes.push(view); + } + + let verbs = profile.write_verbs(); + if !verbs.is_empty() { + let action: Vec = verbs + .iter() + .map(|v| json!({"@id": format!("{F}{v}")})) + .collect(); + let mut write = json!({ + "@id": format!("{policy_class}/write"), + "@type": [format!("{F}AccessPolicy"), policy_class], + format!("{F}action"): action, + format!("{F}onClass"): {"@id": class}, + format!("{F}allow"): true, + }); + if !properties.is_empty() { + // Column narrowing: policy applies to flakes matching class AND + // property — "may edit status of Leads, nothing else". + write[format!("{F}onProperty")] = json!(properties + .iter() + .map(|p| json!({"@id": p})) + .collect::>()); + } + nodes.push(write); + } + + json!({"@graph": nodes}) +} + +// ── show ──────────────────────────────────────────────────────────────── + +/// List compiled access policies grouped by policy class — read straight +/// from the `f:` artifacts (there is no separate intent store to consult). +async fn run_show( + dataset: &str, + remote: Option<&str>, + dirs: &FlureeDir, + direct: bool, +) -> CliResult<()> { + let mode = resolve_mode(dataset, remote, dirs, direct).await?; + let q = json!({ + "@context": {"f": F}, + "select": {"?policy": ["*"]}, + "where": [{"@id": "?policy", "@type": "f:AccessPolicy"}] + }); + let result = query(&mode, &q).await?; + let rows = result.as_array().cloned().unwrap_or_default(); + if rows.is_empty() { + println!("No access policies on '{dataset}'."); + println!( + "Enable a profile: fluree model access enable {dataset} \ + --profile write --class " + ); + return Ok(()); + } + + println!("Access policies on '{dataset}':\n"); + for row in &rows { + let id = row.get("@id").and_then(Value::as_str).unwrap_or("-"); + println!("• {id}"); + let classes: Vec = list_values(row.get("@type")) + .into_iter() + .filter(|t| t != "f:AccessPolicy" && t != &format!("{F}AccessPolicy")) + .collect(); + if !classes.is_empty() { + println!(" class: {}", classes.join(", ")); + } + let actions = list_values(row.get("f:action")); + if !actions.is_empty() { + println!(" action: {}", actions.join(", ")); + } + let on_class = list_values(row.get("f:onClass")); + if !on_class.is_empty() { + println!(" onClass: {}", on_class.join(", ")); + } + let on_prop = list_values(row.get("f:onProperty")); + if !on_prop.is_empty() { + println!(" columns: {}", on_prop.join(", ")); + } + if let Some(gate) = row.get("f:query") { + if let Some(path) = extract_path(gate) { + println!(" gate: connected via {path}"); + } else { + println!(" gate: f:query condition"); + } + } else if row.get("f:allow").is_some() { + println!(" decision: allow"); + } + } + Ok(()) +} + +/// Pull the `@path` expression back out of a stored relationship gate — +/// the storage form is reversible by construction. +fn extract_path(gate: &Value) -> Option { + let raw = match gate { + Value::String(s) => s.clone(), + Value::Array(a) => a.first()?.as_str()?.to_string(), + Value::Object(o) => o.get("@value")?.as_str()?.to_string(), + _ => return None, + }; + let parsed: Value = serde_json::from_str(&raw).ok()?; + let ctx = parsed.get("@context")?.as_object()?; + for term in ctx.values() { + if let Some(path) = term.get("@path").and_then(Value::as_str) { + return Some(path.to_string()); + } + } + None +} + +fn list_values(v: Option<&Value>) -> Vec { + let mut out = Vec::new(); + let Some(v) = v else { return out }; + let items: Vec<&Value> = match v { + Value::Array(a) => a.iter().collect(), + other => vec![other], + }; + for item in items { + match item { + Value::String(s) => out.push(s.clone()), + Value::Bool(b) => out.push(b.to_string()), + Value::Object(o) => { + if let Some(id) = o.get("@id").and_then(Value::as_str) { + out.push(id.to_string()); + } else if let Some(val) = o.get("@value") { + out.push(val.to_string()); + } + } + other => out.push(other.to_string()), + } + } + out +} + +/// Merge `policy_class` into the space's grant on the dataset via the stack's +/// grants API. System-plane state (grants) is the one place the compiler +/// talks to an API instead of the data plane — grants are router-owned +/// invariants (scope validation, membership checks). +async fn attach_grant( + dirs: &FlureeDir, + remote_name: &str, + dataset: &str, + space_id: &str, + policy_class: &str, + wants_write: bool, +) -> CliResult<()> { + use crate::config::TomlSyncConfigStore; + use fluree_db_nameservice::RemoteName; + use fluree_db_nameservice_sync::{RemoteEndpoint, SyncConfigStore}; + + let store = TomlSyncConfigStore::new(dirs.config_dir().to_path_buf()); + let remote = store + .get_remote(&RemoteName::new(remote_name)) + .await + .map_err(|e| CliError::Config(e.to_string()))? + .ok_or_else(|| CliError::NotFound(format!("remote '{remote_name}' not found")))?; + let base_url = match &remote.endpoint { + RemoteEndpoint::Http { base_url } => base_url.clone(), + _ => { + return Err(CliError::Config(format!( + "remote '{remote_name}' is not an HTTP remote" + ))); + } + }; + + // The remote points at the data plane (…/v1/fluree); the grants API + // lives at the stack root. + let trimmed = base_url.trim_end_matches('/'); + let root = trimmed.strip_suffix("/v1/fluree").ok_or_else(|| { + CliError::Config(format!( + "remote '{remote_name}' ({base_url}) does not look like a hosted stack \ + (expected a …/v1/fluree data-plane URL); attach the grant manually" + )) + })?; + let token = remote.auth.token.clone().ok_or_else(|| { + CliError::Config(format!( + "remote '{remote_name}' has no auth token; run `fluree auth login` first" + )) + })?; + + // Base ledger name (no branch) is the dataset id on the stack. + let dataset_id = dataset.split(':').next().unwrap_or(dataset); + let grants_url = format!("{root}/v1/datasets/{dataset_id}/grants"); + let http = reqwest::Client::new(); + + // Read existing grants: merge classes, never clobber; keep (or upgrade) + // the access level. + let list: Value = http + .get(&grants_url) + .bearer_auth(&token) + .send() + .await + .map_err(|e| CliError::Config(format!("grants read failed: {e}")))? + .error_for_status() + .map_err(|e| CliError::Config(format!("grants read failed: {e}")))? + .json() + .await + .map_err(|e| CliError::Config(format!("grants read returned non-JSON: {e}")))?; + let existing = list["grants"] + .as_array() + .map(|grants| { + grants + .iter() + .find(|g| { + g["scopeType"].as_str() == Some("space") + && g["scopeRef"].as_str() == Some(space_id) + }) + .cloned() + }) + .unwrap_or(None); + + let mut classes: Vec = existing + .as_ref() + .and_then(|g| g["policyClasses"].as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + if !classes.iter().any(|c| c == policy_class) { + classes.push(policy_class.to_string()); + } + + let existing_access = existing + .as_ref() + .and_then(|g| g["access"].as_str()) + .map(String::from); + let access = match (&existing_access, wants_write) { + (Some(a), true) if a == "read" => { + println!(" grant access upgraded read → write (profile requires writes)"); + "write".to_string() + } + (Some(a), _) => a.clone(), + (None, true) => "write".to_string(), + (None, false) => "read".to_string(), + }; + + let body = json!({ + "scopeType": "space", + "scopeRef": space_id, + "access": access, + "policyClasses": classes, + }); + http.post(&grants_url) + .bearer_auth(&token) + .json(&body) + .send() + .await + .map_err(|e| CliError::Config(format!("grant upsert failed: {e}")))? + .error_for_status() + .map_err(|e| CliError::Config(format!("grant upsert rejected: {e}")))?; + + println!( + "Grant attached: space {space_id} → {dataset_id} ({access}, {} class{})", + classes.len(), + if classes.len() == 1 { "" } else { "es" } + ); + Ok(()) +} +#[cfg(test)] +mod tests { + use super::*; + + const LEAD: &str = "https://example.org/Lead"; + const POLICY_CLASS: &str = "https://example.org/Lead/access/write"; + + /// Exact-id lookup — the default class IRI itself ends in the profile + /// name (…/access/write), so suffix matching would be ambiguous. + fn node<'a>(g: &'a Value, id: &str) -> Option<&'a Value> { + g["@graph"] + .as_array() + .unwrap() + .iter() + .find(|n| n["@id"].as_str().unwrap() == id) + } + + #[test] + fn write_profile_emits_class_view_and_verb_policy() { + let g = compile(POLICY_CLASS, LEAD, Profile::Write, &[], None); + assert_eq!(g["@graph"].as_array().unwrap().len(), 3); + + let view = node(&g, &format!("{POLICY_CLASS}/view")).expect("view policy"); + assert_eq!(view[format!("{F}onClass")]["@id"], LEAD); + assert_eq!(view[format!("{F}allow")], true); + + let write = node(&g, &format!("{POLICY_CLASS}/write")).expect("write policy"); + let verbs: Vec<&str> = write[format!("{F}action")] + .as_array() + .unwrap() + .iter() + .map(|v| v["@id"].as_str().unwrap()) + .collect(); + assert_eq!( + verbs, + vec![ + "https://ns.flur.ee/db#create", + "https://ns.flur.ee/db#update", + "https://ns.flur.ee/db#delete" + ] + ); + assert_eq!(write[format!("{F}onClass")]["@id"], LEAD); + // No property allow-list, no rdf:type entry — verb semantics make + // the class grant exact on their own. + assert!(write.get(format!("{F}onProperty")).is_none()); + } + + #[test] + fn read_profile_has_no_write_policy() { + let g = compile( + "https://example.org/Lead/access/read", + LEAD, + Profile::Read, + &[], + None, + ); + assert!(node(&g, "https://example.org/Lead/access/read/write").is_none()); + assert!(node(&g, "https://example.org/Lead/access/read/view").is_some()); + } + + #[test] + fn intake_profile_is_create_only_without_view() { + let g = compile( + "https://example.org/Lead/access/intake", + LEAD, + Profile::Intake, + &[], + None, + ); + assert!(node(&g, "https://example.org/Lead/access/intake/view").is_none()); + let write = + node(&g, "https://example.org/Lead/access/intake/write").expect("create policy"); + let verbs = write[format!("{F}action")].as_array().unwrap(); + assert_eq!(verbs.len(), 1); + assert_eq!(verbs[0]["@id"], "https://ns.flur.ee/db#create"); + } + + #[test] + fn property_narrowing_adds_on_property_conjunction() { + let props = vec!["https://example.org/status".to_string()]; + let g = compile(POLICY_CLASS, LEAD, Profile::Write, &props, None); + let write = node(&g, &format!("{POLICY_CLASS}/write")).unwrap(); + assert_eq!( + write[format!("{F}onProperty")][0]["@id"], + "https://example.org/status" + ); + } + + #[test] + fn connected_gate_stores_path_verbatim_and_reversibly() { + let path = "/^"; + let g = compile( + "https://example.org/Lead/access/read", + LEAD, + Profile::Read, + &[], + Some(path), + ); + let view = node(&g, "https://example.org/Lead/access/read/view").unwrap(); + assert!( + view.get(format!("{F}allow")).is_none(), + "f:query replaces f:allow" + ); + let stored = view[format!("{F}query")].as_str().unwrap(); + let parsed: Value = serde_json::from_str(stored).unwrap(); + assert_eq!(parsed["@context"]["connected"]["@path"], path); + assert_eq!(parsed["where"][0]["@id"], "?$identity"); + // And show can pull it back out. + assert_eq!( + extract_path(&view[format!("{F}query")]).as_deref(), + Some(path) + ); + } + + #[test] + fn connected_path_validation() { + assert!(validate_connected_path("/^").is_ok()); + assert!(validate_connected_path("").is_err()); + assert!(validate_connected_path(" CliResult<()> { + match action { + ModelClassAction::Define { + dataset, + class, + subclass_of, + clear_subclass_of, + label, + dry_run, + remote, + } => { + run_define( + dataset, + class, + subclass_of, + *clear_subclass_of, + label.as_deref(), + *dry_run, + remote.as_deref(), + dirs, + direct, + ) + .await + } + ModelClassAction::Show { dataset, remote } => { + run_show(dataset, remote.as_deref(), dirs, direct).await + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn run_define( + dataset: &str, + class: &str, + subclass_of: &[String], + clear_subclass_of: bool, + label: Option<&str>, + dry_run: bool, + remote: Option<&str>, + dirs: &FlureeDir, + direct: bool, +) -> CliResult<()> { + require_absolute_iri("--class", class)?; + for parent in subclass_of { + require_absolute_iri("--subclass-of", parent)?; + } + + let node = compile(class, subclass_of, label); + + println!("Class: {class}"); + if clear_subclass_of { + println!("Subclass of: (clearing all parents)"); + } else if !subclass_of.is_empty() { + println!("Subclass of: {}", subclass_of.join(", ")); + println!( + " note: with RDFS entailment enabled, queries and policies targeting a\n\ + \x20 parent class also cover this class — a widened hierarchy widens\n\ + \x20 every grant on the parent." + ); + } + + if dry_run { + println!("\n-- dry run; compiled JSON-LD --"); + if clear_subclass_of { + println!( + "{}", + serde_json::to_string_pretty(&clear_parents_txn(class, label, &node))? + ); + } else { + println!("{}", serde_json::to_string_pretty(&node)?); + } + return Ok(()); + } + + let mode = resolve_mode(dataset, remote, dirs, direct).await?; + if clear_subclass_of { + update(&mode, &clear_parents_txn(class, label, &node)).await?; + } else { + upsert(&mode, &node).await?; + } + println!( + "Defined on '{dataset}'. Re-running with --subclass-of replaces the \ + parent set (list every parent each time); --clear-subclass-of \ + removes all parents." + ); + Ok(()) +} + +/// Transaction for `--clear-subclass-of`: `upsert` can only replace listed +/// properties, so removing the last parent needs an explicit delete of +/// every `rdfs:subClassOf` edge (and the old label when re-labeling, since +/// the insert side is additive), atomically with writing the node. This +/// matters under RDFS entailment: a stale parent widens every grant on it. +fn clear_parents_txn(class: &str, label: Option<&str>, node: &Value) -> Value { + let mut where_clause = vec![json!([ + "optional", + {"@id": class, RDFS_SUBCLASS_OF: "?parent"} + ])]; + let mut delete = vec![json!({"@id": class, RDFS_SUBCLASS_OF: "?parent"})]; + if label.is_some() { + where_clause.push(json!(["optional", {"@id": class, RDFS_LABEL: "?oldLabel"}])); + delete.push(json!({"@id": class, RDFS_LABEL: "?oldLabel"})); + } + json!({"where": where_clause, "delete": delete, "insert": node}) +} + +/// Compile the class node. `upsert` replaces the listed properties, so +/// re-defining with a different parent set replaces the hierarchy edge +/// rather than accumulating stale ones. +fn compile(class: &str, subclass_of: &[String], label: Option<&str>) -> Value { + let mut node = json!({ + "@id": class, + "@type": RDFS_CLASS, + }); + if let Some(l) = label { + node[RDFS_LABEL] = json!(l); + } + if !subclass_of.is_empty() { + node[RDFS_SUBCLASS_OF] = json!(subclass_of + .iter() + .map(|p| json!({"@id": p})) + .collect::>()); + } + node +} + +async fn run_show( + dataset: &str, + remote: Option<&str>, + dirs: &FlureeDir, + direct: bool, +) -> CliResult<()> { + let mode = resolve_mode(dataset, remote, dirs, direct).await?; + + // Policy classes are rdfs:Class too (the access compiler mints them); + // they're governance plumbing, not domain vocabulary — exclude them. + // There is no stored intent to consult: a policy class is recognized + // by its role, the extra `@type` the compiler puts on its + // `f:AccessPolicy` nodes. + let policy_classes: Vec = { + let q = json!({ + "@context": {"f": F}, + "select": ["?class"], + "where": [ + {"@id": "?p", "@type": "f:AccessPolicy"}, + {"@id": "?p", "@type": "?class"} + ] + }); + iri_rows(&query(&mode, &q).await?) + }; + + let q = json!({ + "@context": {"rdfs": "http://www.w3.org/2000/01/rdf-schema#"}, + "select": {"?class": ["*"]}, + "where": [{"@id": "?class", "@type": "rdfs:Class"}] + }); + let result = query(&mode, &q).await?; + let rows = result.as_array().cloned().unwrap_or_default(); + let domain = domain_classes(&rows, &policy_classes); + + if domain.is_empty() { + println!("No classes defined on '{dataset}'."); + println!("Define one: fluree model class define {dataset} --class "); + return Ok(()); + } + println!("Classes on '{dataset}':\n"); + for row in domain { + let id = row["@id"].as_str().unwrap_or("-"); + println!("• {id}"); + if let Some(label) = row.get("rdfs:label").map(render_value).filter(|s| s != "-") { + println!(" label: {label}"); + } + let parents = ids_of(row.get("rdfs:subClassOf")); + if !parents.is_empty() { + println!(" subclass of: {}", parents.join(", ")); + } + } + Ok(()) +} + +/// Domain vocabulary only: drop rows whose id is a policy class the access +/// compiler minted (governance plumbing, not user classes). +fn domain_classes<'a>(rows: &'a [Value], policy_classes: &[String]) -> Vec<&'a Value> { + rows.iter() + .filter(|row| { + row["@id"] + .as_str() + .is_some_and(|id| !policy_classes.iter().any(|pc| pc == id)) + }) + .collect() +} + +fn render_value(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Object(o) => o + .get("@id") + .and_then(|x| x.as_str()) + .unwrap_or("-") + .to_string(), + Value::Array(items) => items + .iter() + .map(render_value) + .collect::>() + .join(", "), + other => other.to_string(), + } +} + +fn ids_of(v: Option<&Value>) -> Vec { + let mut out = Vec::new(); + let Some(v) = v else { return out }; + let items: Vec<&Value> = match v { + Value::Array(a) => a.iter().collect(), + other => vec![other], + }; + for item in items { + match item { + Value::String(s) => out.push(s.clone()), + Value::Object(o) => { + if let Some(id) = o.get("@id").and_then(|x| x.as_str()) { + out.push(id.to_string()); + } + } + _ => {} + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compile_minimal_class() { + let node = compile("https://example.org/Lead", &[], None); + assert_eq!(node["@id"], "https://example.org/Lead"); + assert_eq!(node["@type"], RDFS_CLASS); + assert!(node.get(RDFS_SUBCLASS_OF).is_none()); + } + + #[test] + fn compile_with_hierarchy_and_label() { + let parents = vec!["https://example.org/Contact".to_string()]; + let node = compile("https://example.org/Lead", &parents, Some("Lead")); + assert_eq!(node[RDFS_LABEL], "Lead"); + assert_eq!( + node[RDFS_SUBCLASS_OF][0]["@id"], + "https://example.org/Contact" + ); + } + + #[test] + fn clear_parents_txn_deletes_all_subclass_edges() { + // `upsert` cannot remove the last parent (compile omits + // rdfs:subClassOf when empty) — the clear txn must delete + // explicitly, or a stale parent keeps widening grants under RDFS + // entailment. + let node = compile("https://example.org/Lead", &[], None); + let txn = clear_parents_txn("https://example.org/Lead", None, &node); + + let where_clause = txn["where"].as_array().unwrap(); + assert_eq!(where_clause.len(), 1); + assert_eq!(where_clause[0][0], "optional"); + assert_eq!(where_clause[0][1][RDFS_SUBCLASS_OF], "?parent"); + + let delete = txn["delete"].as_array().unwrap(); + assert_eq!(delete.len(), 1); + assert_eq!(delete[0]["@id"], "https://example.org/Lead"); + assert_eq!(delete[0][RDFS_SUBCLASS_OF], "?parent"); + + assert_eq!(txn["insert"]["@id"], "https://example.org/Lead"); + assert!(txn["insert"].get(RDFS_SUBCLASS_OF).is_none()); + } + + #[test] + fn clear_parents_txn_replaces_label_when_given() { + // The insert side is a plain (additive) insert, so re-labeling in + // the same run must delete the old label too. + let node = compile("https://example.org/Lead", &[], Some("Lead v2")); + let txn = clear_parents_txn("https://example.org/Lead", Some("Lead v2"), &node); + let delete = txn["delete"].as_array().unwrap(); + assert_eq!(delete.len(), 2); + assert_eq!(delete[1][RDFS_LABEL], "?oldLabel"); + } + + #[test] + fn show_excludes_policy_classes() { + let rows = vec![ + serde_json::json!({"@id": "https://example.org/Lead"}), + serde_json::json!({"@id": "https://example.org/Lead/access/write"}), + ]; + let policy = vec!["https://example.org/Lead/access/write".to_string()]; + let domain = domain_classes(&rows, &policy); + assert_eq!(domain.len(), 1); + assert_eq!(domain[0]["@id"], "https://example.org/Lead"); + } + + #[test] + fn ids_of_handles_single_and_array_forms() { + assert_eq!( + ids_of(Some(&serde_json::json!({"@id": "https://x/A"}))), + vec!["https://x/A"] + ); + assert_eq!( + ids_of(Some(&serde_json::json!([ + {"@id": "https://x/A"}, + "https://x/B" + ]))), + vec!["https://x/A", "https://x/B"] + ); + assert!(ids_of(None).is_empty()); + } +} diff --git a/fluree-db-cli/src/commands/model/entity.rs b/fluree-db-cli/src/commands/model/entity.rs new file mode 100644 index 0000000000..c4d6f2e52e --- /dev/null +++ b/fluree-db-cli/src/commands/model/entity.rs @@ -0,0 +1,579 @@ +//! `fluree model entity` — entity definitions as SHACL shapes. +//! +//! The entity shape is the governance model's single source of truth: the +//! property list is written ONCE here. Validation enforces it (use +//! `--closed` so instances may carry ONLY declared properties), SDK types / +//! form fields can be generated from it, and access policies stay thin +//! class-verb grants because the shape owns the property surface. +//! +//! Enforcement note: Fluree runs SHACL at transaction time once any shapes +//! exist in a ledger (reject mode by default — the "shapes-exist" +//! heuristic; a config graph can override per graph). Defining an entity is +//! therefore not just documentation: it activates validation for its class. + +use std::collections::HashMap; + +use serde_json::{json, Value}; + +use super::{iri_rows, query, replace_nodes, require_absolute_iri, resolve_mode}; +use crate::cli::ModelEntityAction; +use crate::error::{CliError, CliResult}; +use fluree_db_api::server_defaults::FlureeDir; + +const SH: &str = "http://www.w3.org/ns/shacl#"; +const XSD: &str = "http://www.w3.org/2001/XMLSchema#"; +const RDFS_CLASS: &str = "http://www.w3.org/2000/01/rdf-schema#Class"; +const RDFS_LABEL: &str = "http://www.w3.org/2000/01/rdf-schema#label"; + +/// One parsed `--property` spec. +#[derive(Debug, PartialEq)] +struct PropertySpec { + path: String, + /// XSD datatype IRI, or None for untyped / IRI-kind properties. + datatype: Option, + /// `sh:nodeKind sh:IRI` (the `iri` type keyword). + iri_kind: bool, + required: bool, + /// Allowed literal values (`in[a,b,c]`). + values_in: Vec, +} + +/// Parse `" [type] [required] [in[v1,v2,...]]"`. +fn parse_property_spec(spec: &str) -> CliResult { + let mut tokens = spec.split_whitespace(); + let path = tokens + .next() + .ok_or_else(|| CliError::Usage("empty --property spec".into()))? + .to_string(); + require_absolute_iri("--property", &path)?; + + let mut out = PropertySpec { + path, + datatype: None, + iri_kind: false, + required: false, + values_in: vec![], + }; + for token in tokens { + match token { + "string" | "integer" | "decimal" | "boolean" | "date" | "datetime" => { + if out.iri_kind { + return Err(CliError::Usage(format!( + "'iri' cannot be combined with datatype '{token}' \ + (in '{spec}') — a value is either an IRI or a literal" + ))); + } + if out.datatype.is_some() { + return Err(CliError::Usage(format!( + "more than one datatype in --property spec '{spec}'" + ))); + } + let xsd_name = if token == "datetime" { + "dateTime" + } else { + token + }; + out.datatype = Some(format!("{XSD}{xsd_name}")); + } + "iri" => { + if out.datatype.is_some() { + return Err(CliError::Usage(format!( + "'iri' cannot be combined with a datatype (in '{spec}') \ + — a value is either an IRI or a literal" + ))); + } + out.iri_kind = true; + } + "required" => out.required = true, + t if t.starts_with("in[") && t.ends_with(']') => { + out.values_in = t[3..t.len() - 1].split(',').map(str::to_string).collect(); + if out.values_in.iter().any(String::is_empty) { + return Err(CliError::Usage(format!( + "in[...] must list at least one value and none may be \ + empty (in '{spec}')" + ))); + } + } + t if t.starts_with("in[") => { + return Err(CliError::Usage(format!( + "in[...] must not contain spaces — write in[a,b] not \ + in[a, b] (in '{spec}')" + ))); + } + other => { + return Err(CliError::Usage(format!( + "unknown token '{other}' in --property spec '{spec}' \ + (expected a type, 'required', or 'in[v1,v2]')" + ))); + } + } + } + if out.iri_kind && !out.values_in.is_empty() { + // sh:in values are serialized as string literals; combined with + // sh:nodeKind sh:IRI nothing could ever validate. + return Err(CliError::Usage(format!( + "'iri' cannot be combined with in[...] (in '{spec}') — in-values \ + are literals" + ))); + } + Ok(out) +} + +/// Last path segment of an IRI, for stable child-node ids. +fn local_name(iri: &str) -> &str { + iri.rsplit(['/', '#', ':']).next().unwrap_or(iri) +} + +/// Child property-shape ids are minted from the path's local name; two +/// property IRIs sharing a final segment would collapse into ONE JSON-LD +/// node, silently merging their constraints (one path dropped from +/// validation, the survivor over-constrained). Reject up front. +fn check_child_id_collisions(shape_iri: &str, specs: &[PropertySpec]) -> CliResult<()> { + let mut seen: HashMap<&str, &str> = HashMap::new(); + for p in specs { + let name = local_name(&p.path); + if let Some(prev) = seen.insert(name, p.path.as_str()) { + return Err(CliError::Usage(format!( + "--property IRIs '{prev}' and '{}' share the local name \ + '{name}' — both would compile to the property shape \ + '{shape_iri}/{name}', silently merging their constraints. \ + Use property IRIs with distinct final segments.", + p.path + ))); + } + } + Ok(()) +} + +/// Node ids the entity compiler owns for full replace: the shape, every +/// child compiled from the current specs, and any child currently recorded +/// on the shape — so a dropped property's shape is deleted, not orphaned. +fn owned_ids(shape_iri: &str, specs: &[PropertySpec], existing_children: &[String]) -> Vec { + let mut owned = vec![shape_iri.to_string()]; + owned.extend( + specs + .iter() + .map(|p| format!("{shape_iri}/{}", local_name(&p.path))), + ); + for child in existing_children { + if !owned.iter().any(|o| o == child) { + owned.push(child.clone()); + } + } + owned +} + +/// Compile the entity definition into its JSON-LD artifacts. +fn compile(entity: &str, label: Option<&str>, specs: &[PropertySpec], closed: bool) -> Value { + let shape_iri = format!("{}/shape", entity.trim_end_matches('/')); + + let mut class_node = json!({ + "@id": entity, + "@type": RDFS_CLASS, + }); + if let Some(l) = label { + class_node[RDFS_LABEL] = json!(l); + } + + let property_shapes: Vec = specs + .iter() + .map(|p| { + let mut node = json!({ + "@id": format!("{shape_iri}/{}", local_name(&p.path)), + format!("{SH}path"): {"@id": p.path}, + }); + if let Some(dt) = &p.datatype { + node[format!("{SH}datatype")] = json!({"@id": dt}); + } + if p.iri_kind { + node[format!("{SH}nodeKind")] = json!({"@id": format!("{SH}IRI")}); + } + if p.required { + node[format!("{SH}minCount")] = json!(1); + } + if !p.values_in.is_empty() { + node[format!("{SH}in")] = json!({"@list": p.values_in}); + } + node + }) + .collect(); + + let mut shape = json!({ + "@id": shape_iri, + "@type": format!("{SH}NodeShape"), + format!("{SH}targetClass"): {"@id": entity}, + format!("{SH}property"): property_shapes, + }); + if closed { + // Closed shape: instances may carry ONLY the declared properties — + // validation owns the property surface, so access policies never + // need property allow-lists. rdf:type must be carved out or the + // typing triple itself would violate closure. + shape[format!("{SH}closed")] = json!(true); + shape[format!("{SH}ignoredProperties")] = json!({ + "@list": [{"@id": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"}] + }); + } + + json!({"@graph": [class_node, shape]}) +} + +pub async fn run(action: &ModelEntityAction, dirs: &FlureeDir, direct: bool) -> CliResult<()> { + match action { + ModelEntityAction::Define { + dataset, + entity, + properties, + label, + closed, + dry_run, + remote, + } => { + run_define( + dataset, + entity, + properties, + label.as_deref(), + *closed, + *dry_run, + remote.as_deref(), + dirs, + direct, + ) + .await + } + ModelEntityAction::Show { dataset, remote } => { + run_show(dataset, remote.as_deref(), dirs, direct).await + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn run_define( + dataset: &str, + entity: &str, + property_specs: &[String], + label: Option<&str>, + closed: bool, + dry_run: bool, + remote: Option<&str>, + dirs: &FlureeDir, + direct: bool, +) -> CliResult<()> { + require_absolute_iri("--entity", entity)?; + let specs: Vec = property_specs + .iter() + .map(|s| parse_property_spec(s)) + .collect::>()?; + let shape_iri = format!("{}/shape", entity.trim_end_matches('/')); + check_child_id_collisions(&shape_iri, &specs)?; + + let graph = compile(entity, label, &specs, closed); + + println!("Entity: {entity}"); + println!("Shape: {shape_iri}"); + println!("Properties: {}", specs.len()); + for p in &specs { + let mut notes: Vec = vec![]; + if let Some(dt) = &p.datatype { + notes.push(local_name(dt).to_string()); + } + if p.iri_kind { + notes.push("iri".into()); + } + if p.required { + notes.push("required".into()); + } + if !p.values_in.is_empty() { + notes.push(format!("in[{}]", p.values_in.join(","))); + } + println!( + " {} {}", + p.path, + if notes.is_empty() { + String::new() + } else { + format!("({})", notes.join(", ")) + } + ); + } + + if dry_run { + println!("\n-- dry run; compiled JSON-LD --"); + println!("{}", serde_json::to_string_pretty(&graph)?); + return Ok(()); + } + + // The shape and its property-shape children are OWNED by this compiler + // and replaced atomically — a constraint from a previous definition + // (sh:closed, sh:minCount, sh:in, …) must not survive a re-run that + // dropped it, or validation keeps enforcing what the author removed. + // Children currently recorded on the shape are unioned into the owned + // set so a dropped property's child shape is deleted too (RDF-list + // blank nodes behind sh:in / sh:ignoredProperties are the one residue a + // wildcard delete-by-subject cannot cascade to). The entity class node + // is NOT owned: it's shared authorship with `model class define` + // (hierarchy, label), so it stays additive — except rdfs:label, which + // is replaced when --label is given so re-labeling stays idempotent. + let mode = resolve_mode(dataset, remote, dirs, direct).await?; + let children_q = json!({ + "select": ["?child"], + "where": [{"@id": &shape_iri, format!("{SH}property"): "?child"}] + }); + let existing_children = iri_rows(&query(&mode, &children_q).await?); + let owned = owned_ids(&shape_iri, &specs, &existing_children); + let owned_refs: Vec<&str> = owned.iter().map(String::as_str).collect(); + let replaced_props: Vec<(&str, &str)> = if label.is_some() { + vec![(entity, RDFS_LABEL)] + } else { + vec![] + }; + replace_nodes(&mode, &graph, &owned_refs, &replaced_props).await?; + + println!("\nDefined. Shape written to '{dataset}'."); + println!( + "note: SHACL validation is ACTIVE once shapes exist (reject mode by default) —\n\ + \x20 transactions violating this shape will be rejected. Existing data is\n\ + \x20 not retro-validated; `fluree validate` produces a full report." + ); + println!( + "next: fluree model access enable {dataset} --profile write --class {entity}\n\ + \x20 grants create/update/delete on the class — the shape above governs\n\ + \x20 what valid instances look like." + ); + Ok(()) +} + +async fn run_show( + dataset: &str, + remote: Option<&str>, + dirs: &FlureeDir, + direct: bool, +) -> CliResult<()> { + let mode = resolve_mode(dataset, remote, dirs, direct).await?; + let q = json!({ + "@context": {"sh": SH}, + "select": {"?shape": ["*", {"sh:property": ["*"]}]}, + "where": [{"@id": "?shape", "@type": "sh:NodeShape"}] + }); + let result = query(&mode, &q).await?; + let rows = result.as_array().cloned().unwrap_or_default(); + if rows.is_empty() { + println!("No entity definitions (node shapes) on '{dataset}'."); + println!( + "Define one: fluree model entity define {dataset} --entity \ + --property \" string required\"" + ); + return Ok(()); + } + println!("Entity definitions on '{dataset}':\n"); + for shape in &rows { + let target = shape + .get("sh:targetClass") + .map(render_ref) + .unwrap_or_else(|| "-".into()); + println!("• {target}"); + println!(" shape: {}", render_ref(&shape["@id"])); + let props = match shape.get("sh:property") { + Some(Value::Array(items)) => items.clone(), + Some(single) => vec![single.clone()], + None => vec![], + }; + for p in &props { + let path = p + .get("sh:path") + .map(render_ref) + .unwrap_or_else(|| "-".into()); + let mut notes: Vec = vec![]; + if let Some(dt) = p.get("sh:datatype") { + notes.push(local_name(&render_ref(dt)).to_string()); + } + if p.get("sh:nodeKind").is_some() { + notes.push("iri".into()); + } + if p.get("sh:minCount") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0) + >= 1 + { + notes.push("required".into()); + } + println!( + " - {path}{}", + if notes.is_empty() { + String::new() + } else { + format!(" ({})", notes.join(", ")) + } + ); + } + } + Ok(()) +} + +fn render_ref(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Object(o) => o + .get("@id") + .and_then(|x| x.as_str()) + .unwrap_or("-") + .to_string(), + other => other.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_full_spec() { + let p = parse_property_spec( + "https://example.org/status string required in[new,qualified,won,lost]", + ) + .unwrap(); + assert_eq!(p.path, "https://example.org/status"); + assert_eq!( + p.datatype.as_deref(), + Some("http://www.w3.org/2001/XMLSchema#string") + ); + assert!(p.required); + assert_eq!(p.values_in, vec!["new", "qualified", "won", "lost"]); + } + + #[test] + fn parses_bare_iri_as_untyped_optional() { + let p = parse_property_spec("https://example.org/notes").unwrap(); + assert_eq!(p.datatype, None); + assert!(!p.required); + assert!(p.values_in.is_empty()); + } + + #[test] + fn rejects_unknown_token_and_relative_iri() { + assert!(parse_property_spec("https://example.org/x strng").is_err()); + assert!(parse_property_spec("ex:name string").is_err()); + assert!(parse_property_spec("https://example.org/x in[]").is_err()); + } + + #[test] + fn rejects_contradictory_tokens() { + // iri + datatype (both orders): a value can't be both. + assert!(parse_property_spec("https://example.org/x iri string").is_err()); + assert!(parse_property_spec("https://example.org/x string iri").is_err()); + // Two datatypes: previously last-wins, now rejected. + assert!(parse_property_spec("https://example.org/x string integer").is_err()); + // iri + in[...]: in-values are literals, nothing could validate. + assert!(parse_property_spec("https://example.org/x iri in[a,b]").is_err()); + } + + #[test] + fn rejects_malformed_in_lists() { + // Empty values were silently dropped before; now rejected. + assert!(parse_property_spec("https://example.org/x in[a,,b]").is_err()); + // Whitespace splits the token; the error must say why. + let err = parse_property_spec("https://example.org/x in[a, b]").unwrap_err(); + assert!( + err.to_string().contains("must not contain spaces"), + "unexpected error: {err}" + ); + } + + #[test] + fn rejects_child_shape_id_collision() { + // schema.org/name and example.org/name share the local name 'name' + // and would merge into one property-shape node. + let specs = vec![ + parse_property_spec("https://schema.org/name string required").unwrap(), + parse_property_spec("https://example.org/name integer").unwrap(), + ]; + let err = check_child_id_collisions("https://example.org/Lead/shape", &specs).unwrap_err(); + assert!( + err.to_string().contains("share the local name 'name'"), + "unexpected error: {err}" + ); + + let distinct = vec![ + parse_property_spec("https://schema.org/name string").unwrap(), + parse_property_spec("https://example.org/email string").unwrap(), + ]; + assert!(check_child_id_collisions("https://example.org/Lead/shape", &distinct).is_ok()); + } + + #[test] + fn owned_ids_include_existing_children_dropped_from_specs() { + // Re-running define without 'score' must still own (and so delete) + // the previously written score child shape. + let specs = vec![parse_property_spec("https://example.org/name string").unwrap()]; + let existing = vec![ + "https://example.org/Lead/shape/name".to_string(), + "https://example.org/Lead/shape/score".to_string(), + ]; + let owned = owned_ids("https://example.org/Lead/shape", &specs, &existing); + assert_eq!( + owned, + vec![ + "https://example.org/Lead/shape", + "https://example.org/Lead/shape/name", + "https://example.org/Lead/shape/score", + ], + "existing children union in without duplicating current specs" + ); + } + + #[test] + fn compile_emits_class_and_targeted_shape() { + let specs = vec![ + parse_property_spec("https://example.org/name string required").unwrap(), + parse_property_spec("https://example.org/owner iri").unwrap(), + ]; + let g = compile("https://example.org/Lead", Some("Lead"), &specs, false); + let nodes = g["@graph"].as_array().unwrap(); + assert_eq!(nodes.len(), 2, "class + shape"); + + let shape = &nodes[1]; + assert_eq!( + shape["http://www.w3.org/ns/shacl#targetClass"]["@id"], + "https://example.org/Lead" + ); + let props = shape["http://www.w3.org/ns/shacl#property"] + .as_array() + .unwrap(); + assert_eq!(props.len(), 2); + assert_eq!( + props[0]["http://www.w3.org/ns/shacl#minCount"], + json!(1), + "required → minCount 1" + ); + assert_eq!( + props[1]["http://www.w3.org/ns/shacl#nodeKind"]["@id"], + "http://www.w3.org/ns/shacl#IRI" + ); + } + + #[test] + fn closed_shape_carves_out_rdf_type() { + let specs = vec![parse_property_spec("https://example.org/name string required").unwrap()]; + let g = compile("https://example.org/Lead", None, &specs, true); + let shape = &g["@graph"][1]; + assert_eq!(shape[format!("{SH}closed")], true); + assert_eq!( + shape[format!("{SH}ignoredProperties")]["@list"][0]["@id"], + "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" + ); + // Open by default. + let g2 = compile("https://example.org/Lead", None, &specs, false); + assert!(g2["@graph"][1].get(format!("{SH}closed")).is_none()); + } + + #[test] + fn stable_property_shape_ids_for_idempotent_upsert() { + let specs = vec![parse_property_spec("https://example.org/name string").unwrap()]; + let g = compile("https://example.org/Lead", None, &specs, false); + let props = g["@graph"][1]["http://www.w3.org/ns/shacl#property"] + .as_array() + .unwrap(); + assert_eq!(props[0]["@id"], "https://example.org/Lead/shape/name"); + } +} diff --git a/fluree-db-cli/src/commands/model/mod.rs b/fluree-db-cli/src/commands/model/mod.rs new file mode 100644 index 0000000000..ef81346609 --- /dev/null +++ b/fluree-db-cli/src/commands/model/mod.rs @@ -0,0 +1,245 @@ +//! `fluree model` — governance model tooling. +//! +//! A governance model has three facets: **entity** (SHACL shapes — what +//! things are), **access** (policies — who may do what), and **reasoning** +//! (RDFS hierarchy — what follows). This module hosts the facet +//! subcommands; v1 ships the access facet. +//! +//! Architecture: commands here 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. + +pub mod access; +pub mod class; +pub mod entity; + +use crate::cli::ModelAction; +use crate::context::{self, LedgerMode}; +use crate::error::{CliError, CliResult}; +use fluree_db_api::server_defaults::FlureeDir; +use serde_json::Value; + +pub async fn run(action: &ModelAction, dirs: &FlureeDir, direct: bool) -> CliResult<()> { + match action { + ModelAction::Access { action } => access::run(action, dirs, direct).await, + ModelAction::Entity { action } => entity::run(action, dirs, direct).await, + ModelAction::Class { action } => class::run(action, dirs, direct).await, + } +} + +// ── mode-agnostic ledger IO ───────────────────────────────────────────── + +pub(crate) async fn resolve_mode( + dataset: &str, + remote: Option<&str>, + dirs: &FlureeDir, + direct: bool, +) -> CliResult { + if let Some(remote_name) = remote { + let alias = context::resolve_ledger(Some(dataset), dirs)?; + context::build_remote_mode(remote_name, &alias, dirs).await + } else { + let mode = context::resolve_ledger_mode(Some(dataset), dirs).await?; + Ok(if direct { + mode + } else { + context::try_server_route(mode, dirs) + }) + } +} + +/// Run a JSON-LD query in either mode, returning the JSON-LD result value. +pub(crate) async fn query(mode: &LedgerMode, body: &Value) -> CliResult { + match mode { + LedgerMode::Tracked { + client, + remote_alias, + .. + } => Ok(client.query_jsonld(remote_alias, body).await?), + LedgerMode::Local { fluree, alias } => { + let view = fluree.db_with_default_context(alias).await?; + let result = fluree.query(&view, body).await?; + Ok(result.to_jsonld_async(view.as_graph_db_ref()).await?) + } + } +} + +/// Upsert JSON-LD in either mode (replace-listed-properties semantics; used +/// for nodes shared with other authorship, where unlisted properties must +/// survive). +pub(crate) async fn upsert(mode: &LedgerMode, body: &Value) -> CliResult<()> { + match mode { + LedgerMode::Tracked { + client, + remote_alias, + .. + } => { + client.upsert_jsonld(remote_alias, body).await?; + } + LedgerMode::Local { fluree, alias } => { + fluree.graph(alias).transact().upsert(body).commit().await?; + } + } + Ok(()) +} + +/// Run a WHERE/DELETE/INSERT update in either mode. +pub(crate) async fn update(mode: &LedgerMode, body: &Value) -> CliResult<()> { + match mode { + LedgerMode::Tracked { + client, + remote_alias, + .. + } => { + client.update_jsonld(remote_alias, body).await?; + } + LedgerMode::Local { fluree, alias } => { + fluree.graph(alias).transact().update(body).commit().await?; + } + } + Ok(()) +} + +/// Install compiled nodes with full-replace semantics for the ids the +/// compiler OWNS: in one atomic transaction, wildcard-delete every owned +/// node and insert the compiled graph. Owned nodes end up exactly as +/// compiled — a property from a previous compilation cannot linger (the +/// policy loader gives `f:allow` precedence over `f:query`, so a stale +/// `f:allow: true` would silently disable a newly compiled gate). Nodes in +/// the graph that are NOT listed as owned are inserted additively, except +/// the `(node, property)` pairs in `replaced_props`, whose existing values +/// are deleted first (property-level replace on shared-authorship nodes). +pub(crate) async fn replace_nodes( + mode: &LedgerMode, + graph: &Value, + owned_ids: &[&str], + replaced_props: &[(&str, &str)], +) -> CliResult<()> { + update(mode, &replace_txn(graph, owned_ids, replaced_props)).await +} + +/// Pure builder for [`replace_nodes`]'s transaction: optional wildcard +/// match + delete per owned id (delete-if-exists — unbound rows skip their +/// delete template), optional match + delete per replaced property, then +/// insert the compiled nodes. +pub(crate) fn replace_txn( + graph: &Value, + owned_ids: &[&str], + replaced_props: &[(&str, &str)], +) -> Value { + let mut where_clause: Vec = Vec::new(); + let mut delete: Vec = Vec::new(); + for (i, id) in owned_ids.iter().enumerate() { + let pattern = serde_json::json!({"@id": id, format!("?p{i}"): format!("?o{i}")}); + where_clause.push(serde_json::json!(["optional", pattern])); + delete.push(pattern); + } + for (i, (id, prop)) in replaced_props.iter().enumerate() { + let pattern = serde_json::json!({"@id": id, *prop: format!("?rv{i}")}); + where_clause.push(serde_json::json!(["optional", pattern])); + delete.push(pattern); + } + let insert = graph + .get("@graph") + .cloned() + .unwrap_or_else(|| graph.clone()); + serde_json::json!({ + "where": where_clause, + "delete": delete, + "insert": insert, + }) +} + +/// Absolute-IRI guard shared by every facet's flag validation. +pub(crate) fn require_absolute_iri(flag: &str, v: &str) -> CliResult<()> { + if v.starts_with("http://") || v.starts_with("https://") || v.starts_with("urn:") { + Ok(()) + } else { + Err(CliError::Usage(format!( + "{flag} must be an absolute IRI (got '{v}') — e.g. https://example.org/Lead" + ))) + } +} + +/// Extract IRI strings from a single-variable query result (rows may be +/// strings, one-element arrays, or `{"@id": …}` objects). +pub(crate) fn iri_rows(result: &Value) -> Vec { + let rows = match result { + Value::Array(rows) => rows.as_slice(), + _ => return vec![], + }; + rows.iter() + .filter_map(|row| match row { + Value::String(s) => Some(s.clone()), + Value::Array(inner) => inner.first().and_then(|v| v.as_str()).map(String::from), + Value::Object(o) => o.get("@id").and_then(|v| v.as_str()).map(String::from), + _ => None, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::replace_txn; + use serde_json::json; + + #[test] + fn replace_txn_wildcard_deletes_each_owned_node() { + let graph = json!({"@graph": [ + {"@id": "https://x/A", "https://x/p": 1}, + {"@id": "https://x/B", "https://x/p": 2}, + ]}); + let txn = replace_txn(&graph, &["https://x/A", "https://x/B"], &[]); + + let where_clause = txn["where"].as_array().unwrap(); + assert_eq!(where_clause.len(), 2); + // Every owned id gets a delete-if-exists: optional wildcard match + // with per-node variables, mirrored in the delete templates. + assert_eq!(where_clause[0][0], "optional"); + assert_eq!(where_clause[0][1]["@id"], "https://x/A"); + assert_eq!(where_clause[0][1]["?p0"], "?o0"); + assert_eq!(where_clause[1][1]["?p1"], "?o1"); + + let delete = txn["delete"].as_array().unwrap(); + assert_eq!(delete[0]["@id"], "https://x/A"); + assert_eq!(delete[0]["?p0"], "?o0"); + assert_eq!(delete[1]["@id"], "https://x/B"); + assert_eq!(delete[1]["?p1"], "?o1"); + + assert_eq!(txn["insert"], graph["@graph"]); + } + + #[test] + fn replace_txn_can_own_ids_absent_from_the_graph() { + // Switching profiles must revoke the node the new profile no longer + // emits (e.g. write → read drops {class}/write): owned ids are + // deleted even when nothing in the graph reinserts them. + let graph = json!({"@graph": [{"@id": "https://x/view", "https://x/p": 1}]}); + let txn = replace_txn(&graph, &["https://x/view", "https://x/write"], &[]); + assert_eq!(txn["delete"].as_array().unwrap().len(), 2); + assert_eq!(txn["delete"][1]["@id"], "https://x/write"); + assert_eq!(txn["insert"].as_array().unwrap().len(), 1); + } + + #[test] + fn replace_txn_deletes_replaced_props_on_unowned_nodes() { + // A shared-authorship node stays additive except the listed + // property, whose old value is deleted (label re-run idempotency). + let graph = json!({"@graph": [ + {"@id": "https://x/Lead", "https://x/label": "New"}, + ]}); + let txn = replace_txn(&graph, &[], &[("https://x/Lead", "https://x/label")]); + + let where_clause = txn["where"].as_array().unwrap(); + assert_eq!(where_clause.len(), 1); + assert_eq!(where_clause[0][0], "optional"); + assert_eq!(where_clause[0][1]["@id"], "https://x/Lead"); + assert_eq!(where_clause[0][1]["https://x/label"], "?rv0"); + + let delete = txn["delete"].as_array().unwrap(); + assert_eq!(delete.len(), 1); + assert_eq!(delete[0]["https://x/label"], "?rv0"); + } +} diff --git a/fluree-db-cli/src/lib.rs b/fluree-db-cli/src/lib.rs index 99c60c66d6..d42fff6e77 100644 --- a/fluree-db-cli/src/lib.rs +++ b/fluree-db-cli/src/lib.rs @@ -647,6 +647,11 @@ pub async fn run(cli: Cli) -> error::CliResult<()> { "server support not compiled. Rebuild with `--features server`.".into(), )), + Commands::Model { action } => { + let fluree_dir = config::require_fluree_dir(config_path)?; + commands::model::run(&action, &fluree_dir, direct).await + } + Commands::Memory { action } => { let fluree_dir = config::require_fluree_dir(config_path)?; commands::memory::run(action, &fluree_dir).await