From d77db5362d028f96bd945c0f56497bf5372be6b9 Mon Sep 17 00:00:00 2001 From: bplatz Date: Tue, 7 Jul 2026 22:36:05 -0400 Subject: [PATCH 01/12] =?UTF-8?q?feat(cli):=20fluree=20model=20access=20?= =?UTF-8?q?=E2=80=94=20access-profile=20policy=20compiler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First facet of the governance-model tooling (fluree model). Users declare intent; the CLI compiles it to stored policies and transacts them as ordinary data — no bespoke server API, so it works identically against local ledgers, fluree-db-server, and hosted stacks. fluree model access enable --profile read|write|intake --entity [--property ...] [--allow-shared] [--class-iri ] [--dry-run] [--remote ] fluree model access show Compilation: a policy class (the assignment unit grants/tokens carry), a view policy (f:onClass — exact for reads), a property-whitelist modify policy (f:onProperty + f:allow — class-targeted modify cannot cover new-subject inserts and f:query evaluates pre-state), and a declarative fm:AccessProfile node recording the intent so enable is idempotent (upsert) and future sync/verify re-derive from it. Property derivation: explicit → SHACL shape → observed data → fail-closed. Uniqueness partition: properties used by other classes are excluded by default with the blast radius disclosed ('also used by: Invoice'), included only with --allow-shared; every run prints an exactness verdict (class-exact vs property-approximate) and flags the rdf:type creation caveat. Unit tests cover compilation shapes per profile, the parse gates, and the IRI validation; verified end-to-end in local mode (enable → show → identity+policy-class insert allowed → non-whitelisted write denied → shared-property partition + disclosure). --- fluree-db-cli/src/cli.rs | 74 +++ fluree-db-cli/src/commands/mod.rs | 1 + fluree-db-cli/src/commands/model/access.rs | 542 +++++++++++++++++++++ fluree-db-cli/src/commands/model/mod.rs | 24 + fluree-db-cli/src/lib.rs | 5 + 5 files changed, 646 insertions(+) create mode 100644 fluree-db-cli/src/commands/model/access.rs create mode 100644 fluree-db-cli/src/commands/model/mod.rs diff --git a/fluree-db-cli/src/cli.rs b/fluree-db-cli/src/cli.rs index 073c387c8c..10fb40b6f0 100644 --- a/fluree-db-cli/src/cli.rs +++ b/fluree-db-cli/src/cli.rs @@ -1093,6 +1093,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)] @@ -1482,6 +1488,74 @@ pub enum ClusterAction { }, } +/// Governance model subcommands. +#[derive(Subcommand)] +pub enum ModelAction { + /// Access control — compile intent into ledger policies + Access { + #[command(subcommand)] + action: ModelAccessAction, + }, +} + +/// Access-facet subcommands of `fluree model`. +#[derive(Subcommand)] +pub enum ModelAccessAction { + /// Enable an access profile on a dataset (compiles to policies) + /// + /// Declares WHAT apps and other policy-classed principals may do with + /// entities of a class, and compiles that intent into stored policies: + /// a policy class, a view policy, and a property-whitelist modify + /// policy. The intent is stored as a declarative profile node so + /// re-running is idempotent and `sync`/`verify` can re-derive later. + Enable { + /// Target dataset (ledger alias) + dataset: String, + + /// Profile: read | write | intake + #[arg(long)] + profile: String, + + /// Entity class IRI (absolute, e.g. https://example.org/Lead) + #[arg(long)] + entity: String, + + /// Property IRIs to permit (absolute). If omitted, derived from the + /// entity's SHACL shape, else from observed data (fail-closed if + /// neither exists). + #[arg(long = "property")] + properties: Vec, + + /// Include properties that other classes also use (the compiler + /// discloses the blast radius; without this flag shared properties + /// are excluded — fail closed on precision) + #[arg(long)] + allow_shared: bool, + + /// Policy class IRI override (default: {entity}/access/{profile}) + #[arg(long)] + class_iri: Option, + + /// Print the compiled JSON-LD without transacting + #[arg(long)] + dry_run: bool, + + /// Remote to run against + #[arg(long)] + remote: Option, + }, + + /// Show access profiles and their compiled policies on a dataset + 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..84b37af9d0 --- /dev/null +++ b/fluree-db-cli/src/commands/model/access.rs @@ -0,0 +1,542 @@ +//! `fluree model access` — the access-profile policy compiler. +//! +//! Users declare intent ("apps may write Leads"); this module compiles it to +//! the cheapest enforcement shape that expresses it and transacts the result +//! as ordinary data: +//! +//! * a **policy class** (`rdfs:Class`) — the assignment unit grants and +//! tokens carry; +//! * a **view policy** (`f:onClass` — exact for reads); +//! * a **modify policy** as a **property whitelist** (`f:onProperty` + +//! `f:allow`) — class-targeted modify cannot cover new-subject inserts and +//! `f:query` evaluates against pre-state, so the whitelist is the correct +//! (and cheapest) write shape today; +//! * a declarative **profile node** recording the intent (type, properties, +//! compiler version) so `enable` is idempotent and future `sync`/`verify` +//! re-derive instead of reverse-engineering. +//! +//! Exactness honesty: a property whitelist is only class-exact when the +//! properties are unique to the class. The compiler partitions derived +//! properties by observed usage, discloses shared-property blast radius, and +//! requires `--allow-shared` to include them. `rdf:type` is always included +//! (required for creation) and always flagged: until the engine supports +//! object-value constraints on type flakes, a whitelist holder can assert +//! other types using only whitelisted properties. + +use serde_json::{json, Value}; + +use crate::cli::ModelAccessAction; +use crate::context::{self, LedgerMode}; +use crate::error::{CliError, CliResult}; +use fluree_db_api::server_defaults::FlureeDir; + +const F: &str = "https://ns.flur.ee/db#"; +const FM: &str = "https://ns.flur.ee/model#"; +const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; +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"; +const SH: &str = "http://www.w3.org/ns/shacl#"; +const COMPILER_VERSION: &str = "1"; + +/// Access profiles: what the compiled policy set permits. +#[derive(Clone, Copy, PartialEq)] +enum Profile { + /// View entities of the class (class-exact today). + Read, + /// Create + edit entities of the class (property-compiled). + Write, + /// Submit without reading back (write-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) + } + + fn wants_modify(&self) -> bool { + matches!(self, Self::Write | Self::Intake) + } +} + +pub async fn run(action: &ModelAccessAction, dirs: &FlureeDir, direct: bool) -> CliResult<()> { + match action { + ModelAccessAction::Enable { + dataset, + profile, + entity, + properties, + allow_shared, + class_iri, + dry_run, + remote, + } => { + run_enable( + dataset, + profile, + entity, + properties, + *allow_shared, + class_iri.as_deref(), + *dry_run, + remote.as_deref(), + dirs, + direct, + ) + .await + } + ModelAccessAction::Show { dataset, remote } => { + run_show(dataset, remote.as_deref(), dirs, direct).await + } + } +} + +// ── mode-agnostic ledger IO ───────────────────────────────────────────── + +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. +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 keeps +/// `enable` idempotent when the property set changes). +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(()) +} + +// ── enable ────────────────────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +async fn run_enable( + dataset: &str, + profile_str: &str, + entity: &str, + explicit_properties: &[String], + allow_shared: bool, + class_iri_override: Option<&str>, + dry_run: bool, + remote: Option<&str>, + dirs: &FlureeDir, + direct: bool, +) -> CliResult<()> { + let profile = Profile::parse(profile_str)?; + require_absolute_iri("--entity", entity)?; + for p in explicit_properties { + require_absolute_iri("--property", p)?; + } + + let mode = resolve_mode(dataset, remote, dirs, direct).await?; + + // 1. Derive the property surface: explicit → SHACL → observed → fail. + let (properties, derivation) = if !explicit_properties.is_empty() { + (explicit_properties.to_vec(), "explicit") + } else { + let from_shape = derive_from_shacl(&mode, entity).await?; + if !from_shape.is_empty() { + (from_shape, "shacl-shape") + } else { + let observed = derive_from_observed(&mode, entity).await?; + if observed.is_empty() { + return Err(CliError::Usage(format!( + "cannot derive properties for {entity}: no SHACL shape targets it and \ + no instances exist. Pass --property explicitly (fail-closed)." + ))); + } + (observed, "observed-data") + } + }; + + // 2. Uniqueness partition: which of these properties do OTHER classes use? + let mut included: Vec = Vec::new(); + let mut shared: Vec<(String, Vec)> = Vec::new(); + for prop in &properties { + if prop == RDF_TYPE { + continue; // handled below, always included + flagged + } + let others = classes_sharing_property(&mode, prop, entity).await?; + if others.is_empty() { + included.push(prop.clone()); + } else { + shared.push((prop.clone(), others)); + } + } + let mut whitelist: Vec = vec![RDF_TYPE.to_string()]; + whitelist.extend(included.iter().cloned()); + if allow_shared { + whitelist.extend(shared.iter().map(|(p, _)| p.clone())); + } + + // 3. Compile. + let class_iri = class_iri_override.map(String::from).unwrap_or_else(|| { + format!( + "{}/access/{}", + entity.trim_end_matches('/'), + profile.as_str() + ) + }); + let graph = compile(&class_iri, entity, profile, &whitelist); + + // 4. Report. + let exactness = if shared.is_empty() || !allow_shared { + "class-exact (all whitelisted properties are unique to this class)" + } else { + "property-approximate (shared properties included — see below)" + }; + println!("Profile: {} {}", profile.as_str(), entity); + println!("Class: {class_iri}"); + println!("Derivation: {derivation}"); + println!("Exactness: {exactness}"); + if profile.wants_modify() { + println!( + "Whitelist: {} properties (+ rdf:type)", + whitelist.len() - 1 + ); + println!( + " note: rdf:type is required for creation; until the engine constrains type\n\ + \x20 object values, whitelist holders can assert other types using only\n\ + \x20 whitelisted properties." + ); + } + for (prop, others) in &shared { + let status = if allow_shared { + "INCLUDED (--allow-shared)" + } else { + "EXCLUDED (pass --allow-shared to include)" + }; + println!( + " shared: {prop} — also used by {} — {status}", + others.join(", ") + ); + } + + if dry_run { + println!("\n-- dry run; compiled JSON-LD --"); + println!("{}", serde_json::to_string_pretty(&graph)?); + return Ok(()); + } + + // 5. Transact (data plane — policies live in the ledger). + upsert(&mode, &graph).await?; + println!("\nEnabled. Policies written to '{dataset}'."); + println!( + "Attach to a grant so tokens carry it:\n\ + \x20 POST /v1/datasets/{dataset}/grants\n\ + \x20 {{\"scopeType\": \"space\", \"scopeRef\": \"\", \"access\": \"{}\", \"policyClasses\": [\"{class_iri}\"]}}", + if profile.wants_modify() { "write" } else { "read" }, + ); + Ok(()) +} + +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" + ))) + } +} + +/// Properties declared by a SHACL shape targeting the entity class. +async fn derive_from_shacl(mode: &LedgerMode, entity: &str) -> CliResult> { + let q = json!({ + "@context": {"sh": SH}, + "select": ["?path"], + "where": [ + {"@id": "?shape", "sh:targetClass": {"@id": entity}}, + {"@id": "?shape", "sh:property": {"@id": "?p"}}, + {"@id": "?p", "sh:path": {"@id": "?path"}} + ] + }); + Ok(iri_rows(&query(mode, &q).await?)) +} + +/// Distinct predicates observed on instances of the entity class. +async fn derive_from_observed(mode: &LedgerMode, entity: &str) -> CliResult> { + let q = json!({ + "select": ["?p"], + "where": [ + {"@id": "?s", "@type": entity}, + {"@id": "?s", "?p": "?o"} + ] + }); + let mut props = iri_rows(&query(mode, &q).await?); + props.retain(|p| p != RDF_TYPE); + props.sort(); + props.dedup(); + Ok(props) +} + +/// Other classes whose instances also use this property. +async fn classes_sharing_property( + mode: &LedgerMode, + prop: &str, + entity: &str, +) -> CliResult> { + let q = json!({ + "select": ["?c"], + "where": [ + {"@id": "?s", prop: "?o"}, + {"@id": "?s", "@type": "?c"} + ] + }); + let mut classes = iri_rows(&query(mode, &q).await?); + classes.sort(); + classes.dedup(); + classes.retain(|c| c != entity && !c.starts_with(F)); + Ok(classes) +} + +/// Flatten a select result of single-binding rows into IRI strings. +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() +} + +/// Compile the profile into its JSON-LD artifacts. +fn compile(class_iri: &str, entity: &str, profile: Profile, whitelist: &[String]) -> Value { + let mut nodes: Vec = Vec::new(); + + // The policy class — the assignment unit grants and tokens carry. + nodes.push(json!({ + "@id": class_iri, + "@type": RDFS_CLASS, + RDFS_LABEL: format!("{} access: {}", profile.as_str(), entity), + })); + + // The declarative profile node — intent, not artifact. `sync`/`verify` + // re-derive from this; the compiler version makes upgrades a recompile. + nodes.push(json!({ + "@id": format!("{class_iri}/profile"), + "@type": format!("{FM}AccessProfile"), + format!("{FM}profile"): profile.as_str(), + format!("{FM}onType"): {"@id": entity}, + format!("{FM}property"): whitelist.iter().map(|p| json!({"@id": p})).collect::>(), + format!("{FM}policyClass"): {"@id": class_iri}, + format!("{FM}compilerVersion"): COMPILER_VERSION, + })); + + if profile.wants_view() { + nodes.push(json!({ + "@id": format!("{class_iri}/view"), + "@type": [format!("{F}AccessPolicy"), class_iri], + format!("{F}action"): {"@id": format!("{F}view")}, + format!("{F}allow"): true, + format!("{F}onClass"): {"@id": entity}, + })); + } + if profile.wants_modify() { + nodes.push(json!({ + "@id": format!("{class_iri}/modify"), + "@type": [format!("{F}AccessPolicy"), class_iri], + format!("{F}action"): {"@id": format!("{F}modify")}, + format!("{F}allow"): true, + format!("{F}onProperty"): whitelist.iter().map(|p| json!({"@id": p})).collect::>(), + })); + } + + json!({"@graph": nodes}) +} + +// ── show ──────────────────────────────────────────────────────────────── + +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": {"fm": FM}, + "select": {"?profile": ["*"]}, + "where": [{"@id": "?profile", "@type": "fm:AccessProfile"}] + }); + let result = query(&mode, &q).await?; + let rows = result.as_array().cloned().unwrap_or_default(); + if rows.is_empty() { + println!("No access profiles on '{dataset}'."); + println!("Enable one: fluree model access enable {dataset} --profile write --entity "); + return Ok(()); + } + println!("Access profiles on '{dataset}':\n"); + for row in &rows { + let get = |k: &str| -> String { + row.get(k) + .map(render_value) + .unwrap_or_else(|| "-".to_string()) + }; + println!("• {}", get("@id")); + println!(" profile: {}", get("fm:profile")); + println!(" type: {}", get("fm:onType")); + println!(" class: {}", get("fm:policyClass")); + println!(" props: {}", get("fm:property")); + println!(" compiler: v{}", get("fm:compilerVersion")); + } + Ok(()) +} + +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(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compile_write_profile_emits_class_profile_view_and_whitelist() { + let whitelist = vec![RDF_TYPE.to_string(), "https://example.org/name".to_string()]; + let graph = compile( + "https://example.org/Lead/access/write", + "https://example.org/Lead", + Profile::Write, + &whitelist, + ); + let nodes = graph["@graph"].as_array().unwrap(); + assert_eq!(nodes.len(), 4, "class + profile + view + modify"); + + let modify = nodes + .iter() + .find(|n| n["@id"].as_str().unwrap().ends_with("/modify")) + .expect("modify policy"); + let types: Vec<&str> = modify["@type"] + .as_array() + .unwrap() + .iter() + .map(|t| t.as_str().unwrap()) + .collect(); + assert!(types.contains(&"https://ns.flur.ee/db#AccessPolicy")); + assert!(types.contains(&"https://example.org/Lead/access/write")); + let props = modify["https://ns.flur.ee/db#onProperty"] + .as_array() + .unwrap(); + assert_eq!(props.len(), 2, "rdf:type + name"); + } + + #[test] + fn compile_read_profile_has_no_modify_policy() { + let graph = compile( + "https://example.org/Lead/access/read", + "https://example.org/Lead", + Profile::Read, + &[RDF_TYPE.to_string()], + ); + let nodes = graph["@graph"].as_array().unwrap(); + assert_eq!(nodes.len(), 3, "class + profile + view (no modify)"); + assert!(nodes + .iter() + .all(|n| !n["@id"].as_str().unwrap().ends_with("/modify"))); + } + + #[test] + fn compile_intake_profile_has_no_view_policy() { + let graph = compile( + "https://example.org/Lead/access/intake", + "https://example.org/Lead", + Profile::Intake, + &[RDF_TYPE.to_string()], + ); + let nodes = graph["@graph"].as_array().unwrap(); + assert_eq!(nodes.len(), 3, "class + profile + modify (no view)"); + assert!(nodes + .iter() + .all(|n| !n["@id"].as_str().unwrap().ends_with("/view"))); + } + + #[test] + fn profile_parse_rejects_unknown() { + assert!(Profile::parse("admin").is_err()); + } + + #[test] + fn absolute_iri_gate() { + assert!(require_absolute_iri("--entity", "https://example.org/Lead").is_ok()); + assert!(require_absolute_iri("--entity", "urn:example:Lead").is_ok()); + assert!(require_absolute_iri("--entity", "ex:Lead").is_err()); + } +} 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..7fc6651b7e --- /dev/null +++ b/fluree-db-cli/src/commands/model/mod.rs @@ -0,0 +1,24 @@ +//! `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; + +use crate::cli::ModelAction; +use crate::error::CliResult; +use fluree_db_api::server_defaults::FlureeDir; + +pub async fn run(action: &ModelAction, dirs: &FlureeDir, direct: bool) -> CliResult<()> { + match action { + ModelAction::Access { action } => access::run(action, dirs, direct).await, + } +} 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 From 576c88a298d4b1dbe6df2c109b78090bce8c2443 Mon Sep 17 00:00:00 2001 From: bplatz Date: Tue, 7 Jul 2026 22:43:48 -0400 Subject: [PATCH 02/12] =?UTF-8?q?feat(cli):=20model=20access=20enable=20--?= =?UTF-8?q?space=20=E2=80=94=20grant=20attachment=20on=20hosted=20stacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the enable flow end-to-end: after the policies land in the ledger (data plane), --space merges the compiled policy class into the space's grant on the dataset via the stack's grants API — the one system-plane touch in the compiler, since grants are router-owned invariants (scope validation, membership checks). - Reads existing grants first and merges classes (never clobbers); keeps the existing access level, upgrading read → write with a printed notice only when the profile requires writes. - Policies-then-grant ordering is deliberate: a partial failure leaves unused policies (harmless), never a grant naming absent classes. - Root URL derived from the remote's data-plane URL (…/v1/fluree); clear errors when the remote isn't a hosted stack or has no token. - --space without --remote is a usage error. Verified against a live stack: enable … --space wrote 4 policies + attached the grant in one command; a minted space app token then carried leads2 with the compiled class, a whitelisted insert passed, and a non-whitelisted write was rejected at the ledger. --- fluree-db-cli/src/cli.rs | 5 + fluree-db-cli/src/commands/model/access.rs | 164 ++++++++++++++++++++- 2 files changed, 164 insertions(+), 5 deletions(-) diff --git a/fluree-db-cli/src/cli.rs b/fluree-db-cli/src/cli.rs index 10fb40b6f0..621d95e3e2 100644 --- a/fluree-db-cli/src/cli.rs +++ b/fluree-db-cli/src/cli.rs @@ -1536,6 +1536,11 @@ pub enum ModelAccessAction { #[arg(long)] class_iri: 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, + /// Print the compiled JSON-LD without transacting #[arg(long)] dry_run: bool, diff --git a/fluree-db-cli/src/commands/model/access.rs b/fluree-db-cli/src/commands/model/access.rs index 84b37af9d0..f54bd71ef3 100644 --- a/fluree-db-cli/src/commands/model/access.rs +++ b/fluree-db-cli/src/commands/model/access.rs @@ -87,6 +87,7 @@ pub async fn run(action: &ModelAccessAction, dirs: &FlureeDir, direct: bool) -> properties, allow_shared, class_iri, + space, dry_run, remote, } => { @@ -97,6 +98,7 @@ pub async fn run(action: &ModelAccessAction, dirs: &FlureeDir, direct: bool) -> properties, *allow_shared, class_iri.as_deref(), + space.as_deref(), *dry_run, remote.as_deref(), dirs, @@ -175,6 +177,7 @@ async fn run_enable( explicit_properties: &[String], allow_shared: bool, class_iri_override: Option<&str>, + space: Option<&str>, dry_run: bool, remote: Option<&str>, dirs: &FlureeDir, @@ -185,6 +188,11 @@ async fn run_enable( for p in explicit_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(), + )); + } let mode = resolve_mode(dataset, remote, dirs, direct).await?; @@ -276,14 +284,160 @@ async fn run_enable( return Ok(()); } - // 5. Transact (data plane — policies live in the ledger). + // 5. 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. upsert(&mode, &graph).await?; println!("\nEnabled. Policies written to '{dataset}'."); + + // 6. Grant attachment (hosted stacks): merge the class into the space's + // grant so minted tokens carry it. + if let (Some(space_id), Some(remote_name)) = (space, remote) { + attach_grant( + dirs, + remote_name, + dataset, + space_id, + &class_iri, + profile.wants_modify(), + ) + .await?; + } else { + println!( + "Attach to a grant so tokens carry it (or re-run with --space ):\n\ + \x20 POST /v1/datasets/{dataset}/grants\n\ + \x20 {{\"scopeType\": \"space\", \"scopeRef\": \"\", \"access\": \"{}\", \"policyClasses\": [\"{class_iri}\"]}}", + if profile.wants_modify() { "write" } else { "read" }, + ); + } + Ok(()) +} + +// ── grant attachment (hosted stacks) ──────────────────────────────────── + +/// Merge `class_iri` 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, + class_iri: &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 == class_iri) { + classes.push(class_iri.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!( - "Attach to a grant so tokens carry it:\n\ - \x20 POST /v1/datasets/{dataset}/grants\n\ - \x20 {{\"scopeType\": \"space\", \"scopeRef\": \"\", \"access\": \"{}\", \"policyClasses\": [\"{class_iri}\"]}}", - if profile.wants_modify() { "write" } else { "read" }, + "Grant attached: space {space_id} → {dataset_id} ({access}, {} class{})", + classes.len(), + if classes.len() == 1 { "" } else { "es" } ); Ok(()) } From 1ddea5018440d45042fe44660663ff190bfa71a9 Mon Sep 17 00:00:00 2001 From: bplatz Date: Tue, 7 Jul 2026 22:49:39 -0400 Subject: [PATCH 03/12] =?UTF-8?q?refactor(cli):=20model=20access=20?= =?UTF-8?q?=E2=80=94=20rename=20whitelist=20to=20allow-list/allowed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminology only: user-facing output now prints 'Allowed: N properties', identifiers use allowed/allow_list, docs say property allow-list. No behavior change. --- fluree-db-cli/src/commands/model/access.rs | 41 ++++++++++------------ 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/fluree-db-cli/src/commands/model/access.rs b/fluree-db-cli/src/commands/model/access.rs index f54bd71ef3..f777d154e4 100644 --- a/fluree-db-cli/src/commands/model/access.rs +++ b/fluree-db-cli/src/commands/model/access.rs @@ -7,21 +7,21 @@ //! * a **policy class** (`rdfs:Class`) — the assignment unit grants and //! tokens carry; //! * a **view policy** (`f:onClass` — exact for reads); -//! * a **modify policy** as a **property whitelist** (`f:onProperty` + +//! * a **modify policy** as a **property allow-list** (`f:onProperty` + //! `f:allow`) — class-targeted modify cannot cover new-subject inserts and -//! `f:query` evaluates against pre-state, so the whitelist is the correct +//! `f:query` evaluates against pre-state, so the allow-list is the correct //! (and cheapest) write shape today; //! * a declarative **profile node** recording the intent (type, properties, //! compiler version) so `enable` is idempotent and future `sync`/`verify` //! re-derive instead of reverse-engineering. //! -//! Exactness honesty: a property whitelist is only class-exact when the +//! Exactness honesty: a property allow-list is only class-exact when the //! properties are unique to the class. The compiler partitions derived //! properties by observed usage, discloses shared-property blast radius, and //! requires `--allow-shared` to include them. `rdf:type` is always included //! (required for creation) and always flagged: until the engine supports -//! object-value constraints on type flakes, a whitelist holder can assert -//! other types using only whitelisted properties. +//! object-value constraints on type flakes, a allow_list holder can assert +//! other types using only allowed properties. use serde_json::{json, Value}; @@ -229,10 +229,10 @@ async fn run_enable( shared.push((prop.clone(), others)); } } - let mut whitelist: Vec = vec![RDF_TYPE.to_string()]; - whitelist.extend(included.iter().cloned()); + let mut allowed: Vec = vec![RDF_TYPE.to_string()]; + allowed.extend(included.iter().cloned()); if allow_shared { - whitelist.extend(shared.iter().map(|(p, _)| p.clone())); + allowed.extend(shared.iter().map(|(p, _)| p.clone())); } // 3. Compile. @@ -243,11 +243,11 @@ async fn run_enable( profile.as_str() ) }); - let graph = compile(&class_iri, entity, profile, &whitelist); + let graph = compile(&class_iri, entity, profile, &allowed); // 4. Report. let exactness = if shared.is_empty() || !allow_shared { - "class-exact (all whitelisted properties are unique to this class)" + "class-exact (all allowed properties are unique to this class)" } else { "property-approximate (shared properties included — see below)" }; @@ -256,14 +256,11 @@ async fn run_enable( println!("Derivation: {derivation}"); println!("Exactness: {exactness}"); if profile.wants_modify() { - println!( - "Whitelist: {} properties (+ rdf:type)", - whitelist.len() - 1 - ); + println!("Allowed: {} properties (+ rdf:type)", allowed.len() - 1); println!( " note: rdf:type is required for creation; until the engine constrains type\n\ - \x20 object values, whitelist holders can assert other types using only\n\ - \x20 whitelisted properties." + \x20 object values, allow-list holders can assert other types using only\n\ + \x20 allowed properties." ); } for (prop, others) in &shared { @@ -519,7 +516,7 @@ fn iri_rows(result: &Value) -> Vec { } /// Compile the profile into its JSON-LD artifacts. -fn compile(class_iri: &str, entity: &str, profile: Profile, whitelist: &[String]) -> Value { +fn compile(class_iri: &str, entity: &str, profile: Profile, allowed: &[String]) -> Value { let mut nodes: Vec = Vec::new(); // The policy class — the assignment unit grants and tokens carry. @@ -536,7 +533,7 @@ fn compile(class_iri: &str, entity: &str, profile: Profile, whitelist: &[String] "@type": format!("{FM}AccessProfile"), format!("{FM}profile"): profile.as_str(), format!("{FM}onType"): {"@id": entity}, - format!("{FM}property"): whitelist.iter().map(|p| json!({"@id": p})).collect::>(), + format!("{FM}property"): allowed.iter().map(|p| json!({"@id": p})).collect::>(), format!("{FM}policyClass"): {"@id": class_iri}, format!("{FM}compilerVersion"): COMPILER_VERSION, })); @@ -556,7 +553,7 @@ fn compile(class_iri: &str, entity: &str, profile: Profile, whitelist: &[String] "@type": [format!("{F}AccessPolicy"), class_iri], format!("{F}action"): {"@id": format!("{F}modify")}, format!("{F}allow"): true, - format!("{F}onProperty"): whitelist.iter().map(|p| json!({"@id": p})).collect::>(), + format!("{F}onProperty"): allowed.iter().map(|p| json!({"@id": p})).collect::>(), })); } @@ -623,13 +620,13 @@ mod tests { use super::*; #[test] - fn compile_write_profile_emits_class_profile_view_and_whitelist() { - let whitelist = vec![RDF_TYPE.to_string(), "https://example.org/name".to_string()]; + fn compile_write_profile_emits_class_profile_view_and_allow_list() { + let allowed = vec![RDF_TYPE.to_string(), "https://example.org/name".to_string()]; let graph = compile( "https://example.org/Lead/access/write", "https://example.org/Lead", Profile::Write, - &whitelist, + &allowed, ); let nodes = graph["@graph"].as_array().unwrap(); assert_eq!(nodes.len(), 4, "class + profile + view + modify"); From 6d54b4f5a8a8640d0158b9c3e98cd36404af6864 Mon Sep 17 00:00:00 2001 From: bplatz Date: Tue, 7 Jul 2026 22:53:08 -0400 Subject: [PATCH 04/12] =?UTF-8?q?feat(cli):=20fluree=20model=20entity=20?= =?UTF-8?q?=E2=80=94=20SHACL=20entity=20definitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second facet of the governance-model tooling. The entity shape is the model's single source of truth: the property list is written ONCE here and everything else derives — model access enable now prefers shape-derivation for its allow-lists, transaction-time validation enforces the shape (the shapes-exist heuristic activates reject mode), and SDK types / form fields can be generated from it later. fluree model entity define --entity [--label ] --property " [string|integer|decimal|boolean|date|datetime|iri] [required] [in[v1,v2,...]]" ... fluree model entity show Compiles to an rdfs:Class node + a sh:NodeShape targeting it, with stable property-shape ids ({entity}/shape/{localname}) so re-define is an idempotent upsert. The define output states plainly that validation becomes ACTIVE (existing data is not retro-validated; fluree validate reports) and prints the model access enable synergy hint. Shared ledger-IO helpers (resolve_mode/query/upsert) promoted from access.rs to model/mod.rs. Verified locally end-to-end: define (no seed data) → access enable reports 'Derivation: shacl-shape' (class-exact, 3 properties) → a shape-violating insert is rejected by SHACL with a violation report → a conforming insert through the compiled policy class commits → both show views render. --- fluree-db-cli/src/cli.rs | 52 +++ fluree-db-cli/src/commands/model/access.rs | 58 +-- fluree-db-cli/src/commands/model/entity.rs | 394 +++++++++++++++++++++ fluree-db-cli/src/commands/model/mod.rs | 59 +++ 4 files changed, 507 insertions(+), 56 deletions(-) create mode 100644 fluree-db-cli/src/commands/model/entity.rs diff --git a/fluree-db-cli/src/cli.rs b/fluree-db-cli/src/cli.rs index 621d95e3e2..6d82dc9dfb 100644 --- a/fluree-db-cli/src/cli.rs +++ b/fluree-db-cli/src/cli.rs @@ -1496,6 +1496,58 @@ pub enum ModelAction { #[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, + }, +} + +/// 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, + + /// 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`. diff --git a/fluree-db-cli/src/commands/model/access.rs b/fluree-db-cli/src/commands/model/access.rs index f777d154e4..0fc0a0aa0d 100644 --- a/fluree-db-cli/src/commands/model/access.rs +++ b/fluree-db-cli/src/commands/model/access.rs @@ -25,8 +25,9 @@ use serde_json::{json, Value}; +use super::{query, resolve_mode, upsert}; use crate::cli::ModelAccessAction; -use crate::context::{self, LedgerMode}; +use crate::context::LedgerMode; use crate::error::{CliError, CliResult}; use fluree_db_api::server_defaults::FlureeDir; @@ -112,61 +113,6 @@ pub async fn run(action: &ModelAccessAction, dirs: &FlureeDir, direct: bool) -> } } -// ── mode-agnostic ledger IO ───────────────────────────────────────────── - -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. -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 keeps -/// `enable` idempotent when the property set changes). -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(()) -} - // ── enable ────────────────────────────────────────────────────────────── #[allow(clippy::too_many_arguments)] 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..574d7b706b --- /dev/null +++ b/fluree-db-cli/src/commands/model/entity.rs @@ -0,0 +1,394 @@ +//! `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, and everything else derives — access +//! profiles compile their allow-lists from it (`model access enable` prefers +//! shape-derivation), validation enforces it, and SDK types / form fields +//! can be generated from it. +//! +//! 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 serde_json::{json, Value}; + +use super::{query, resolve_mode, upsert}; +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(); + if !(path.starts_with("http://") || path.starts_with("https://") || path.starts_with("urn:")) { + return Err(CliError::Usage(format!( + "--property must start with an absolute IRI (got '{path}')" + ))); + } + + let mut out = PropertySpec { + path, + datatype: None, + iri_kind: false, + required: false, + values_in: vec![], + }; + for token in tokens { + match token { + "string" => out.datatype = Some(format!("{XSD}string")), + "integer" => out.datatype = Some(format!("{XSD}integer")), + "decimal" => out.datatype = Some(format!("{XSD}decimal")), + "boolean" => out.datatype = Some(format!("{XSD}boolean")), + "date" => out.datatype = Some(format!("{XSD}date")), + "datetime" => out.datatype = Some(format!("{XSD}dateTime")), + "iri" => 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(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .collect(); + if out.values_in.is_empty() { + return Err(CliError::Usage(format!( + "in[...] must list at least one value (in '{spec}')" + ))); + } + } + other => { + return Err(CliError::Usage(format!( + "unknown token '{other}' in --property spec '{spec}' \ + (expected a type, 'required', or 'in[v1,v2]')" + ))); + } + } + } + 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) +} + +/// Compile the entity definition into its JSON-LD artifacts. +fn compile(entity: &str, label: Option<&str>, specs: &[PropertySpec]) -> 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 shape = json!({ + "@id": shape_iri, + "@type": format!("{SH}NodeShape"), + format!("{SH}targetClass"): {"@id": entity}, + format!("{SH}property"): property_shapes, + }); + + json!({"@graph": [class_node, shape]}) +} + +pub async fn run(action: &ModelEntityAction, dirs: &FlureeDir, direct: bool) -> CliResult<()> { + match action { + ModelEntityAction::Define { + dataset, + entity, + properties, + label, + dry_run, + remote, + } => { + run_define( + dataset, + entity, + properties, + label.as_deref(), + *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>, + dry_run: bool, + remote: Option<&str>, + dirs: &FlureeDir, + direct: bool, +) -> CliResult<()> { + if !(entity.starts_with("http://") + || entity.starts_with("https://") + || entity.starts_with("urn:")) + { + return Err(CliError::Usage(format!( + "--entity must be an absolute IRI (got '{entity}')" + ))); + } + let specs: Vec = property_specs + .iter() + .map(|s| parse_property_spec(s)) + .collect::>()?; + + let graph = compile(entity, label, &specs); + + println!("Entity: {entity}"); + println!("Shape: {}/shape", entity.trim_end_matches('/')); + 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(()); + } + + let mode = resolve_mode(dataset, remote, dirs, direct).await?; + upsert(&mode, &graph).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 --entity {entity}\n\ + \x20 will derive its allowed properties from this shape." + ); + 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 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); + 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 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); + 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 index 7fc6651b7e..477b80cba8 100644 --- a/fluree-db-cli/src/commands/model/mod.rs +++ b/fluree-db-cli/src/commands/model/mod.rs @@ -12,13 +12,72 @@ //! stacks. pub mod access; +pub mod entity; use crate::cli::ModelAction; +use crate::context::{self, LedgerMode}; use crate::error::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, } } + +// ── 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 keeps +/// `enable` idempotent when the property set changes). +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(()) +} From c92575df655dd45650851a3458988407ff002228 Mon Sep 17 00:00:00 2001 From: bplatz Date: Tue, 7 Jul 2026 23:01:25 -0400 Subject: [PATCH 05/12] =?UTF-8?q?feat(cli):=20model=20access=20--connected?= =?UTF-8?q?=20=E2=80=94=20relationship-gated=20visibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third tier of the access compiler: a SPARQL property-path subset (sequence / and inverse ^ of angle-bracketed absolute IRIs), anchored identity → path → subject, expanded into f:query where-patterns on the view policy (f:query replaces f:allow — they are alternative decision modes). The raw path is recorded as fm:connected on the profile node. --connected "^" # I see what I own --connected "/^" # I see entities whose team I'm in v1 scope: --profile read only. Relationship gates evaluate against pre-transaction state, so gating writes would deny every create (the connection triples are not yet visible); staged-state evaluation at the engine lifts this later. Alternatives (|) and transitive (+/*) paths are rejected with a clear message until f:query supports them. Verified live in local mode: two users on different teams, one enable with --connected, and each user's query returns only their own team's entities — graph-native row-level security from a single flag. --- fluree-db-cli/src/cli.rs | 9 + fluree-db-cli/src/commands/model/access.rs | 241 ++++++++++++++++++++- 2 files changed, 243 insertions(+), 7 deletions(-) diff --git a/fluree-db-cli/src/cli.rs b/fluree-db-cli/src/cli.rs index 6d82dc9dfb..e028719404 100644 --- a/fluree-db-cli/src/cli.rs +++ b/fluree-db-cli/src/cli.rs @@ -1593,6 +1593,15 @@ pub enum ModelAccessAction { #[arg(long)] space: Option, + /// Relationship gate (read profile only): a SPARQL property path + /// from the requesting identity to the entity, with angle-bracketed + /// IRIs. e.g. "^" (I see what I own) or + /// "/^" + /// (I see entities whose team I'm a member of). Sequence (/) and + /// inverse (^) steps only. + #[arg(long)] + connected: Option, + /// Print the compiled JSON-LD without transacting #[arg(long)] dry_run: bool, diff --git a/fluree-db-cli/src/commands/model/access.rs b/fluree-db-cli/src/commands/model/access.rs index 0fc0a0aa0d..10ff6bc510 100644 --- a/fluree-db-cli/src/commands/model/access.rs +++ b/fluree-db-cli/src/commands/model/access.rs @@ -89,6 +89,7 @@ pub async fn run(action: &ModelAccessAction, dirs: &FlureeDir, direct: bool) -> allow_shared, class_iri, space, + connected, dry_run, remote, } => { @@ -100,6 +101,7 @@ pub async fn run(action: &ModelAccessAction, dirs: &FlureeDir, direct: bool) -> *allow_shared, class_iri.as_deref(), space.as_deref(), + connected.as_deref(), *dry_run, remote.as_deref(), dirs, @@ -124,6 +126,7 @@ async fn run_enable( allow_shared: bool, class_iri_override: Option<&str>, space: Option<&str>, + connected: Option<&str>, dry_run: bool, remote: Option<&str>, dirs: &FlureeDir, @@ -139,6 +142,21 @@ async fn run_enable( "--space attaches the policy class to a hosted stack's grant; pass --remote ".into(), )); } + let connected_steps = match connected { + Some(path) => { + if profile != Profile::Read { + return Err(CliError::Usage( + "--connected is supported with --profile read only: relationship gates \ + evaluate against pre-transaction state, so gating writes would deny \ + every create (the connection triples are not visible yet). Engine \ + support for staged-state gates lifts this later." + .into(), + )); + } + Some(parse_property_path(path)?) + } + None => None, + }; let mode = resolve_mode(dataset, remote, dirs, direct).await?; @@ -189,7 +207,14 @@ async fn run_enable( profile.as_str() ) }); - let graph = compile(&class_iri, entity, profile, &allowed); + let graph = compile( + &class_iri, + entity, + profile, + &allowed, + connected_steps.as_deref(), + connected, + ); // 4. Report. let exactness = if shared.is_empty() || !allow_shared { @@ -201,6 +226,9 @@ async fn run_enable( println!("Class: {class_iri}"); println!("Derivation: {derivation}"); println!("Exactness: {exactness}"); + if let Some(path) = connected { + println!("Connected: {path} (view gated by relationship to the requesting identity)"); + } if profile.wants_modify() { println!("Allowed: {} properties (+ rdf:type)", allowed.len() - 1); println!( @@ -461,8 +489,107 @@ fn iri_rows(result: &Value) -> Vec { .collect() } +/// One step of a `--connected` property path. +#[derive(Debug, PartialEq)] +struct PathStep { + iri: String, + inverse: bool, +} + +/// Parse a SPARQL property-path subset: sequence (`/`) of optionally +/// inverse (`^`) angle-bracketed absolute IRIs. +/// +/// `"/^"` → +/// identity —memberOf→ ?v0 ←team— subject. +fn parse_property_path(path: &str) -> CliResult> { + // Split on '/' outside angle brackets (IRIs contain slashes). + let mut raw_steps: Vec = vec![String::new()]; + let mut in_iri = false; + for c in path.chars() { + match c { + '<' => { + in_iri = true; + raw_steps.last_mut().unwrap().push(c); + } + '>' => { + in_iri = false; + raw_steps.last_mut().unwrap().push(c); + } + '/' if !in_iri => raw_steps.push(String::new()), + _ => raw_steps.last_mut().unwrap().push(c), + } + } + + raw_steps + .iter() + .map(|raw| { + let raw = raw.trim(); + let (inverse, rest) = match raw.strip_prefix('^') { + Some(r) => (true, r.trim()), + None => (false, raw), + }; + let iri = rest + .strip_prefix('<') + .and_then(|r| r.strip_suffix('>')) + .ok_or_else(|| { + CliError::Usage(format!( + "--connected step '{raw}' must be an angle-bracketed IRI, optionally \ + inverse: ^. Supported subset: sequence (/) and inverse (^) — \ + alternatives (|) and transitive (+/*) are not supported yet." + )) + })?; + if !(iri.starts_with("http://") + || iri.starts_with("https://") + || iri.starts_with("urn:")) + { + return Err(CliError::Usage(format!( + "--connected step IRI must be absolute (got '{iri}')" + ))); + } + Ok(PathStep { + iri: iri.to_string(), + inverse, + }) + }) + .collect() +} + +/// Expand path steps into `f:query` where-patterns anchored +/// `?$identity` —path→ `?$this`. +fn compile_path_where(steps: &[PathStep]) -> Vec { + let n = steps.len(); + let node = |i: usize| -> String { + if i == 0 { + "?$identity".to_string() + } else if i == n { + "?$this".to_string() + } else { + format!("?v{}", i - 1) + } + }; + steps + .iter() + .enumerate() + .map(|(i, s)| { + let (from, to) = (node(i), node(i + 1)); + if s.inverse { + json!({"@id": to, s.iri.clone(): {"@id": from}}) + } else { + json!({"@id": from, s.iri.clone(): {"@id": to}}) + } + }) + .collect() +} + /// Compile the profile into its JSON-LD artifacts. -fn compile(class_iri: &str, entity: &str, profile: Profile, allowed: &[String]) -> Value { +fn compile( + class_iri: &str, + entity: &str, + profile: Profile, + allowed: &[String], + connected_steps: Option<&[PathStep]>, + connected_raw: Option<&str>, +) -> Value { let mut nodes: Vec = Vec::new(); // The policy class — the assignment unit grants and tokens carry. @@ -474,7 +601,7 @@ fn compile(class_iri: &str, entity: &str, profile: Profile, allowed: &[String]) // The declarative profile node — intent, not artifact. `sync`/`verify` // re-derive from this; the compiler version makes upgrades a recompile. - nodes.push(json!({ + let mut profile_node = json!({ "@id": format!("{class_iri}/profile"), "@type": format!("{FM}AccessProfile"), format!("{FM}profile"): profile.as_str(), @@ -482,16 +609,34 @@ fn compile(class_iri: &str, entity: &str, profile: Profile, allowed: &[String]) format!("{FM}property"): allowed.iter().map(|p| json!({"@id": p})).collect::>(), format!("{FM}policyClass"): {"@id": class_iri}, format!("{FM}compilerVersion"): COMPILER_VERSION, - })); + }); + if let Some(path) = connected_raw { + profile_node[format!("{FM}connected")] = json!(path); + } + nodes.push(profile_node); if profile.wants_view() { - nodes.push(json!({ + let mut view = json!({ "@id": format!("{class_iri}/view"), "@type": [format!("{F}AccessPolicy"), class_iri], format!("{F}action"): {"@id": format!("{F}view")}, - format!("{F}allow"): true, format!("{F}onClass"): {"@id": entity}, - })); + }); + match connected_steps { + Some(steps) => { + // Relationship gate: the flake is visible when the query + // returns rows — `f:query` REPLACES `f:allow` (they are + // alternative decision modes in the policy model). + let where_patterns = compile_path_where(steps); + view[format!("{F}query")] = + json!(serde_json::to_string(&json!({"where": where_patterns})) + .expect("static JSON serializes")); + } + None => { + view[format!("{F}allow")] = json!(true); + } + } + nodes.push(view); } if profile.wants_modify() { nodes.push(json!({ @@ -573,6 +718,8 @@ mod tests { "https://example.org/Lead", Profile::Write, &allowed, + None, + None, ); let nodes = graph["@graph"].as_array().unwrap(); assert_eq!(nodes.len(), 4, "class + profile + view + modify"); @@ -602,6 +749,8 @@ mod tests { "https://example.org/Lead", Profile::Read, &[RDF_TYPE.to_string()], + None, + None, ); let nodes = graph["@graph"].as_array().unwrap(); assert_eq!(nodes.len(), 3, "class + profile + view (no modify)"); @@ -617,6 +766,8 @@ mod tests { "https://example.org/Lead", Profile::Intake, &[RDF_TYPE.to_string()], + None, + None, ); let nodes = graph["@graph"].as_array().unwrap(); assert_eq!(nodes.len(), 3, "class + profile + modify (no view)"); @@ -637,3 +788,79 @@ mod tests { assert!(require_absolute_iri("--entity", "ex:Lead").is_err()); } } + +#[cfg(test)] +mod connected_tests { + use super::*; + + #[test] + fn parses_single_inverse_step() { + let steps = parse_property_path("^").unwrap(); + assert_eq!(steps.len(), 1); + assert!(steps[0].inverse); + assert_eq!(steps[0].iri, "https://example.org/owner"); + } + + #[test] + fn parses_sequence_with_inverse_tail() { + let steps = + parse_property_path("/^") + .unwrap(); + assert_eq!(steps.len(), 2); + assert!(!steps[0].inverse); + assert!(steps[1].inverse); + } + + #[test] + fn rejects_unbracketed_and_transitive() { + assert!(parse_property_path("https://example.org/owner").is_err()); + assert!(parse_property_path("+").is_err()); + assert!(parse_property_path("ex:owner").is_err()); + } + + #[test] + fn path_expands_to_anchored_patterns() { + let steps = + parse_property_path("/^") + .unwrap(); + let patterns = compile_path_where(&steps); + assert_eq!(patterns.len(), 2); + // identity —memberOf→ ?v0 + assert_eq!(patterns[0]["@id"], "?$identity"); + assert_eq!(patterns[0]["https://example.org/memberOf"]["@id"], "?v0"); + // ?$this —team→ ?v0 (inverse step targets ?$this as subject) + assert_eq!(patterns[1]["@id"], "?$this"); + assert_eq!(patterns[1]["https://example.org/team"]["@id"], "?v0"); + } + + #[test] + fn connected_view_policy_uses_query_not_allow() { + let steps = parse_property_path("^").unwrap(); + let g = compile( + "https://example.org/Lead/access/read", + "https://example.org/Lead", + Profile::Read, + &[RDF_TYPE.to_string()], + Some(&steps), + Some("^"), + ); + let nodes = g["@graph"].as_array().unwrap(); + let view = nodes + .iter() + .find(|n| n["@id"].as_str().unwrap().ends_with("/view")) + .expect("view policy"); + assert!(view.get("https://ns.flur.ee/db#allow").is_none()); + let q = view["https://ns.flur.ee/db#query"].as_str().unwrap(); + let parsed: Value = serde_json::from_str(q).unwrap(); + assert_eq!(parsed["where"][0]["@id"], "?$this"); + // profile node records the raw path + let profile = nodes + .iter() + .find(|n| n["@id"].as_str().unwrap().ends_with("/profile")) + .unwrap(); + assert_eq!( + profile["https://ns.flur.ee/model#connected"], + "^" + ); + } +} From a222b5aa62074cccf771018a74a56e7c92698ad0 Mon Sep 17 00:00:00 2001 From: bplatz Date: Wed, 8 Jul 2026 05:28:01 -0400 Subject: [PATCH 06/12] =?UTF-8?q?feat(cli):=20model=20access=20verify/sync?= =?UTF-8?q?=20+=20model=20class=20=E2=80=94=20drift=20detection=20and=20hi?= =?UTF-8?q?erarchy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 'model access verify' recompiles every stored fm:AccessProfile from its intent node and semantically compares against the policies in the ledger (compact/full form tolerant). Hand-edited allow-lists, flipped f:allow, retargeted classes, and missing/unexpected policies report as DRIFT with a nonzero exit for CI. - 'model access sync' re-derives profiles whose property surface came from a source that can change (SHACL shape, observed data) and recompiles on drift; explicit lists are never rewritten. The compiler now records fm:derivation and fm:allowShared on the intent node so sync honors the author's original choices; nodes written before these fields (or by other front ends) read as explicit — the safe default. - 'model class define/show' — rdfs:subClassOf hierarchy as governed data, with the entailment blast-radius note; show excludes compiler-minted policy classes from the domain vocabulary. Verified live against a hosted stack (verify caught a hand-widened allow-list; enable repaired it) and locally (shape grew -> sync applied '+score' -> verify clean). --- fluree-db-cli/src/cli.rs | 79 +++ fluree-db-cli/src/commands/model/access.rs | 595 +++++++++++++++++++++ fluree-db-cli/src/commands/model/class.rs | 268 ++++++++++ fluree-db-cli/src/commands/model/entity.rs | 6 +- fluree-db-cli/src/commands/model/mod.rs | 2 + 5 files changed, 949 insertions(+), 1 deletion(-) create mode 100644 fluree-db-cli/src/commands/model/class.rs diff --git a/fluree-db-cli/src/cli.rs b/fluree-db-cli/src/cli.rs index e028719404..bc2a98bcdd 100644 --- a/fluree-db-cli/src/cli.rs +++ b/fluree-db-cli/src/cli.rs @@ -1503,6 +1503,53 @@ pub enum ModelAction { #[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, + + /// 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`. @@ -1620,6 +1667,38 @@ pub enum ModelAccessAction { #[arg(long)] remote: Option, }, + + /// Verify compiled policies still match their declared profiles + /// + /// Recompiles every stored profile from its intent node and compares + /// against the policies actually in the ledger — hand-edited or missing + /// policies are reported as drift. + Verify { + /// Target dataset (ledger alias) + dataset: String, + + /// Remote to run against + #[arg(long)] + remote: Option, + }, + + /// Re-derive profiles whose property surface came from a source that + /// can change (SHACL shape, observed data) and recompile on drift + /// + /// Explicit profiles (authored property lists) are left untouched — + /// re-run `enable` to change them. + Sync { + /// Target dataset (ledger alias) + dataset: String, + + /// Print planned changes without transacting + #[arg(long)] + dry_run: bool, + + /// Remote to run against + #[arg(long)] + remote: Option, + }, } /// Memory subcommands. diff --git a/fluree-db-cli/src/commands/model/access.rs b/fluree-db-cli/src/commands/model/access.rs index 10ff6bc510..badbb33df7 100644 --- a/fluree-db-cli/src/commands/model/access.rs +++ b/fluree-db-cli/src/commands/model/access.rs @@ -112,6 +112,14 @@ pub async fn run(action: &ModelAccessAction, dirs: &FlureeDir, direct: bool) -> ModelAccessAction::Show { dataset, remote } => { run_show(dataset, remote.as_deref(), dirs, direct).await } + ModelAccessAction::Verify { dataset, remote } => { + run_verify(dataset, remote.as_deref(), dirs, direct).await + } + ModelAccessAction::Sync { + dataset, + dry_run, + remote, + } => run_sync(dataset, *dry_run, remote.as_deref(), dirs, direct).await, } } @@ -214,6 +222,8 @@ async fn run_enable( &allowed, connected_steps.as_deref(), connected, + derivation, + allow_shared, ); // 4. Report. @@ -582,6 +592,12 @@ fn compile_path_where(steps: &[PathStep]) -> Vec { } /// Compile the profile into its JSON-LD artifacts. +/// +/// `derivation` and `allow_shared` are recorded on the intent node so +/// `sync` knows whether (and how) the property surface may be re-derived: +/// explicit lists are authored and never touched; shape/observed lists +/// re-derive with the same shared-property choice the author made. +#[allow(clippy::too_many_arguments)] fn compile( class_iri: &str, entity: &str, @@ -589,6 +605,8 @@ fn compile( allowed: &[String], connected_steps: Option<&[PathStep]>, connected_raw: Option<&str>, + derivation: &str, + allow_shared: bool, ) -> Value { let mut nodes: Vec = Vec::new(); @@ -609,6 +627,8 @@ fn compile( format!("{FM}property"): allowed.iter().map(|p| json!({"@id": p})).collect::>(), format!("{FM}policyClass"): {"@id": class_iri}, format!("{FM}compilerVersion"): COMPILER_VERSION, + format!("{FM}derivation"): derivation, + format!("{FM}allowShared"): allow_shared, }); if let Some(path) = connected_raw { profile_node[format!("{FM}connected")] = json!(path); @@ -689,6 +709,455 @@ async fn run_show( Ok(()) } +// ── verify / sync ─────────────────────────────────────────────────────── + +/// A stored `fm:AccessProfile` intent node, decoded. +struct StoredProfile { + class_iri: String, + profile: Profile, + entity: String, + /// Full allow-list as stored (rdf:type included). + allowed: Vec, + connected: Option, + /// How the property surface was derived at enable time. Absent on + /// nodes written by older compilers or other front ends — treated as + /// `explicit` (the safest reading: never rewrite an authored list). + derivation: Option, + allow_shared: bool, +} + +async fn fetch_profiles(mode: &LedgerMode) -> CliResult> { + let q = json!({ + "@context": {"fm": FM}, + "select": {"?profile": ["*"]}, + "where": [{"@id": "?profile", "@type": "fm:AccessProfile"}] + }); + let result = query(mode, &q).await?; + let rows = result.as_array().cloned().unwrap_or_default(); + let mut out = Vec::new(); + for row in &rows { + let Some(class_iri) = value_id(row.get("fm:policyClass")) else { + continue; + }; + let Some(profile_str) = value_str(row.get("fm:profile")) else { + continue; + }; + let Ok(profile) = Profile::parse(&profile_str) else { + continue; + }; + let Some(entity) = value_id(row.get("fm:onType")) else { + continue; + }; + out.push(StoredProfile { + class_iri, + profile, + entity, + allowed: value_ids(row.get("fm:property")), + connected: value_str(row.get("fm:connected")), + derivation: value_str(row.get("fm:derivation")), + allow_shared: value_bool(row.get("fm:allowShared")).unwrap_or(false), + }); + } + Ok(out) +} + +/// Read a subject's properties; `None` when the subject has no triples. +async fn fetch_node(mode: &LedgerMode, id: &str) -> CliResult> { + let q = json!({ + "@context": {"f": F}, + "select": {id: ["*"]}, + }); + let result = query(mode, &q).await?; + let node = match result { + Value::Array(mut a) => { + if a.is_empty() { + return Ok(None); + } + a.remove(0) + } + other => other, + }; + let Some(obj) = node.as_object() else { + return Ok(None); + }; + if obj.keys().all(|k| k == "@id") { + return Ok(None); + } + Ok(Some(node)) +} + +async fn run_verify( + dataset: &str, + remote: Option<&str>, + dirs: &FlureeDir, + direct: bool, +) -> CliResult<()> { + let mode = resolve_mode(dataset, remote, dirs, direct).await?; + let profiles = fetch_profiles(&mode).await?; + if profiles.is_empty() { + println!("No access profiles on '{dataset}' — nothing to verify."); + return Ok(()); + } + + let mut drifted = 0usize; + for sp in &profiles { + println!("• {} ({} {})", sp.class_iri, sp.profile.as_str(), sp.entity); + let steps = match &sp.connected { + Some(path) => Some(parse_property_path(path)?), + None => None, + }; + let expected = compile( + &sp.class_iri, + &sp.entity, + sp.profile, + &sp.allowed, + steps.as_deref(), + sp.connected.as_deref(), + sp.derivation.as_deref().unwrap_or("explicit"), + sp.allow_shared, + ); + let nodes = expected["@graph"].as_array().expect("compile emits @graph"); + + let mut profile_drifted = false; + for kind in ["view", "modify"] { + let id = format!("{}/{kind}", sp.class_iri); + let want = nodes + .iter() + .find(|n| n["@id"].as_str() == Some(id.as_str())); + let have = fetch_node(&mode, &id).await?; + match (want, have) { + (Some(w), Some(h)) => { + let drift = diff_policy(w, &h); + if drift.is_empty() { + println!(" {kind}: OK"); + } else { + profile_drifted = true; + for d in drift { + println!(" {kind}: DRIFT — {d}"); + } + } + } + (Some(_), None) => { + profile_drifted = true; + println!(" {kind}: MISSING — re-run `enable` (or `sync`) to recompile"); + } + (None, Some(_)) => { + profile_drifted = true; + println!( + " {kind}: UNEXPECTED — the {} profile compiles no {kind} policy, \ + but one exists in the ledger", + sp.profile.as_str() + ); + } + (None, None) => {} + } + } + if profile_drifted { + drifted += 1; + } + } + + println!( + "\n{} profile{} checked, {drifted} drifted", + profiles.len(), + if profiles.len() == 1 { "" } else { "s" } + ); + if drifted > 0 { + Err(CliError::Config(format!( + "{drifted} profile(s) drifted from their declared intent — \ + `fluree model access sync` recompiles derivable ones; re-run `enable` for the rest" + ))) + } else { + Ok(()) + } +} + +async fn run_sync( + dataset: &str, + dry_run: bool, + remote: Option<&str>, + dirs: &FlureeDir, + direct: bool, +) -> CliResult<()> { + let mode = resolve_mode(dataset, remote, dirs, direct).await?; + let profiles = fetch_profiles(&mode).await?; + if profiles.is_empty() { + println!("No access profiles on '{dataset}' — nothing to sync."); + return Ok(()); + } + + for sp in &profiles { + let derivation = sp.derivation.as_deref().unwrap_or("explicit"); + println!("• {} ({derivation})", sp.class_iri); + + let derived = match derivation { + "shacl-shape" => derive_from_shacl(&mode, &sp.entity).await?, + "observed-data" => derive_from_observed(&mode, &sp.entity).await?, + _ => { + println!(" explicit property list — skipped (re-run `enable` to change)"); + continue; + } + }; + if derived.is_empty() { + println!( + " derivation source vanished (no shape / no instances) — left \ + unchanged; re-run `enable` to redeclare" + ); + continue; + } + + // Same uniqueness partition as `enable`, honoring the stored choice. + let mut new_allowed: Vec = vec![RDF_TYPE.to_string()]; + for prop in &derived { + if prop == RDF_TYPE { + continue; + } + let others = classes_sharing_property(&mode, prop, &sp.entity).await?; + if others.is_empty() || sp.allow_shared { + new_allowed.push(prop.clone()); + } else { + println!( + " shared: {prop} — also used by {} — EXCLUDED", + others.join(", ") + ); + } + } + + let mut want = new_allowed.clone(); + want.sort(); + let mut have = sp.allowed.clone(); + have.sort(); + if want == have { + println!(" unchanged"); + continue; + } + let added: Vec<&String> = want.iter().filter(|p| !have.contains(p)).collect(); + let removed: Vec<&String> = have.iter().filter(|p| !want.contains(p)).collect(); + if !added.is_empty() { + println!( + " + {}", + added + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ); + } + if !removed.is_empty() { + println!( + " - {}", + removed + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + ); + } + + let steps = match &sp.connected { + Some(path) => Some(parse_property_path(path)?), + None => None, + }; + let graph = compile( + &sp.class_iri, + &sp.entity, + sp.profile, + &new_allowed, + steps.as_deref(), + sp.connected.as_deref(), + derivation, + sp.allow_shared, + ); + if dry_run { + println!(" (dry run — not transacted)"); + continue; + } + upsert(&mode, &graph).await?; + println!(" recompiled"); + } + Ok(()) +} + +/// Semantic comparison of a compiled policy node against the node actually +/// in the ledger. Tolerates compacted keys/values in query results +/// (`f:action` vs the full IRI) — drift means a REAL difference. +fn diff_policy(expected: &Value, actual: &Value) -> Vec { + let mut drift = Vec::new(); + + let want_types: Vec = value_ids(expected.get("@type")); + let have_types: Vec = value_ids(actual.get("@type")) + .iter() + .map(|t| expand(t)) + .collect(); + for t in &want_types { + if !have_types.contains(t) { + drift.push(format!("missing @type {t}")); + } + } + + for key in [format!("{F}action"), format!("{F}onClass")] { + if let Some(want) = expected.get(&key) { + let want_id = value_id(Some(want)).map(|s| expand(&s)); + let have_id = get_prop(actual, &key) + .and_then(|v| value_id(Some(v))) + .map(|s| expand(&s)); + if want_id != have_id { + drift.push(format!( + "{key}: expected {}, found {}", + want_id.as_deref().unwrap_or("(none)"), + have_id.as_deref().unwrap_or("(none)") + )); + } + } + } + + let on_property = format!("{F}onProperty"); + if let Some(want) = expected.get(&on_property) { + let mut want_props: Vec = value_ids(Some(want)).iter().map(|s| expand(s)).collect(); + let mut have_props: Vec = get_prop(actual, &on_property) + .map(|v| value_ids(Some(v))) + .unwrap_or_default() + .iter() + .map(|s| expand(s)) + .collect(); + want_props.sort(); + have_props.sort(); + if want_props != have_props { + let extra: Vec<&String> = have_props + .iter() + .filter(|p| !want_props.contains(p)) + .collect(); + let missing: Vec<&String> = want_props + .iter() + .filter(|p| !have_props.contains(p)) + .collect(); + let mut parts = Vec::new(); + if !extra.is_empty() { + parts.push(format!( + "extra in ledger: {}", + extra + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + )); + } + if !missing.is_empty() { + parts.push(format!( + "missing: {}", + missing + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", ") + )); + } + drift.push(format!("allow-list differs ({})", parts.join("; "))); + } + } + + let query_key = format!("{F}query"); + let allow_key = format!("{F}allow"); + if let Some(want_q) = expected.get(&query_key) { + let want_parsed: Option = + value_str(Some(want_q)).and_then(|s| serde_json::from_str(&s).ok()); + let have_parsed: Option = get_prop(actual, &query_key) + .and_then(|v| value_str(Some(v))) + .and_then(|s| serde_json::from_str(&s).ok()); + if want_parsed != have_parsed { + drift.push("relationship gate (f:query) differs".into()); + } + } else if expected.get(&allow_key).is_some() + && get_prop(actual, &allow_key).and_then(|v| value_bool(Some(v))) != Some(true) + { + drift.push("f:allow is not true".into()); + } + + drift +} + +// ── JSON-LD value helpers (query results come back compacted) ─────────── + +/// Expand a compacted IRI back to its full form for comparisons. +fn expand(s: &str) -> String { + if let Some(rest) = s.strip_prefix("f:") { + format!("{F}{rest}") + } else if let Some(rest) = s.strip_prefix("fm:") { + format!("{FM}{rest}") + } else { + s.to_string() + } +} + +/// Look up a property on a result node by full IRI, tolerating the +/// compacted key form the query's @context produces. +fn get_prop<'a>(node: &'a Value, full: &str) -> Option<&'a Value> { + if let Some(v) = node.get(full) { + return Some(v); + } + let local = full.rsplit(['#', '/']).next().unwrap_or(full); + for prefix in ["f:", "fm:"] { + if let Some(v) = node.get(format!("{prefix}{local}")) { + return Some(v); + } + } + None +} + +fn value_id(v: Option<&Value>) -> Option { + let ids = value_ids(v); + ids.into_iter().next() +} + +fn value_ids(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()); + } else if let Some(val) = o.get("@value").and_then(|x| x.as_str()) { + out.push(val.to_string()); + } + } + _ => {} + } + } + out +} + +fn value_str(v: Option<&Value>) -> Option { + let v = v?; + let item = match v { + Value::Array(a) => a.first()?, + other => other, + }; + match item { + Value::String(s) => Some(s.clone()), + Value::Object(o) => o.get("@value").and_then(|x| x.as_str()).map(String::from), + _ => None, + } +} + +fn value_bool(v: Option<&Value>) -> Option { + let v = v?; + let item = match v { + Value::Array(a) => a.first()?, + other => other, + }; + match item { + Value::Bool(b) => Some(*b), + Value::Object(o) => o.get("@value").and_then(serde_json::Value::as_bool), + _ => None, + } +} + fn render_value(v: &Value) -> String { match v { Value::String(s) => s.clone(), @@ -720,6 +1189,8 @@ mod tests { &allowed, None, None, + "explicit", + false, ); let nodes = graph["@graph"].as_array().unwrap(); assert_eq!(nodes.len(), 4, "class + profile + view + modify"); @@ -751,6 +1222,8 @@ mod tests { &[RDF_TYPE.to_string()], None, None, + "explicit", + false, ); let nodes = graph["@graph"].as_array().unwrap(); assert_eq!(nodes.len(), 3, "class + profile + view (no modify)"); @@ -768,6 +1241,8 @@ mod tests { &[RDF_TYPE.to_string()], None, None, + "explicit", + false, ); let nodes = graph["@graph"].as_array().unwrap(); assert_eq!(nodes.len(), 3, "class + profile + modify (no view)"); @@ -843,6 +1318,8 @@ mod connected_tests { &[RDF_TYPE.to_string()], Some(&steps), Some("^"), + "explicit", + false, ); let nodes = g["@graph"].as_array().unwrap(); let view = nodes @@ -864,3 +1341,121 @@ mod connected_tests { ); } } + +#[cfg(test)] +mod verify_tests { + use super::*; + + fn compiled_write_nodes() -> Vec { + let allowed = vec![RDF_TYPE.to_string(), "https://example.org/name".to_string()]; + let g = compile( + "https://example.org/Lead/access/write", + "https://example.org/Lead", + Profile::Write, + &allowed, + None, + None, + "shacl-shape", + false, + ); + g["@graph"].as_array().unwrap().clone() + } + + fn node(nodes: &[Value], suffix: &str) -> Value { + nodes + .iter() + .find(|n| n["@id"].as_str().unwrap().ends_with(suffix)) + .cloned() + .unwrap() + } + + #[test] + fn identical_nodes_have_no_drift() { + let nodes = compiled_write_nodes(); + let modify = node(&nodes, "/modify"); + assert!(diff_policy(&modify, &modify).is_empty()); + } + + #[test] + fn compacted_actual_matches_full_expected() { + let nodes = compiled_write_nodes(); + let view = node(&nodes, "/view"); + // The same node as a query result would render it: compact keys/values. + let actual = serde_json::json!({ + "@id": "https://example.org/Lead/access/write/view", + "@type": ["f:AccessPolicy", "https://example.org/Lead/access/write"], + "f:action": {"@id": "f:view"}, + "f:onClass": {"@id": "https://example.org/Lead"}, + "f:allow": true, + }); + assert!(diff_policy(&view, &actual).is_empty()); + } + + #[test] + fn hand_widened_allow_list_is_drift() { + let nodes = compiled_write_nodes(); + let modify = node(&nodes, "/modify"); + let mut actual = modify.clone(); + actual[format!("{F}onProperty")] = serde_json::json!([ + {"@id": RDF_TYPE}, + {"@id": "https://example.org/name"}, + {"@id": "https://example.org/salary"} + ]); + let drift = diff_policy(&modify, &actual); + assert_eq!(drift.len(), 1); + assert!(drift[0].contains("salary"), "{drift:?}"); + assert!(drift[0].contains("extra in ledger"), "{drift:?}"); + } + + #[test] + fn flipped_allow_is_drift() { + let nodes = compiled_write_nodes(); + let view = node(&nodes, "/view"); + let mut actual = view.clone(); + actual[format!("{F}allow")] = serde_json::json!(false); + let drift = diff_policy(&view, &actual); + assert!(drift.iter().any(|d| d.contains("f:allow")), "{drift:?}"); + } + + #[test] + fn retargeted_class_is_drift() { + let nodes = compiled_write_nodes(); + let view = node(&nodes, "/view"); + let mut actual = view.clone(); + actual[format!("{F}onClass")] = serde_json::json!({"@id": "https://example.org/Invoice"}); + let drift = diff_policy(&view, &actual); + assert!(drift.iter().any(|d| d.contains("onClass")), "{drift:?}"); + } + + #[test] + fn profile_node_records_derivation_and_shared_choice() { + let nodes = compiled_write_nodes(); + let profile = node(&nodes, "/profile"); + assert_eq!(profile[format!("{FM}derivation")], "shacl-shape"); + assert_eq!(profile[format!("{FM}allowShared")], false); + } + + #[test] + fn value_helpers_tolerate_all_shapes() { + assert_eq!( + value_id(Some(&serde_json::json!({"@id": "https://x/A"}))), + Some("https://x/A".to_string()) + ); + assert_eq!( + value_ids(Some( + &serde_json::json!([{"@id": "https://x/A"}, "https://x/B"]) + )), + vec!["https://x/A", "https://x/B"] + ); + assert_eq!( + value_str(Some(&serde_json::json!(["write"]))), + Some("write".to_string()) + ); + assert_eq!( + value_bool(Some(&serde_json::json!({"@value": true}))), + Some(true) + ); + assert_eq!(expand("f:allow"), format!("{F}allow")); + assert_eq!(expand("https://x/A"), "https://x/A"); + } +} diff --git a/fluree-db-cli/src/commands/model/class.rs b/fluree-db-cli/src/commands/model/class.rs new file mode 100644 index 0000000000..f5353c995a --- /dev/null +++ b/fluree-db-cli/src/commands/model/class.rs @@ -0,0 +1,268 @@ +//! `fluree model class` — the reasoning facet's vocabulary. +//! +//! Defines classes and their `rdfs:subClassOf` relations as ordinary data. +//! 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` — which is why +//! the hierarchy lives under governance tooling and not ad-hoc transacts. + +use serde_json::{json, Value}; + +use super::{query, resolve_mode, upsert}; +use crate::cli::ModelClassAction; +use crate::error::{CliError, CliResult}; +use fluree_db_api::server_defaults::FlureeDir; + +const FM: &str = "https://ns.flur.ee/model#"; +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"; +const RDFS_SUBCLASS_OF: &str = "http://www.w3.org/2000/01/rdf-schema#subClassOf"; + +pub async fn run(action: &ModelClassAction, dirs: &FlureeDir, direct: bool) -> CliResult<()> { + match action { + ModelClassAction::Define { + dataset, + class, + subclass_of, + label, + dry_run, + remote, + } => { + run_define( + dataset, + class, + 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], + 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 !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 --"); + println!("{}", serde_json::to_string_pretty(&node)?); + return Ok(()); + } + + let mode = resolve_mode(dataset, remote, dirs, direct).await?; + upsert(&mode, &node).await?; + println!("Defined on '{dataset}'. Re-run with more --subclass-of to extend."); + Ok(()) +} + +/// 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. + let policy_classes: Vec = { + let q = json!({ + "@context": {"fm": FM}, + "select": ["?class"], + "where": [ + {"@id": "?p", "@type": "fm:AccessProfile"}, + {"@id": "?p", "fm:policyClass": {"@id": "?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: Vec<&Value> = rows + .iter() + .filter(|row| { + row["@id"] + .as_str() + .is_some_and(|id| !policy_classes.iter().any(|pc| pc == id)) + }) + .collect(); + + 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(()) +} + +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" + ))) + } +} + +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 +} + +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::*; + + #[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 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 index 574d7b706b..e4d9419413 100644 --- a/fluree-db-cli/src/commands/model/entity.rs +++ b/fluree-db-cli/src/commands/model/entity.rs @@ -290,7 +290,11 @@ async fn run_show( 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 { + if p.get("sh:minCount") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0) + >= 1 + { notes.push("required".into()); } println!( diff --git a/fluree-db-cli/src/commands/model/mod.rs b/fluree-db-cli/src/commands/model/mod.rs index 477b80cba8..ba3284c217 100644 --- a/fluree-db-cli/src/commands/model/mod.rs +++ b/fluree-db-cli/src/commands/model/mod.rs @@ -12,6 +12,7 @@ //! stacks. pub mod access; +pub mod class; pub mod entity; use crate::cli::ModelAction; @@ -24,6 +25,7 @@ pub async fn run(action: &ModelAction, dirs: &FlureeDir, direct: bool) -> CliRes 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, } } From b898d2e01489b3ae9850d97923110623e0211668 Mon Sep 17 00:00:00 2001 From: bplatz Date: Wed, 8 Jul 2026 06:57:36 -0400 Subject: [PATCH 07/12] test(policy): @path context term enforces inside f:query Proves the storage form governance tooling uses for relationship gates: a stored, classed view policy whose f:query is one triple over an @path term (the author's path expression survives verbatim in the artifact), selected via policy-class with a bind-only identity. Two identities, one gate, each sees only entities connected via ex:memberOf/^ex:team. --- fluree-db-api/tests/it_policy_fquery.rs | 101 ++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/fluree-db-api/tests/it_policy_fquery.rs b/fluree-db-api/tests/it_policy_fquery.rs index 0e1699eca2..b896eea3b7 100644 --- a/fluree-db-api/tests/it_policy_fquery.rs +++ b/fluree-db-api/tests/it_policy_fquery.rs @@ -554,3 +554,104 @@ 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"; + + let gate = serde_json::to_string(&json!({ + "@context": { + "ex": "http://example.org/ns/", + "connected": { "@path": "ex:memberOf/^ex: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" + ); +} From c403ee6a2e0bab1df084471944d440cdb38b5ccf Mon Sep 17 00:00:00 2001 From: bplatz Date: Wed, 8 Jul 2026 11:20:47 -0400 Subject: [PATCH 08/12] =?UTF-8?q?feat(cli):=20rework=20model=20access=20on?= =?UTF-8?q?to=20write-verb=20policies=20=E2=80=94=20stateless=20scaffolder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine's f:create/f:update/f:delete verbs (with pre∪post class targeting and class-scoped rdf:type matching) make thin class policies exact, so the entire workaround layer is deleted: no property allow-list derivation, no uniqueness partition / --allow-shared, no fm: intent vocabulary, no sync, no drift-verify (1,461 → ~700 lines). Profiles map directly: read → view; write → view + create/update/delete; intake → create-only. --property remains as optional column narrowing (f:onProperty conjunction). --connected now stores the SPARQL path VERBATIM via the engine's @path context term (readable + reversible in the artifact; show extracts it back). model entity define gains --closed (sh:closed + rdf:type carve-out): validation owns the property surface, policy owns state transitions. Contract proven by integration test scaffolder_write_profile_grants_class_ownership_only: the scaffolder's byte-exact output, stored + selected via policy-class with bind-only identity, allows create/update of the class and denies minting or editing other classes. @path gate test switched to the CLI's angle-bracket absolute-IRI storage form. --- fluree-db-api/tests/it_policy_fquery.rs | 7 +- fluree-db-api/tests/it_policy_verbs.rs | 116 ++ fluree-db-cli/src/cli.rs | 73 +- fluree-db-cli/src/commands/model/access.rs | 1506 +++++--------------- fluree-db-cli/src/commands/model/entity.rs | 49 +- 5 files changed, 555 insertions(+), 1196 deletions(-) diff --git a/fluree-db-api/tests/it_policy_fquery.rs b/fluree-db-api/tests/it_policy_fquery.rs index b896eea3b7..8beecbcae9 100644 --- a/fluree-db-api/tests/it_policy_fquery.rs +++ b/fluree-db-api/tests/it_policy_fquery.rs @@ -578,10 +578,13 @@ async fn policy_fquery_with_path_context_term() { 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": { - "ex": "http://example.org/ns/", - "connected": { "@path": "ex:memberOf/^ex:team" } + "connected": { + "@path": "/^" + } }, "where": [ { "@id": "?$identity", "connected": { "@id": "?$this" } } diff --git a/fluree-db-api/tests/it_policy_verbs.rs b/fluree-db-api/tests/it_policy_verbs.rs index 14ae3edb51..5daf001543 100644 --- a/fluree-db-api/tests/it_policy_verbs.rs +++ b/fluree-db-api/tests/it_policy_verbs.rs @@ -729,3 +729,119 @@ 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. + 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 bc2a98bcdd..769c506022 100644 --- a/fluree-db-cli/src/cli.rs +++ b/fluree-db-cli/src/cli.rs @@ -1577,6 +1577,13 @@ pub enum ModelEntityAction { #[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, @@ -1600,13 +1607,17 @@ pub enum ModelEntityAction { /// Access-facet subcommands of `fluree model`. #[derive(Subcommand)] pub enum ModelAccessAction { - /// Enable an access profile on a dataset (compiles to policies) + /// Enable an access profile on a dataset (emits thin verb policies) /// - /// Declares WHAT apps and other policy-classed principals may do with - /// entities of a class, and compiles that intent into stored policies: - /// a policy class, a view policy, and a property-whitelist modify - /// policy. The intent is stored as a declarative profile node so - /// re-running is idempotent and `sync`/`verify` can re-derive later. + /// 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, @@ -1619,18 +1630,12 @@ pub enum ModelAccessAction { #[arg(long)] entity: String, - /// Property IRIs to permit (absolute). If omitted, derived from the - /// entity's SHACL shape, else from observed data (fail-closed if - /// neither exists). + /// 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, - /// Include properties that other classes also use (the compiler - /// discloses the blast radius; without this flag shared properties - /// are excluded — fail closed on precision) - #[arg(long)] - allow_shared: bool, - /// Policy class IRI override (default: {entity}/access/{profile}) #[arg(long)] class_iri: Option, @@ -1644,8 +1649,8 @@ pub enum ModelAccessAction { /// from the requesting identity to the entity, with angle-bracketed /// IRIs. e.g. "^" (I see what I own) or /// "/^" - /// (I see entities whose team I'm a member of). Sequence (/) and - /// inverse (^) steps only. + /// (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, @@ -1658,7 +1663,7 @@ pub enum ModelAccessAction { remote: Option, }, - /// Show access profiles and their compiled policies on a dataset + /// Show compiled access policies on a dataset (grouped by policy class) Show { /// Target dataset (ledger alias) dataset: String, @@ -1667,38 +1672,6 @@ pub enum ModelAccessAction { #[arg(long)] remote: Option, }, - - /// Verify compiled policies still match their declared profiles - /// - /// Recompiles every stored profile from its intent node and compares - /// against the policies actually in the ledger — hand-edited or missing - /// policies are reported as drift. - Verify { - /// Target dataset (ledger alias) - dataset: String, - - /// Remote to run against - #[arg(long)] - remote: Option, - }, - - /// Re-derive profiles whose property surface came from a source that - /// can change (SHACL shape, observed data) and recompile on drift - /// - /// Explicit profiles (authored property lists) are left untouched — - /// re-run `enable` to change them. - Sync { - /// Target dataset (ledger alias) - dataset: String, - - /// Print planned changes without transacting - #[arg(long)] - dry_run: bool, - - /// Remote to run against - #[arg(long)] - remote: Option, - }, } /// Memory subcommands. diff --git a/fluree-db-cli/src/commands/model/access.rs b/fluree-db-cli/src/commands/model/access.rs index badbb33df7..f5b2580a9f 100644 --- a/fluree-db-cli/src/commands/model/access.rs +++ b/fluree-db-cli/src/commands/model/access.rs @@ -1,52 +1,51 @@ -//! `fluree model access` — the access-profile policy compiler. +//! `fluree model access` — a stateless policy scaffolder. //! -//! Users declare intent ("apps may write Leads"); this module compiles it to -//! the cheapest enforcement shape that expresses it and transacts the result -//! as ordinary data: +//! 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: //! -//! * a **policy class** (`rdfs:Class`) — the assignment unit grants and -//! tokens carry; -//! * a **view policy** (`f:onClass` — exact for reads); -//! * a **modify policy** as a **property allow-list** (`f:onProperty` + -//! `f:allow`) — class-targeted modify cannot cover new-subject inserts and -//! `f:query` evaluates against pre-state, so the allow-list is the correct -//! (and cheapest) write shape today; -//! * a declarative **profile node** recording the intent (type, properties, -//! compiler version) so `enable` is idempotent and future `sync`/`verify` -//! re-derive instead of reverse-engineering. +//! * **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). //! -//! Exactness honesty: a property allow-list is only class-exact when the -//! properties are unique to the class. The compiler partitions derived -//! properties by observed usage, discloses shared-property blast radius, and -//! requires `--allow-shared` to include them. `rdf:type` is always included -//! (required for creation) and always flagged: until the engine supports -//! object-value constraints on type flakes, a allow_list holder can assert -//! other types using only allowed properties. +//! `--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, idempotence comes from deterministic node ids +//! (`{class}/view`, `{class}/write`), the `--connected` path survives +//! verbatim inside the stored `f:query`'s `@path` context term, and +//! hand-editing a policy is legitimate authorship — not drift. use serde_json::{json, Value}; use super::{query, resolve_mode, upsert}; use crate::cli::ModelAccessAction; -use crate::context::LedgerMode; use crate::error::{CliError, CliResult}; use fluree_db_api::server_defaults::FlureeDir; const F: &str = "https://ns.flur.ee/db#"; -const FM: &str = "https://ns.flur.ee/model#"; -const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; 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"; -const SH: &str = "http://www.w3.org/ns/shacl#"; -const COMPILER_VERSION: &str = "1"; /// Access profiles: what the compiled policy set permits. #[derive(Clone, Copy, PartialEq)] enum Profile { - /// View entities of the class (class-exact today). + /// View instances of the class. Read, - /// Create + edit entities of the class (property-compiled). + /// Full instance ownership: view + create/update/delete. Write, - /// Submit without reading back (write-only; the anonymous-form shape). + /// Submit without reading back (create-only; the anonymous-form shape). Intake, } @@ -74,8 +73,13 @@ impl Profile { matches!(self, Self::Read | Self::Write) } - fn wants_modify(&self) -> bool { - matches!(self, Self::Write | Self::Intake) + /// 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"], + } } } @@ -86,7 +90,6 @@ pub async fn run(action: &ModelAccessAction, dirs: &FlureeDir, direct: bool) -> profile, entity, properties, - allow_shared, class_iri, space, connected, @@ -98,7 +101,6 @@ pub async fn run(action: &ModelAccessAction, dirs: &FlureeDir, direct: bool) -> profile, entity, properties, - *allow_shared, class_iri.as_deref(), space.as_deref(), connected.as_deref(), @@ -112,14 +114,6 @@ pub async fn run(action: &ModelAccessAction, dirs: &FlureeDir, direct: bool) -> ModelAccessAction::Show { dataset, remote } => { run_show(dataset, remote.as_deref(), dirs, direct).await } - ModelAccessAction::Verify { dataset, remote } => { - run_verify(dataset, remote.as_deref(), dirs, direct).await - } - ModelAccessAction::Sync { - dataset, - dry_run, - remote, - } => run_sync(dataset, *dry_run, remote.as_deref(), dirs, direct).await, } } @@ -130,8 +124,7 @@ async fn run_enable( dataset: &str, profile_str: &str, entity: &str, - explicit_properties: &[String], - allow_shared: bool, + properties: &[String], class_iri_override: Option<&str>, space: Option<&str>, connected: Option<&str>, @@ -142,7 +135,7 @@ async fn run_enable( ) -> CliResult<()> { let profile = Profile::parse(profile_str)?; require_absolute_iri("--entity", entity)?; - for p in explicit_properties { + for p in properties { require_absolute_iri("--property", p)?; } if space.is_some() && remote.is_none() { @@ -150,64 +143,27 @@ async fn run_enable( "--space attaches the policy class to a hosted stack's grant; pass --remote ".into(), )); } - let connected_steps = match connected { - Some(path) => { - if profile != Profile::Read { - return Err(CliError::Usage( - "--connected is supported with --profile read only: relationship gates \ - evaluate against pre-transaction state, so gating writes would deny \ - every create (the connection triples are not visible yet). Engine \ - support for staged-state gates lifts this later." - .into(), - )); - } - Some(parse_property_path(path)?) - } - None => None, - }; - - let mode = resolve_mode(dataset, remote, dirs, direct).await?; - - // 1. Derive the property surface: explicit → SHACL → observed → fail. - let (properties, derivation) = if !explicit_properties.is_empty() { - (explicit_properties.to_vec(), "explicit") - } else { - let from_shape = derive_from_shacl(&mode, entity).await?; - if !from_shape.is_empty() { - (from_shape, "shacl-shape") - } else { - let observed = derive_from_observed(&mode, entity).await?; - if observed.is_empty() { - return Err(CliError::Usage(format!( - "cannot derive properties for {entity}: no SHACL shape targets it and \ - no instances exist. Pass --property explicitly (fail-closed)." - ))); - } - (observed, "observed-data") - } - }; - - // 2. Uniqueness partition: which of these properties do OTHER classes use? - let mut included: Vec = Vec::new(); - let mut shared: Vec<(String, Vec)> = Vec::new(); - for prop in &properties { - if prop == RDF_TYPE { - continue; // handled below, always included + flagged - } - let others = classes_sharing_property(&mode, prop, entity).await?; - if others.is_empty() { - included.push(prop.clone()); - } else { - shared.push((prop.clone(), others)); + 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)?; } - let mut allowed: Vec = vec![RDF_TYPE.to_string()]; - allowed.extend(included.iter().cloned()); - if allow_shared { - allowed.extend(shared.iter().map(|(p, _)| p.clone())); + 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(), + )); } - // 3. Compile. let class_iri = class_iri_override.map(String::from).unwrap_or_else(|| { format!( "{}/access/{}", @@ -215,49 +171,30 @@ async fn run_enable( profile.as_str() ) }); - let graph = compile( - &class_iri, - entity, - profile, - &allowed, - connected_steps.as_deref(), - connected, - derivation, - allow_shared, - ); + let graph = compile(&class_iri, entity, profile, properties, connected); - // 4. Report. - let exactness = if shared.is_empty() || !allow_shared { - "class-exact (all allowed properties are unique to this class)" - } else { - "property-approximate (shared properties included — see below)" - }; println!("Profile: {} {}", profile.as_str(), entity); println!("Class: {class_iri}"); - println!("Derivation: {derivation}"); - println!("Exactness: {exactness}"); - if let Some(path) = connected { - println!("Connected: {path} (view gated by relationship to the requesting identity)"); + 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 profile.wants_modify() { - println!("Allowed: {} properties (+ rdf:type)", allowed.len() - 1); + if !properties.is_empty() { println!( - " note: rdf:type is required for creation; until the engine constrains type\n\ - \x20 object values, allow-list holders can assert other types using only\n\ - \x20 allowed properties." + "Columns: write narrowed to {} propert{} (f:onProperty)", + properties.len(), + if properties.len() == 1 { "y" } else { "ies" } ); } - for (prop, others) in &shared { - let status = if allow_shared { - "INCLUDED (--allow-shared)" - } else { - "EXCLUDED (pass --allow-shared to include)" - }; - println!( - " shared: {prop} — also used by {} — {status}", - others.join(", ") - ); + if let Some(path) = connected { + println!("Connected: {path} (view gated by relationship to the requesting identity)"); } + println!( + "note: the property SURFACE of {entity} 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 --"); @@ -265,14 +202,13 @@ async fn run_enable( return Ok(()); } - // 5. 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. + // 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. + let mode = resolve_mode(dataset, remote, dirs, direct).await?; upsert(&mode, &graph).await?; - println!("\nEnabled. Policies written to '{dataset}'."); + println!("\nPolicies transacted to '{dataset}'."); - // 6. Grant attachment (hosted stacks): merge the class into the space's - // grant so minted tokens carry it. if let (Some(space_id), Some(remote_name)) = (space, remote) { attach_grant( dirs, @@ -280,21 +216,228 @@ async fn run_enable( dataset, space_id, &class_iri, - profile.wants_modify(), + !profile.write_verbs().is_empty(), ) .await?; } else { println!( - "Attach to a grant so tokens carry it (or re-run with --space ):\n\ - \x20 POST /v1/datasets/{dataset}/grants\n\ - \x20 {{\"scopeType\": \"space\", \"scopeRef\": \"\", \"access\": \"{}\", \"policyClasses\": [\"{class_iri}\"]}}", - if profile.wants_modify() { "write" } else { "read" }, + "Attach to a space grant so app tokens carry it:\n \ + fluree model access enable {dataset} --profile {} --entity {entity} \ + --space --remote ", + profile.as_str() ); } Ok(()) } -// ── grant attachment (hosted stacks) ──────────────────────────────────── +/// 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. +/// Deterministic ids ({class}/view, {class}/write) make re-running +/// idempotent — `upsert` replaces listed properties in place. +fn compile( + class_iri: &str, + entity: &str, + profile: Profile, + properties: &[String], + connected: Option<&str>, +) -> Value { + let mut nodes: Vec = Vec::new(); + + nodes.push(json!({ + "@id": class_iri, + "@type": RDFS_CLASS, + RDFS_LABEL: format!("{} access: {}", profile.as_str(), entity), + })); + + if profile.wants_view() { + let mut view = json!({ + "@id": format!("{class_iri}/view"), + "@type": [format!("{F}AccessPolicy"), class_iri], + format!("{F}action"): {"@id": format!("{F}view")}, + format!("{F}onClass"): {"@id": entity}, + }); + 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!("{class_iri}/write"), + "@type": [format!("{F}AccessPolicy"), class_iri], + format!("{F}action"): action, + format!("{F}onClass"): {"@id": entity}, + 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 --entity " + ); + 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 +} + +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" + ))) + } +} /// Merge `class_iri` into the space's grant on the dataset via the stack's /// grants API. System-plane state (grants) is the one place the compiler @@ -422,1040 +565,135 @@ async fn attach_grant( ); Ok(()) } +#[cfg(test)] +mod tests { + use super::*; -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" - ))) - } -} - -/// Properties declared by a SHACL shape targeting the entity class. -async fn derive_from_shacl(mode: &LedgerMode, entity: &str) -> CliResult> { - let q = json!({ - "@context": {"sh": SH}, - "select": ["?path"], - "where": [ - {"@id": "?shape", "sh:targetClass": {"@id": entity}}, - {"@id": "?shape", "sh:property": {"@id": "?p"}}, - {"@id": "?p", "sh:path": {"@id": "?path"}} - ] - }); - Ok(iri_rows(&query(mode, &q).await?)) -} - -/// Distinct predicates observed on instances of the entity class. -async fn derive_from_observed(mode: &LedgerMode, entity: &str) -> CliResult> { - let q = json!({ - "select": ["?p"], - "where": [ - {"@id": "?s", "@type": entity}, - {"@id": "?s", "?p": "?o"} - ] - }); - let mut props = iri_rows(&query(mode, &q).await?); - props.retain(|p| p != RDF_TYPE); - props.sort(); - props.dedup(); - Ok(props) -} - -/// Other classes whose instances also use this property. -async fn classes_sharing_property( - mode: &LedgerMode, - prop: &str, - entity: &str, -) -> CliResult> { - let q = json!({ - "select": ["?c"], - "where": [ - {"@id": "?s", prop: "?o"}, - {"@id": "?s", "@type": "?c"} - ] - }); - let mut classes = iri_rows(&query(mode, &q).await?); - classes.sort(); - classes.dedup(); - classes.retain(|c| c != entity && !c.starts_with(F)); - Ok(classes) -} - -/// Flatten a select result of single-binding rows into IRI strings. -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() -} - -/// One step of a `--connected` property path. -#[derive(Debug, PartialEq)] -struct PathStep { - iri: String, - inverse: bool, -} - -/// Parse a SPARQL property-path subset: sequence (`/`) of optionally -/// inverse (`^`) angle-bracketed absolute IRIs. -/// -/// `"/^"` → -/// identity —memberOf→ ?v0 ←team— subject. -fn parse_property_path(path: &str) -> CliResult> { - // Split on '/' outside angle brackets (IRIs contain slashes). - let mut raw_steps: Vec = vec![String::new()]; - let mut in_iri = false; - for c in path.chars() { - match c { - '<' => { - in_iri = true; - raw_steps.last_mut().unwrap().push(c); - } - '>' => { - in_iri = false; - raw_steps.last_mut().unwrap().push(c); - } - '/' if !in_iri => raw_steps.push(String::new()), - _ => raw_steps.last_mut().unwrap().push(c), - } - } - - raw_steps - .iter() - .map(|raw| { - let raw = raw.trim(); - let (inverse, rest) = match raw.strip_prefix('^') { - Some(r) => (true, r.trim()), - None => (false, raw), - }; - let iri = rest - .strip_prefix('<') - .and_then(|r| r.strip_suffix('>')) - .ok_or_else(|| { - CliError::Usage(format!( - "--connected step '{raw}' must be an angle-bracketed IRI, optionally \ - inverse: ^. Supported subset: sequence (/) and inverse (^) — \ - alternatives (|) and transitive (+/*) are not supported yet." - )) - })?; - if !(iri.starts_with("http://") - || iri.starts_with("https://") - || iri.starts_with("urn:")) - { - return Err(CliError::Usage(format!( - "--connected step IRI must be absolute (got '{iri}')" - ))); - } - Ok(PathStep { - iri: iri.to_string(), - inverse, - }) - }) - .collect() -} - -/// Expand path steps into `f:query` where-patterns anchored -/// `?$identity` —path→ `?$this`. -fn compile_path_where(steps: &[PathStep]) -> Vec { - let n = steps.len(); - let node = |i: usize| -> String { - if i == 0 { - "?$identity".to_string() - } else if i == n { - "?$this".to_string() - } else { - format!("?v{}", i - 1) - } - }; - steps - .iter() - .enumerate() - .map(|(i, s)| { - let (from, to) = (node(i), node(i + 1)); - if s.inverse { - json!({"@id": to, s.iri.clone(): {"@id": from}}) - } else { - json!({"@id": from, s.iri.clone(): {"@id": to}}) - } - }) - .collect() -} - -/// Compile the profile into its JSON-LD artifacts. -/// -/// `derivation` and `allow_shared` are recorded on the intent node so -/// `sync` knows whether (and how) the property surface may be re-derived: -/// explicit lists are authored and never touched; shape/observed lists -/// re-derive with the same shared-property choice the author made. -#[allow(clippy::too_many_arguments)] -fn compile( - class_iri: &str, - entity: &str, - profile: Profile, - allowed: &[String], - connected_steps: Option<&[PathStep]>, - connected_raw: Option<&str>, - derivation: &str, - allow_shared: bool, -) -> Value { - let mut nodes: Vec = Vec::new(); - - // The policy class — the assignment unit grants and tokens carry. - nodes.push(json!({ - "@id": class_iri, - "@type": RDFS_CLASS, - RDFS_LABEL: format!("{} access: {}", profile.as_str(), entity), - })); - - // The declarative profile node — intent, not artifact. `sync`/`verify` - // re-derive from this; the compiler version makes upgrades a recompile. - let mut profile_node = json!({ - "@id": format!("{class_iri}/profile"), - "@type": format!("{FM}AccessProfile"), - format!("{FM}profile"): profile.as_str(), - format!("{FM}onType"): {"@id": entity}, - format!("{FM}property"): allowed.iter().map(|p| json!({"@id": p})).collect::>(), - format!("{FM}policyClass"): {"@id": class_iri}, - format!("{FM}compilerVersion"): COMPILER_VERSION, - format!("{FM}derivation"): derivation, - format!("{FM}allowShared"): allow_shared, - }); - if let Some(path) = connected_raw { - profile_node[format!("{FM}connected")] = json!(path); - } - nodes.push(profile_node); - - if profile.wants_view() { - let mut view = json!({ - "@id": format!("{class_iri}/view"), - "@type": [format!("{F}AccessPolicy"), class_iri], - format!("{F}action"): {"@id": format!("{F}view")}, - format!("{F}onClass"): {"@id": entity}, - }); - match connected_steps { - Some(steps) => { - // Relationship gate: the flake is visible when the query - // returns rows — `f:query` REPLACES `f:allow` (they are - // alternative decision modes in the policy model). - let where_patterns = compile_path_where(steps); - view[format!("{F}query")] = - json!(serde_json::to_string(&json!({"where": where_patterns})) - .expect("static JSON serializes")); - } - None => { - view[format!("{F}allow")] = json!(true); - } - } - nodes.push(view); - } - if profile.wants_modify() { - nodes.push(json!({ - "@id": format!("{class_iri}/modify"), - "@type": [format!("{F}AccessPolicy"), class_iri], - format!("{F}action"): {"@id": format!("{F}modify")}, - format!("{F}allow"): true, - format!("{F}onProperty"): allowed.iter().map(|p| json!({"@id": p})).collect::>(), - })); - } - - json!({"@graph": nodes}) -} - -// ── show ──────────────────────────────────────────────────────────────── - -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": {"fm": FM}, - "select": {"?profile": ["*"]}, - "where": [{"@id": "?profile", "@type": "fm:AccessProfile"}] - }); - let result = query(&mode, &q).await?; - let rows = result.as_array().cloned().unwrap_or_default(); - if rows.is_empty() { - println!("No access profiles on '{dataset}'."); - println!("Enable one: fluree model access enable {dataset} --profile write --entity "); - return Ok(()); - } - println!("Access profiles on '{dataset}':\n"); - for row in &rows { - let get = |k: &str| -> String { - row.get(k) - .map(render_value) - .unwrap_or_else(|| "-".to_string()) - }; - println!("• {}", get("@id")); - println!(" profile: {}", get("fm:profile")); - println!(" type: {}", get("fm:onType")); - println!(" class: {}", get("fm:policyClass")); - println!(" props: {}", get("fm:property")); - println!(" compiler: v{}", get("fm:compilerVersion")); - } - Ok(()) -} - -// ── verify / sync ─────────────────────────────────────────────────────── - -/// A stored `fm:AccessProfile` intent node, decoded. -struct StoredProfile { - class_iri: String, - profile: Profile, - entity: String, - /// Full allow-list as stored (rdf:type included). - allowed: Vec, - connected: Option, - /// How the property surface was derived at enable time. Absent on - /// nodes written by older compilers or other front ends — treated as - /// `explicit` (the safest reading: never rewrite an authored list). - derivation: Option, - allow_shared: bool, -} - -async fn fetch_profiles(mode: &LedgerMode) -> CliResult> { - let q = json!({ - "@context": {"fm": FM}, - "select": {"?profile": ["*"]}, - "where": [{"@id": "?profile", "@type": "fm:AccessProfile"}] - }); - let result = query(mode, &q).await?; - let rows = result.as_array().cloned().unwrap_or_default(); - let mut out = Vec::new(); - for row in &rows { - let Some(class_iri) = value_id(row.get("fm:policyClass")) else { - continue; - }; - let Some(profile_str) = value_str(row.get("fm:profile")) else { - continue; - }; - let Ok(profile) = Profile::parse(&profile_str) else { - continue; - }; - let Some(entity) = value_id(row.get("fm:onType")) else { - continue; - }; - out.push(StoredProfile { - class_iri, - profile, - entity, - allowed: value_ids(row.get("fm:property")), - connected: value_str(row.get("fm:connected")), - derivation: value_str(row.get("fm:derivation")), - allow_shared: value_bool(row.get("fm:allowShared")).unwrap_or(false), - }); - } - Ok(out) -} - -/// Read a subject's properties; `None` when the subject has no triples. -async fn fetch_node(mode: &LedgerMode, id: &str) -> CliResult> { - let q = json!({ - "@context": {"f": F}, - "select": {id: ["*"]}, - }); - let result = query(mode, &q).await?; - let node = match result { - Value::Array(mut a) => { - if a.is_empty() { - return Ok(None); - } - a.remove(0) - } - other => other, - }; - let Some(obj) = node.as_object() else { - return Ok(None); - }; - if obj.keys().all(|k| k == "@id") { - return Ok(None); - } - Ok(Some(node)) -} - -async fn run_verify( - dataset: &str, - remote: Option<&str>, - dirs: &FlureeDir, - direct: bool, -) -> CliResult<()> { - let mode = resolve_mode(dataset, remote, dirs, direct).await?; - let profiles = fetch_profiles(&mode).await?; - if profiles.is_empty() { - println!("No access profiles on '{dataset}' — nothing to verify."); - return Ok(()); - } - - let mut drifted = 0usize; - for sp in &profiles { - println!("• {} ({} {})", sp.class_iri, sp.profile.as_str(), sp.entity); - let steps = match &sp.connected { - Some(path) => Some(parse_property_path(path)?), - None => None, - }; - let expected = compile( - &sp.class_iri, - &sp.entity, - sp.profile, - &sp.allowed, - steps.as_deref(), - sp.connected.as_deref(), - sp.derivation.as_deref().unwrap_or("explicit"), - sp.allow_shared, - ); - let nodes = expected["@graph"].as_array().expect("compile emits @graph"); - - let mut profile_drifted = false; - for kind in ["view", "modify"] { - let id = format!("{}/{kind}", sp.class_iri); - let want = nodes - .iter() - .find(|n| n["@id"].as_str() == Some(id.as_str())); - let have = fetch_node(&mode, &id).await?; - match (want, have) { - (Some(w), Some(h)) => { - let drift = diff_policy(w, &h); - if drift.is_empty() { - println!(" {kind}: OK"); - } else { - profile_drifted = true; - for d in drift { - println!(" {kind}: DRIFT — {d}"); - } - } - } - (Some(_), None) => { - profile_drifted = true; - println!(" {kind}: MISSING — re-run `enable` (or `sync`) to recompile"); - } - (None, Some(_)) => { - profile_drifted = true; - println!( - " {kind}: UNEXPECTED — the {} profile compiles no {kind} policy, \ - but one exists in the ledger", - sp.profile.as_str() - ); - } - (None, None) => {} - } - } - if profile_drifted { - drifted += 1; - } - } - - println!( - "\n{} profile{} checked, {drifted} drifted", - profiles.len(), - if profiles.len() == 1 { "" } else { "s" } - ); - if drifted > 0 { - Err(CliError::Config(format!( - "{drifted} profile(s) drifted from their declared intent — \ - `fluree model access sync` recompiles derivable ones; re-run `enable` for the rest" - ))) - } else { - Ok(()) - } -} - -async fn run_sync( - dataset: &str, - dry_run: bool, - remote: Option<&str>, - dirs: &FlureeDir, - direct: bool, -) -> CliResult<()> { - let mode = resolve_mode(dataset, remote, dirs, direct).await?; - let profiles = fetch_profiles(&mode).await?; - if profiles.is_empty() { - println!("No access profiles on '{dataset}' — nothing to sync."); - return Ok(()); - } - - for sp in &profiles { - let derivation = sp.derivation.as_deref().unwrap_or("explicit"); - println!("• {} ({derivation})", sp.class_iri); - - let derived = match derivation { - "shacl-shape" => derive_from_shacl(&mode, &sp.entity).await?, - "observed-data" => derive_from_observed(&mode, &sp.entity).await?, - _ => { - println!(" explicit property list — skipped (re-run `enable` to change)"); - continue; - } - }; - if derived.is_empty() { - println!( - " derivation source vanished (no shape / no instances) — left \ - unchanged; re-run `enable` to redeclare" - ); - continue; - } - - // Same uniqueness partition as `enable`, honoring the stored choice. - let mut new_allowed: Vec = vec![RDF_TYPE.to_string()]; - for prop in &derived { - if prop == RDF_TYPE { - continue; - } - let others = classes_sharing_property(&mode, prop, &sp.entity).await?; - if others.is_empty() || sp.allow_shared { - new_allowed.push(prop.clone()); - } else { - println!( - " shared: {prop} — also used by {} — EXCLUDED", - others.join(", ") - ); - } - } - - let mut want = new_allowed.clone(); - want.sort(); - let mut have = sp.allowed.clone(); - have.sort(); - if want == have { - println!(" unchanged"); - continue; - } - let added: Vec<&String> = want.iter().filter(|p| !have.contains(p)).collect(); - let removed: Vec<&String> = have.iter().filter(|p| !want.contains(p)).collect(); - if !added.is_empty() { - println!( - " + {}", - added - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") - ); - } - if !removed.is_empty() { - println!( - " - {}", - removed - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") - ); - } - - let steps = match &sp.connected { - Some(path) => Some(parse_property_path(path)?), - None => None, - }; - let graph = compile( - &sp.class_iri, - &sp.entity, - sp.profile, - &new_allowed, - steps.as_deref(), - sp.connected.as_deref(), - derivation, - sp.allow_shared, - ); - if dry_run { - println!(" (dry run — not transacted)"); - continue; - } - upsert(&mode, &graph).await?; - println!(" recompiled"); - } - Ok(()) -} - -/// Semantic comparison of a compiled policy node against the node actually -/// in the ledger. Tolerates compacted keys/values in query results -/// (`f:action` vs the full IRI) — drift means a REAL difference. -fn diff_policy(expected: &Value, actual: &Value) -> Vec { - let mut drift = Vec::new(); - - let want_types: Vec = value_ids(expected.get("@type")); - let have_types: Vec = value_ids(actual.get("@type")) - .iter() - .map(|t| expand(t)) - .collect(); - for t in &want_types { - if !have_types.contains(t) { - drift.push(format!("missing @type {t}")); - } - } - - for key in [format!("{F}action"), format!("{F}onClass")] { - if let Some(want) = expected.get(&key) { - let want_id = value_id(Some(want)).map(|s| expand(&s)); - let have_id = get_prop(actual, &key) - .and_then(|v| value_id(Some(v))) - .map(|s| expand(&s)); - if want_id != have_id { - drift.push(format!( - "{key}: expected {}, found {}", - want_id.as_deref().unwrap_or("(none)"), - have_id.as_deref().unwrap_or("(none)") - )); - } - } - } - - let on_property = format!("{F}onProperty"); - if let Some(want) = expected.get(&on_property) { - let mut want_props: Vec = value_ids(Some(want)).iter().map(|s| expand(s)).collect(); - let mut have_props: Vec = get_prop(actual, &on_property) - .map(|v| value_ids(Some(v))) - .unwrap_or_default() - .iter() - .map(|s| expand(s)) - .collect(); - want_props.sort(); - have_props.sort(); - if want_props != have_props { - let extra: Vec<&String> = have_props - .iter() - .filter(|p| !want_props.contains(p)) - .collect(); - let missing: Vec<&String> = want_props - .iter() - .filter(|p| !have_props.contains(p)) - .collect(); - let mut parts = Vec::new(); - if !extra.is_empty() { - parts.push(format!( - "extra in ledger: {}", - extra - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") - )); - } - if !missing.is_empty() { - parts.push(format!( - "missing: {}", - missing - .iter() - .map(|s| s.as_str()) - .collect::>() - .join(", ") - )); - } - drift.push(format!("allow-list differs ({})", parts.join("; "))); - } - } - - let query_key = format!("{F}query"); - let allow_key = format!("{F}allow"); - if let Some(want_q) = expected.get(&query_key) { - let want_parsed: Option = - value_str(Some(want_q)).and_then(|s| serde_json::from_str(&s).ok()); - let have_parsed: Option = get_prop(actual, &query_key) - .and_then(|v| value_str(Some(v))) - .and_then(|s| serde_json::from_str(&s).ok()); - if want_parsed != have_parsed { - drift.push("relationship gate (f:query) differs".into()); - } - } else if expected.get(&allow_key).is_some() - && get_prop(actual, &allow_key).and_then(|v| value_bool(Some(v))) != Some(true) - { - drift.push("f:allow is not true".into()); - } - - drift -} - -// ── JSON-LD value helpers (query results come back compacted) ─────────── - -/// Expand a compacted IRI back to its full form for comparisons. -fn expand(s: &str) -> String { - if let Some(rest) = s.strip_prefix("f:") { - format!("{F}{rest}") - } else if let Some(rest) = s.strip_prefix("fm:") { - format!("{FM}{rest}") - } else { - s.to_string() - } -} - -/// Look up a property on a result node by full IRI, tolerating the -/// compacted key form the query's @context produces. -fn get_prop<'a>(node: &'a Value, full: &str) -> Option<&'a Value> { - if let Some(v) = node.get(full) { - return Some(v); - } - let local = full.rsplit(['#', '/']).next().unwrap_or(full); - for prefix in ["f:", "fm:"] { - if let Some(v) = node.get(format!("{prefix}{local}")) { - return Some(v); - } - } - None -} - -fn value_id(v: Option<&Value>) -> Option { - let ids = value_ids(v); - ids.into_iter().next() -} - -fn value_ids(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()); - } else if let Some(val) = o.get("@value").and_then(|x| x.as_str()) { - out.push(val.to_string()); - } - } - _ => {} - } - } - out -} - -fn value_str(v: Option<&Value>) -> Option { - let v = v?; - let item = match v { - Value::Array(a) => a.first()?, - other => other, - }; - match item { - Value::String(s) => Some(s.clone()), - Value::Object(o) => o.get("@value").and_then(|x| x.as_str()).map(String::from), - _ => None, - } -} - -fn value_bool(v: Option<&Value>) -> Option { - let v = v?; - let item = match v { - Value::Array(a) => a.first()?, - other => other, - }; - match item { - Value::Bool(b) => Some(*b), - Value::Object(o) => o.get("@value").and_then(serde_json::Value::as_bool), - _ => None, - } -} + const ENTITY: &str = "https://example.org/Lead"; + const CLASS: &str = "https://example.org/Lead/access/write"; -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 + /// 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() - .map(render_value) - .collect::>() - .join(", "), - other => other.to_string(), + .find(|n| n["@id"].as_str().unwrap() == id) } -} - -#[cfg(test)] -mod tests { - use super::*; #[test] - fn compile_write_profile_emits_class_profile_view_and_allow_list() { - let allowed = vec![RDF_TYPE.to_string(), "https://example.org/name".to_string()]; - let graph = compile( - "https://example.org/Lead/access/write", - "https://example.org/Lead", - Profile::Write, - &allowed, - None, - None, - "explicit", - false, - ); - let nodes = graph["@graph"].as_array().unwrap(); - assert_eq!(nodes.len(), 4, "class + profile + view + modify"); + fn write_profile_emits_class_view_and_verb_policy() { + let g = compile(CLASS, ENTITY, Profile::Write, &[], None); + assert_eq!(g["@graph"].as_array().unwrap().len(), 3); - let modify = nodes - .iter() - .find(|n| n["@id"].as_str().unwrap().ends_with("/modify")) - .expect("modify policy"); - let types: Vec<&str> = modify["@type"] + let view = node(&g, &format!("{CLASS}/view")).expect("view policy"); + assert_eq!(view[format!("{F}onClass")]["@id"], ENTITY); + assert_eq!(view[format!("{F}allow")], true); + + let write = node(&g, &format!("{CLASS}/write")).expect("write policy"); + let verbs: Vec<&str> = write[format!("{F}action")] .as_array() .unwrap() .iter() - .map(|t| t.as_str().unwrap()) + .map(|v| v["@id"].as_str().unwrap()) .collect(); - assert!(types.contains(&"https://ns.flur.ee/db#AccessPolicy")); - assert!(types.contains(&"https://example.org/Lead/access/write")); - let props = modify["https://ns.flur.ee/db#onProperty"] - .as_array() - .unwrap(); - assert_eq!(props.len(), 2, "rdf:type + name"); + 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"], ENTITY); + // 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 compile_read_profile_has_no_modify_policy() { - let graph = compile( + fn read_profile_has_no_write_policy() { + let g = compile( "https://example.org/Lead/access/read", - "https://example.org/Lead", + ENTITY, Profile::Read, - &[RDF_TYPE.to_string()], - None, + &[], None, - "explicit", - false, ); - let nodes = graph["@graph"].as_array().unwrap(); - assert_eq!(nodes.len(), 3, "class + profile + view (no modify)"); - assert!(nodes - .iter() - .all(|n| !n["@id"].as_str().unwrap().ends_with("/modify"))); + 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 compile_intake_profile_has_no_view_policy() { - let graph = compile( + fn intake_profile_is_create_only_without_view() { + let g = compile( "https://example.org/Lead/access/intake", - "https://example.org/Lead", + ENTITY, Profile::Intake, - &[RDF_TYPE.to_string()], + &[], None, - None, - "explicit", - false, ); - let nodes = graph["@graph"].as_array().unwrap(); - assert_eq!(nodes.len(), 3, "class + profile + modify (no view)"); - assert!(nodes - .iter() - .all(|n| !n["@id"].as_str().unwrap().ends_with("/view"))); - } - - #[test] - fn profile_parse_rejects_unknown() { - assert!(Profile::parse("admin").is_err()); - } - - #[test] - fn absolute_iri_gate() { - assert!(require_absolute_iri("--entity", "https://example.org/Lead").is_ok()); - assert!(require_absolute_iri("--entity", "urn:example:Lead").is_ok()); - assert!(require_absolute_iri("--entity", "ex:Lead").is_err()); - } -} - -#[cfg(test)] -mod connected_tests { - use super::*; - - #[test] - fn parses_single_inverse_step() { - let steps = parse_property_path("^").unwrap(); - assert_eq!(steps.len(), 1); - assert!(steps[0].inverse); - assert_eq!(steps[0].iri, "https://example.org/owner"); - } - - #[test] - fn parses_sequence_with_inverse_tail() { - let steps = - parse_property_path("/^") - .unwrap(); - assert_eq!(steps.len(), 2); - assert!(!steps[0].inverse); - assert!(steps[1].inverse); + 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 rejects_unbracketed_and_transitive() { - assert!(parse_property_path("https://example.org/owner").is_err()); - assert!(parse_property_path("+").is_err()); - assert!(parse_property_path("ex:owner").is_err()); - } - - #[test] - fn path_expands_to_anchored_patterns() { - let steps = - parse_property_path("/^") - .unwrap(); - let patterns = compile_path_where(&steps); - assert_eq!(patterns.len(), 2); - // identity —memberOf→ ?v0 - assert_eq!(patterns[0]["@id"], "?$identity"); - assert_eq!(patterns[0]["https://example.org/memberOf"]["@id"], "?v0"); - // ?$this —team→ ?v0 (inverse step targets ?$this as subject) - assert_eq!(patterns[1]["@id"], "?$this"); - assert_eq!(patterns[1]["https://example.org/team"]["@id"], "?v0"); + fn property_narrowing_adds_on_property_conjunction() { + let props = vec!["https://example.org/status".to_string()]; + let g = compile(CLASS, ENTITY, Profile::Write, &props, None); + let write = node(&g, &format!("{CLASS}/write")).unwrap(); + assert_eq!( + write[format!("{F}onProperty")][0]["@id"], + "https://example.org/status" + ); } #[test] - fn connected_view_policy_uses_query_not_allow() { - let steps = parse_property_path("^").unwrap(); + fn connected_gate_stores_path_verbatim_and_reversibly() { + let path = "/^"; let g = compile( "https://example.org/Lead/access/read", - "https://example.org/Lead", + ENTITY, Profile::Read, - &[RDF_TYPE.to_string()], - Some(&steps), - Some("^"), - "explicit", - false, + &[], + Some(path), ); - let nodes = g["@graph"].as_array().unwrap(); - let view = nodes - .iter() - .find(|n| n["@id"].as_str().unwrap().ends_with("/view")) - .expect("view policy"); - assert!(view.get("https://ns.flur.ee/db#allow").is_none()); - let q = view["https://ns.flur.ee/db#query"].as_str().unwrap(); - let parsed: Value = serde_json::from_str(q).unwrap(); - assert_eq!(parsed["where"][0]["@id"], "?$this"); - // profile node records the raw path - let profile = nodes - .iter() - .find(|n| n["@id"].as_str().unwrap().ends_with("/profile")) - .unwrap(); - assert_eq!( - profile["https://ns.flur.ee/model#connected"], - "^" + 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" ); - } -} - -#[cfg(test)] -mod verify_tests { - use super::*; - - fn compiled_write_nodes() -> Vec { - let allowed = vec![RDF_TYPE.to_string(), "https://example.org/name".to_string()]; - let g = compile( - "https://example.org/Lead/access/write", - "https://example.org/Lead", - Profile::Write, - &allowed, - None, - None, - "shacl-shape", - false, + 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) ); - g["@graph"].as_array().unwrap().clone() - } - - fn node(nodes: &[Value], suffix: &str) -> Value { - nodes - .iter() - .find(|n| n["@id"].as_str().unwrap().ends_with(suffix)) - .cloned() - .unwrap() - } - - #[test] - fn identical_nodes_have_no_drift() { - let nodes = compiled_write_nodes(); - let modify = node(&nodes, "/modify"); - assert!(diff_policy(&modify, &modify).is_empty()); } #[test] - fn compacted_actual_matches_full_expected() { - let nodes = compiled_write_nodes(); - let view = node(&nodes, "/view"); - // The same node as a query result would render it: compact keys/values. - let actual = serde_json::json!({ - "@id": "https://example.org/Lead/access/write/view", - "@type": ["f:AccessPolicy", "https://example.org/Lead/access/write"], - "f:action": {"@id": "f:view"}, - "f:onClass": {"@id": "https://example.org/Lead"}, - "f:allow": true, - }); - assert!(diff_policy(&view, &actual).is_empty()); - } - - #[test] - fn hand_widened_allow_list_is_drift() { - let nodes = compiled_write_nodes(); - let modify = node(&nodes, "/modify"); - let mut actual = modify.clone(); - actual[format!("{F}onProperty")] = serde_json::json!([ - {"@id": RDF_TYPE}, - {"@id": "https://example.org/name"}, - {"@id": "https://example.org/salary"} - ]); - let drift = diff_policy(&modify, &actual); - assert_eq!(drift.len(), 1); - assert!(drift[0].contains("salary"), "{drift:?}"); - assert!(drift[0].contains("extra in ledger"), "{drift:?}"); + fn connected_path_validation() { + assert!(validate_connected_path("/^").is_ok()); + assert!(validate_connected_path("").is_err()); + assert!(validate_connected_path(" &str { } /// Compile the entity definition into its JSON-LD artifacts. -fn compile(entity: &str, label: Option<&str>, specs: &[PropertySpec]) -> Value { +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!({ @@ -129,12 +129,22 @@ fn compile(entity: &str, label: Option<&str>, specs: &[PropertySpec]) -> Value { }) .collect(); - let shape = json!({ + 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]}) } @@ -146,6 +156,7 @@ pub async fn run(action: &ModelEntityAction, dirs: &FlureeDir, direct: bool) -> entity, properties, label, + closed, dry_run, remote, } => { @@ -154,6 +165,7 @@ pub async fn run(action: &ModelEntityAction, dirs: &FlureeDir, direct: bool) -> entity, properties, label.as_deref(), + *closed, *dry_run, remote.as_deref(), dirs, @@ -173,6 +185,7 @@ async fn run_define( entity: &str, property_specs: &[String], label: Option<&str>, + closed: bool, dry_run: bool, remote: Option<&str>, dirs: &FlureeDir, @@ -191,7 +204,7 @@ async fn run_define( .map(|s| parse_property_spec(s)) .collect::>()?; - let graph = compile(entity, label, &specs); + let graph = compile(entity, label, &specs, closed); println!("Entity: {entity}"); println!("Shape: {}/shape", entity.trim_end_matches('/')); @@ -238,7 +251,8 @@ async fn run_define( ); println!( "next: fluree model access enable {dataset} --profile write --entity {entity}\n\ - \x20 will derive its allowed properties from this shape." + \x20 grants create/update/delete on the class — the shape above governs\n\ + \x20 what valid instances look like." ); Ok(()) } @@ -362,7 +376,7 @@ mod tests { 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); + 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"); @@ -386,10 +400,25 @@ mod tests { ); } + #[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); + 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(); From d0e58cc870cb27bd78a78d98e5cbd5651e227ad9 Mon Sep 17 00:00:00 2001 From: bplatz Date: Fri, 17 Jul 2026 18:23:01 -0400 Subject: [PATCH 09/12] fix(cli): model compilers install owned nodes via atomic replace Property-merge upsert could leave a stale f:allow: true on a policy node when re-running `model access enable --connected`; the policy loader gives f:allow precedence over f:query, so the new relationship gate was silently disabled and the policy stayed allow-all. Install compiled artifacts through one update transaction that wildcard-deletes every OWNED node id (delete-if-exists via optional patterns) and inserts the fresh compilation. - access: owns {class}/view and {class}/write on every run, so gating switches are exact and profile switches on the same class revoke the node the new profile no longer emits - entity: owns the shape and its property-shape children (a dropped sh:closed/sh:minCount/sh:in no longer survives a re-run); the entity class node is shared authorship with `model class define` and stays additive - class show: recognize policy classes by the extra @type on f:AccessPolicy nodes (the fm:AccessProfile intent vocabulary no longer exists); correct the --subclass-of re-run hint (replaces, not extends) - integration test pins the CLI-emitted replace transaction end-to-end: an allow-all read profile switched to a --connected gate must narrow --- fluree-db-api/tests/it_policy_fquery.rs | 138 +++++++++++++++++++++ fluree-db-api/tests/it_policy_verbs.rs | 9 +- fluree-db-cli/src/commands/model/access.rs | 27 ++-- fluree-db-cli/src/commands/model/class.rs | 16 ++- fluree-db-cli/src/commands/model/entity.rs | 17 ++- fluree-db-cli/src/commands/model/mod.rs | 103 ++++++++++++++- 6 files changed, 293 insertions(+), 17 deletions(-) diff --git a/fluree-db-api/tests/it_policy_fquery.rs b/fluree-db-api/tests/it_policy_fquery.rs index 8beecbcae9..e76d3c5e06 100644 --- a/fluree-db-api/tests/it_policy_fquery.rs +++ b/fluree-db-api/tests/it_policy_fquery.rs @@ -658,3 +658,141 @@ async fn policy_fquery_with_path_context_term() { "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 5daf001543..f5626bb9f6 100644 --- a/fluree-db-api/tests/it_policy_verbs.rs +++ b/fluree-db-api/tests/it_policy_verbs.rs @@ -826,7 +826,14 @@ async fn scaffolder_write_profile_grants_class_ownership_only() { "@type": "ex:Person", "ex:nickname": "Mallory" }); - let denied = try_txn(&fluree, ledger.clone(), TxnType::Insert, &create_person, &ctx).await; + 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:?}" diff --git a/fluree-db-cli/src/commands/model/access.rs b/fluree-db-cli/src/commands/model/access.rs index f5b2580a9f..96432636ad 100644 --- a/fluree-db-cli/src/commands/model/access.rs +++ b/fluree-db-cli/src/commands/model/access.rs @@ -22,14 +22,22 @@ //! cause which state transitions". //! //! There is NO stored intent language: the compiled policies are small and -//! self-describing, idempotence comes from deterministic node ids -//! (`{class}/view`, `{class}/write`), the `--connected` path survives -//! verbatim inside the stored `f:query`'s `@path` context term, and -//! hand-editing a policy is legitimate authorship — not drift. +//! 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, resolve_mode, upsert}; +use super::{query, replace_nodes, resolve_mode}; use crate::cli::ModelAccessAction; use crate::error::{CliError, CliResult}; use fluree_db_api::server_defaults::FlureeDir; @@ -205,8 +213,12 @@ async fn run_enable( // 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?; - upsert(&mode, &graph).await?; + let view_id = format!("{class_iri}/view"); + let write_id = format!("{class_iri}/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) { @@ -251,7 +263,8 @@ fn validate_connected_path(path: &str) -> CliResult<()> { /// Compile the profile into its JSON-LD artifacts: the policy class (the /// assignment unit grants and tokens carry) and one or two thin policies. /// Deterministic ids ({class}/view, {class}/write) make re-running -/// idempotent — `upsert` replaces listed properties in place. +/// idempotent — `enable` atomically replaces both owned nodes with the +/// fresh compilation. fn compile( class_iri: &str, entity: &str, diff --git a/fluree-db-cli/src/commands/model/class.rs b/fluree-db-cli/src/commands/model/class.rs index f5353c995a..8b02dc979e 100644 --- a/fluree-db-cli/src/commands/model/class.rs +++ b/fluree-db-cli/src/commands/model/class.rs @@ -13,7 +13,7 @@ use crate::cli::ModelClassAction; use crate::error::{CliError, CliResult}; use fluree_db_api::server_defaults::FlureeDir; -const FM: &str = "https://ns.flur.ee/model#"; +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"; const RDFS_SUBCLASS_OF: &str = "http://www.w3.org/2000/01/rdf-schema#subClassOf"; @@ -82,7 +82,10 @@ async fn run_define( let mode = resolve_mode(dataset, remote, dirs, direct).await?; upsert(&mode, &node).await?; - println!("Defined on '{dataset}'. Re-run with more --subclass-of to extend."); + println!( + "Defined on '{dataset}'. Re-running with --subclass-of replaces the \ + parent set (list every parent each time)." + ); Ok(()) } @@ -116,13 +119,16 @@ async fn run_show( // 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": {"fm": FM}, + "@context": {"f": F}, "select": ["?class"], "where": [ - {"@id": "?p", "@type": "fm:AccessProfile"}, - {"@id": "?p", "fm:policyClass": {"@id": "?class"}} + {"@id": "?p", "@type": "f:AccessPolicy"}, + {"@id": "?p", "@type": "?class"} ] }); iri_rows(&query(&mode, &q).await?) diff --git a/fluree-db-cli/src/commands/model/entity.rs b/fluree-db-cli/src/commands/model/entity.rs index 51181ff6cd..e922b35b9d 100644 --- a/fluree-db-cli/src/commands/model/entity.rs +++ b/fluree-db-cli/src/commands/model/entity.rs @@ -13,7 +13,7 @@ use serde_json::{json, Value}; -use super::{query, resolve_mode, upsert}; +use super::{query, replace_nodes, resolve_mode}; use crate::cli::ModelEntityAction; use crate::error::{CliError, CliResult}; use fluree_db_api::server_defaults::FlureeDir; @@ -240,8 +240,21 @@ async fn run_define( 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. + // The entity class node is NOT owned: it's shared authorship with + // `model class define` (hierarchy, label), so it stays additive. let mode = resolve_mode(dataset, remote, dirs, direct).await?; - upsert(&mode, &graph).await?; + let shape_iri = format!("{}/shape", entity.trim_end_matches('/')); + let mut owned: Vec = specs + .iter() + .map(|p| format!("{shape_iri}/{}", local_name(&p.path))) + .collect(); + owned.insert(0, shape_iri); + let owned_refs: Vec<&str> = owned.iter().map(String::as_str).collect(); + replace_nodes(&mode, &graph, &owned_refs).await?; println!("\nDefined. Shape written to '{dataset}'."); println!( diff --git a/fluree-db-cli/src/commands/model/mod.rs b/fluree-db-cli/src/commands/model/mod.rs index ba3284c217..01f05619b3 100644 --- a/fluree-db-cli/src/commands/model/mod.rs +++ b/fluree-db-cli/src/commands/model/mod.rs @@ -66,8 +66,9 @@ pub(crate) async fn query(mode: &LedgerMode, body: &Value) -> CliResult { } } -/// Upsert JSON-LD in either mode (replace-listed-properties semantics keeps -/// `enable` idempotent when the property set changes). +/// 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 { @@ -83,3 +84,101 @@ pub(crate) async fn upsert(mode: &LedgerMode, body: &Value) -> CliResult<()> { } 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. +pub(crate) async fn replace_nodes( + mode: &LedgerMode, + graph: &Value, + owned_ids: &[&str], +) -> CliResult<()> { + update(mode, &replace_txn(graph, owned_ids)).await +} + +/// Pure builder for [`replace_nodes`]'s transaction: optional wildcard +/// match + delete per owned id (delete-if-exists — unbound rows skip their +/// delete template), then insert the compiled nodes. +pub(crate) fn replace_txn(graph: &Value, owned_ids: &[&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); + } + let insert = graph + .get("@graph") + .cloned() + .unwrap_or_else(|| graph.clone()); + serde_json::json!({ + "where": where_clause, + "delete": delete, + "insert": insert, + }) +} + +#[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); + } +} From fe1c621976d669fe46e380b0c4dcac93f16da488 Mon Sep 17 00:00:00 2001 From: bplatz Date: Fri, 17 Jul 2026 18:23:06 -0400 Subject: [PATCH 10/12] docs(cli): fluree model command reference Add docs/cli/model.md covering the three governance facets (access profiles, SHACL entity shapes, class hierarchy), their compile-to-data architecture, and the atomic replace semantics of re-running enable and define. Index it in docs/SUMMARY.md and the CLI command table. --- docs/SUMMARY.md | 1 + docs/cli/README.md | 1 + docs/cli/model.md | 123 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 docs/cli/model.md 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..af33e76721 --- /dev/null +++ b/docs/cli/model.md @@ -0,0 +1,123 @@ +# 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 --entity [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` | +| `--entity ` | Entity class IRI (absolute) | +| `--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 entity, e.g. `"^"` (I see what I own). Stored verbatim in the policy via the engine's `@path` context term | +| `--class-iri ` | Policy class IRI override (default `{entity}/access/{profile}`) | +| `--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 | + +### Semantics of 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. 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 class are exact — switching `write` → `read` revokes `{class}/write`. + +### Examples + +```bash +# Apps may fully own Lead instances +fluree model access enable crm --profile write --entity https://example.org/Lead + +# Column-narrowed write: may edit status of Leads, nothing else +fluree model access enable crm --profile write --entity 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 --entity https://example.org/Lead \ + --connected "/^" + +# Hosted stack: transact the policies AND attach the class to a space grant +fluree model access enable crm --profile write --entity 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" +``` From 2372a5304a9c94c5e9803c9d2bf224b48ad725ad Mon Sep 17 00:00:00 2001 From: bplatz Date: Fri, 17 Jul 2026 18:54:22 -0400 Subject: [PATCH 11/12] refactor(cli): model access targets --class, override is --policy-class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The access command's target restriction compiles to f:onClass, but the flag was named --entity — which reads as subject-level targeting (f:onSubject) to RDF users, and clashed with `model class define --class` taking the SAME iri for the same concept. Rename: - --entity → --class (the governed class; what data) - --class-iri → --policy-class (the assignment unit grants/tokens carry; how a request selects its policy set — not a data restriction) `model entity define --entity` is unchanged: there the flag names the entity facet's own concept. Internal names and enable's output follow (Policy class: line), and the docs spell out the three class-shaped roles: --class restricts data, --property narrows columns, the policy class selects the policy set. --- docs/cli/model.md | 22 ++-- fluree-db-cli/src/cli.rs | 13 ++- fluree-db-cli/src/commands/model/access.rs | 111 +++++++++++---------- fluree-db-cli/src/commands/model/entity.rs | 2 +- 4 files changed, 77 insertions(+), 71 deletions(-) diff --git a/docs/cli/model.md b/docs/cli/model.md index af33e76721..bb2602417b 100644 --- a/docs/cli/model.md +++ b/docs/cli/model.md @@ -25,7 +25,7 @@ All write commands accept `--dry-run` (print the compiled JSON-LD without transa Compile an access profile into policies on a dataset. ```bash -fluree model access enable --profile --entity [OPTIONS] +fluree model access enable --profile --class [OPTIONS] fluree model access show ``` @@ -44,33 +44,35 @@ Verb semantics make the class grant exact: class targeting matches pre ∪ post | Option | Description | |--------|-------------| | `--profile

` | `read`, `write`, or `intake` | -| `--entity ` | Entity class IRI (absolute) | +| `--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 entity, e.g. `"^"` (I see what I own). Stored verbatim in the policy via the engine's `@path` context term | -| `--class-iri ` | Policy class IRI override (default `{entity}/access/{profile}`) | +| `--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 (`{class}/view`, `{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 class are exact — switching `write` → `read` revokes `{class}/write`. +`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 --entity https://example.org/Lead +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 --entity https://example.org/Lead \ +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 --entity https://example.org/Lead \ +fluree model access enable crm --profile read --class https://example.org/Lead \ --connected "/^" -# Hosted stack: transact the policies AND attach the class to a space grant -fluree model access enable crm --profile write --entity https://example.org/Lead \ +# 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 ``` diff --git a/fluree-db-cli/src/cli.rs b/fluree-db-cli/src/cli.rs index 3eb7f80be2..187b5e264c 100644 --- a/fluree-db-cli/src/cli.rs +++ b/fluree-db-cli/src/cli.rs @@ -1627,9 +1627,10 @@ pub enum ModelAccessAction { #[arg(long)] profile: String, - /// Entity class IRI (absolute, e.g. https://example.org/Lead) + /// Target class IRI whose instances the profile governs (absolute, + /// e.g. https://example.org/Lead) — compiles to f:onClass #[arg(long)] - entity: String, + class: String, /// Optional COLUMN narrowing for the write policy (absolute IRIs): /// the grant covers only these properties of the class ("may edit @@ -1637,9 +1638,11 @@ pub enum ModelAccessAction { #[arg(long = "property")] properties: Vec, - /// Policy class IRI override (default: {entity}/access/{profile}) + /// 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)] - class_iri: Option, + policy_class: Option, /// Attach the policy class to this space's grant on the dataset /// (hosted stacks; requires --remote). Merges with existing classes. @@ -1647,7 +1650,7 @@ pub enum ModelAccessAction { space: Option, /// Relationship gate (read profile only): a SPARQL property path - /// from the requesting identity to the entity, with angle-bracketed + /// 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 diff --git a/fluree-db-cli/src/commands/model/access.rs b/fluree-db-cli/src/commands/model/access.rs index 96432636ad..442b93c2de 100644 --- a/fluree-db-cli/src/commands/model/access.rs +++ b/fluree-db-cli/src/commands/model/access.rs @@ -96,9 +96,9 @@ pub async fn run(action: &ModelAccessAction, dirs: &FlureeDir, direct: bool) -> ModelAccessAction::Enable { dataset, profile, - entity, + class, properties, - class_iri, + policy_class, space, connected, dry_run, @@ -107,9 +107,9 @@ pub async fn run(action: &ModelAccessAction, dirs: &FlureeDir, direct: bool) -> run_enable( dataset, profile, - entity, + class, properties, - class_iri.as_deref(), + policy_class.as_deref(), space.as_deref(), connected.as_deref(), *dry_run, @@ -131,9 +131,9 @@ pub async fn run(action: &ModelAccessAction, dirs: &FlureeDir, direct: bool) -> async fn run_enable( dataset: &str, profile_str: &str, - entity: &str, + class: &str, properties: &[String], - class_iri_override: Option<&str>, + policy_class_override: Option<&str>, space: Option<&str>, connected: Option<&str>, dry_run: bool, @@ -142,7 +142,7 @@ async fn run_enable( direct: bool, ) -> CliResult<()> { let profile = Profile::parse(profile_str)?; - require_absolute_iri("--entity", entity)?; + require_absolute_iri("--class", class)?; for p in properties { require_absolute_iri("--property", p)?; } @@ -172,34 +172,34 @@ async fn run_enable( )); } - let class_iri = class_iri_override.map(String::from).unwrap_or_else(|| { + let policy_class = policy_class_override.map(String::from).unwrap_or_else(|| { format!( "{}/access/{}", - entity.trim_end_matches('/'), + class.trim_end_matches('/'), profile.as_str() ) }); - let graph = compile(&class_iri, entity, profile, properties, connected); + let graph = compile(&policy_class, class, profile, properties, connected); - println!("Profile: {} {}", profile.as_str(), entity); - println!("Class: {class_iri}"); + 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)"), + 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)", + "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!("Connected: {path} (view gated by relationship to the requesting identity)"); } println!( - "note: the property SURFACE of {entity} is governed by its SHACL shape\n\ + "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." ); @@ -216,8 +216,8 @@ async fn run_enable( // 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!("{class_iri}/view"); - let write_id = format!("{class_iri}/write"); + 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}'."); @@ -227,14 +227,14 @@ async fn run_enable( remote_name, dataset, space_id, - &class_iri, + &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 {} --entity {entity} \ + fluree model access enable {dataset} --profile {} --class {class} \ --space --remote ", profile.as_str() ); @@ -261,13 +261,14 @@ fn validate_connected_path(path: &str) -> CliResult<()> { // ── 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. -/// Deterministic ids ({class}/view, {class}/write) make re-running -/// idempotent — `enable` atomically replaces both owned nodes with the -/// fresh compilation. +/// 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( - class_iri: &str, - entity: &str, + policy_class: &str, + class: &str, profile: Profile, properties: &[String], connected: Option<&str>, @@ -275,17 +276,17 @@ fn compile( let mut nodes: Vec = Vec::new(); nodes.push(json!({ - "@id": class_iri, + "@id": policy_class, "@type": RDFS_CLASS, - RDFS_LABEL: format!("{} access: {}", profile.as_str(), entity), + RDFS_LABEL: format!("{} access: {}", profile.as_str(), class), })); if profile.wants_view() { let mut view = json!({ - "@id": format!("{class_iri}/view"), - "@type": [format!("{F}AccessPolicy"), class_iri], + "@id": format!("{policy_class}/view"), + "@type": [format!("{F}AccessPolicy"), policy_class], format!("{F}action"): {"@id": format!("{F}view")}, - format!("{F}onClass"): {"@id": entity}, + format!("{F}onClass"): {"@id": class}, }); match connected { Some(path) => { @@ -316,10 +317,10 @@ fn compile( .map(|v| json!({"@id": format!("{F}{v}")})) .collect(); let mut write = json!({ - "@id": format!("{class_iri}/write"), - "@type": [format!("{F}AccessPolicy"), class_iri], + "@id": format!("{policy_class}/write"), + "@type": [format!("{F}AccessPolicy"), policy_class], format!("{F}action"): action, - format!("{F}onClass"): {"@id": entity}, + format!("{F}onClass"): {"@id": class}, format!("{F}allow"): true, }); if !properties.is_empty() { @@ -358,7 +359,7 @@ async fn run_show( println!("No access policies on '{dataset}'."); println!( "Enable a profile: fluree model access enable {dataset} \ - --profile write --entity " + --profile write --class " ); return Ok(()); } @@ -452,7 +453,7 @@ fn require_absolute_iri(flag: &str, v: &str) -> CliResult<()> { } } -/// Merge `class_iri` into the space's grant on the dataset via the stack's +/// 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). @@ -461,7 +462,7 @@ async fn attach_grant( remote_name: &str, dataset: &str, space_id: &str, - class_iri: &str, + policy_class: &str, wants_write: bool, ) -> CliResult<()> { use crate::config::TomlSyncConfigStore; @@ -538,8 +539,8 @@ async fn attach_grant( .collect() }) .unwrap_or_default(); - if !classes.iter().any(|c| c == class_iri) { - classes.push(class_iri.to_string()); + if !classes.iter().any(|c| c == policy_class) { + classes.push(policy_class.to_string()); } let existing_access = existing @@ -582,8 +583,8 @@ async fn attach_grant( mod tests { use super::*; - const ENTITY: &str = "https://example.org/Lead"; - const CLASS: &str = "https://example.org/Lead/access/write"; + 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. @@ -597,14 +598,14 @@ mod tests { #[test] fn write_profile_emits_class_view_and_verb_policy() { - let g = compile(CLASS, ENTITY, Profile::Write, &[], None); + let g = compile(POLICY_CLASS, LEAD, Profile::Write, &[], None); assert_eq!(g["@graph"].as_array().unwrap().len(), 3); - let view = node(&g, &format!("{CLASS}/view")).expect("view policy"); - assert_eq!(view[format!("{F}onClass")]["@id"], ENTITY); + 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!("{CLASS}/write")).expect("write policy"); + let write = node(&g, &format!("{POLICY_CLASS}/write")).expect("write policy"); let verbs: Vec<&str> = write[format!("{F}action")] .as_array() .unwrap() @@ -619,7 +620,7 @@ mod tests { "https://ns.flur.ee/db#delete" ] ); - assert_eq!(write[format!("{F}onClass")]["@id"], ENTITY); + 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()); @@ -629,7 +630,7 @@ mod tests { fn read_profile_has_no_write_policy() { let g = compile( "https://example.org/Lead/access/read", - ENTITY, + LEAD, Profile::Read, &[], None, @@ -642,7 +643,7 @@ mod tests { fn intake_profile_is_create_only_without_view() { let g = compile( "https://example.org/Lead/access/intake", - ENTITY, + LEAD, Profile::Intake, &[], None, @@ -658,8 +659,8 @@ mod tests { #[test] fn property_narrowing_adds_on_property_conjunction() { let props = vec!["https://example.org/status".to_string()]; - let g = compile(CLASS, ENTITY, Profile::Write, &props, None); - let write = node(&g, &format!("{CLASS}/write")).unwrap(); + 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" @@ -671,7 +672,7 @@ mod tests { let path = "/^"; let g = compile( "https://example.org/Lead/access/read", - ENTITY, + LEAD, Profile::Read, &[], Some(path), @@ -701,8 +702,8 @@ mod tests { #[test] fn absolute_iri_gate() { - assert!(require_absolute_iri("--entity", "https://example.org/Lead").is_ok()); - assert!(require_absolute_iri("--entity", "ex:Lead").is_err()); + assert!(require_absolute_iri("--class", "https://example.org/Lead").is_ok()); + assert!(require_absolute_iri("--class", "ex:Lead").is_err()); } #[test] diff --git a/fluree-db-cli/src/commands/model/entity.rs b/fluree-db-cli/src/commands/model/entity.rs index e922b35b9d..6d69d8835d 100644 --- a/fluree-db-cli/src/commands/model/entity.rs +++ b/fluree-db-cli/src/commands/model/entity.rs @@ -263,7 +263,7 @@ async fn run_define( \x20 not retro-validated; `fluree validate` produces a full report." ); println!( - "next: fluree model access enable {dataset} --profile write --entity {entity}\n\ + "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." ); From 8c1174fd97a2cab97d378e5439778619a01a3217 Mon Sep 17 00:00:00 2001 From: bplatz Date: Sat, 18 Jul 2026 15:37:42 -0400 Subject: [PATCH 12/12] fix(cli): address model command review feedback - reject child property-shape id collisions (shared local names silently merged constraints, dropping one path from validation) - reject contradictory --property tokens (iri+datatype, double datatype, iri+in[...]) and tighten in[...] parsing (no empty values, clear error for spaces) - union existing sh:property children into the owned set so a dropped property's child shape is deleted, not orphaned - add --clear-subclass-of to model class define (upsert cannot remove the last parent; stale parents widen grants under RDFS entailment) - make entity define --label idempotent via targeted property replace - dedupe require_absolute_iri across facets; share iri_rows - cross-reference the hand-copied scaffolder shape in it_policy_verbs --- fluree-db-api/tests/it_policy_verbs.rs | 6 +- fluree-db-cli/src/cli.rs | 6 + fluree-db-cli/src/commands/model/access.rs | 14 +- fluree-db-cli/src/commands/model/class.rs | 135 +++++++++---- fluree-db-cli/src/commands/model/entity.rs | 215 +++++++++++++++++---- fluree-db-cli/src/commands/model/mod.rs | 75 ++++++- 6 files changed, 356 insertions(+), 95 deletions(-) diff --git a/fluree-db-api/tests/it_policy_verbs.rs b/fluree-db-api/tests/it_policy_verbs.rs index f5626bb9f6..49c5242ecd 100644 --- a/fluree-db-api/tests/it_policy_verbs.rs +++ b/fluree-db-api/tests/it_policy_verbs.rs @@ -742,7 +742,11 @@ async fn scaffolder_write_profile_grants_class_ownership_only() { let fluree = FlureeBuilder::memory().build_memory(); let ledger = seed(&fluree, "verbs_scaffolder_write").await; - // Byte-for-byte the shape `fluree model access enable` emits. + // 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#"}, diff --git a/fluree-db-cli/src/cli.rs b/fluree-db-cli/src/cli.rs index 187b5e264c..da79ba2945 100644 --- a/fluree-db-cli/src/cli.rs +++ b/fluree-db-cli/src/cli.rs @@ -1529,6 +1529,12 @@ pub enum ModelClassAction { #[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, diff --git a/fluree-db-cli/src/commands/model/access.rs b/fluree-db-cli/src/commands/model/access.rs index 442b93c2de..47016d682d 100644 --- a/fluree-db-cli/src/commands/model/access.rs +++ b/fluree-db-cli/src/commands/model/access.rs @@ -37,7 +37,7 @@ use serde_json::{json, Value}; -use super::{query, replace_nodes, resolve_mode}; +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; @@ -218,7 +218,7 @@ async fn run_enable( 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?; + replace_nodes(&mode, &graph, &[&view_id, &write_id], &[]).await?; println!("\nPolicies transacted to '{dataset}'."); if let (Some(space_id), Some(remote_name)) = (space, remote) { @@ -443,16 +443,6 @@ fn list_values(v: Option<&Value>) -> Vec { out } -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" - ))) - } -} - /// 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 diff --git a/fluree-db-cli/src/commands/model/class.rs b/fluree-db-cli/src/commands/model/class.rs index 8b02dc979e..65e8a62651 100644 --- a/fluree-db-cli/src/commands/model/class.rs +++ b/fluree-db-cli/src/commands/model/class.rs @@ -8,9 +8,9 @@ use serde_json::{json, Value}; -use super::{query, resolve_mode, upsert}; +use super::{iri_rows, query, require_absolute_iri, resolve_mode, update, upsert}; use crate::cli::ModelClassAction; -use crate::error::{CliError, CliResult}; +use crate::error::CliResult; use fluree_db_api::server_defaults::FlureeDir; const F: &str = "https://ns.flur.ee/db#"; @@ -24,6 +24,7 @@ pub async fn run(action: &ModelClassAction, dirs: &FlureeDir, direct: bool) -> C dataset, class, subclass_of, + clear_subclass_of, label, dry_run, remote, @@ -32,6 +33,7 @@ pub async fn run(action: &ModelClassAction, dirs: &FlureeDir, direct: bool) -> C dataset, class, subclass_of, + *clear_subclass_of, label.as_deref(), *dry_run, remote.as_deref(), @@ -51,6 +53,7 @@ async fn run_define( dataset: &str, class: &str, subclass_of: &[String], + clear_subclass_of: bool, label: Option<&str>, dry_run: bool, remote: Option<&str>, @@ -65,7 +68,9 @@ async fn run_define( let node = compile(class, subclass_of, label); println!("Class: {class}"); - if !subclass_of.is_empty() { + 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\ @@ -76,19 +81,49 @@ async fn run_define( if dry_run { println!("\n-- dry run; compiled JSON-LD --"); - println!("{}", serde_json::to_string_pretty(&node)?); + 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?; - upsert(&mode, &node).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)." + 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. @@ -141,14 +176,7 @@ async fn run_show( }); let result = query(&mode, &q).await?; let rows = result.as_array().cloned().unwrap_or_default(); - let domain: Vec<&Value> = rows - .iter() - .filter(|row| { - row["@id"] - .as_str() - .is_some_and(|id| !policy_classes.iter().any(|pc| pc == id)) - }) - .collect(); + let domain = domain_classes(&rows, &policy_classes); if domain.is_empty() { println!("No classes defined on '{dataset}'."); @@ -170,14 +198,16 @@ async fn run_show( Ok(()) } -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" - ))) - } +/// 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 { @@ -218,21 +248,6 @@ fn ids_of(v: Option<&Value>) -> Vec { out } -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::*; @@ -256,6 +271,52 @@ mod tests { ); } + #[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!( diff --git a/fluree-db-cli/src/commands/model/entity.rs b/fluree-db-cli/src/commands/model/entity.rs index 6d69d8835d..c4d6f2e52e 100644 --- a/fluree-db-cli/src/commands/model/entity.rs +++ b/fluree-db-cli/src/commands/model/entity.rs @@ -11,9 +11,11 @@ //! 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::{query, replace_nodes, resolve_mode}; +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; @@ -43,11 +45,7 @@ fn parse_property_spec(spec: &str) -> CliResult { .next() .ok_or_else(|| CliError::Usage("empty --property spec".into()))? .to_string(); - if !(path.starts_with("http://") || path.starts_with("https://") || path.starts_with("urn:")) { - return Err(CliError::Usage(format!( - "--property must start with an absolute IRI (got '{path}')" - ))); - } + require_absolute_iri("--property", &path)?; let mut out = PropertySpec { path, @@ -58,26 +56,50 @@ fn parse_property_spec(spec: &str) -> CliResult { }; for token in tokens { match token { - "string" => out.datatype = Some(format!("{XSD}string")), - "integer" => out.datatype = Some(format!("{XSD}integer")), - "decimal" => out.datatype = Some(format!("{XSD}decimal")), - "boolean" => out.datatype = Some(format!("{XSD}boolean")), - "date" => out.datatype = Some(format!("{XSD}date")), - "datetime" => out.datatype = Some(format!("{XSD}dateTime")), - "iri" => out.iri_kind = true, + "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(|v| v.trim().to_string()) - .filter(|v| !v.is_empty()) - .collect(); - if out.values_in.is_empty() { + 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 (in '{spec}')" + "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}' \ @@ -86,6 +108,14 @@ fn parse_property_spec(spec: &str) -> CliResult { } } } + 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) } @@ -94,6 +124,45 @@ 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('/')); @@ -191,23 +260,18 @@ async fn run_define( dirs: &FlureeDir, direct: bool, ) -> CliResult<()> { - if !(entity.starts_with("http://") - || entity.starts_with("https://") - || entity.starts_with("urn:")) - { - return Err(CliError::Usage(format!( - "--entity must be an absolute IRI (got '{entity}')" - ))); - } + 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", entity.trim_end_matches('/')); + println!("Shape: {shape_iri}"); println!("Properties: {}", specs.len()); for p in &specs { let mut notes: Vec = vec![]; @@ -244,17 +308,27 @@ async fn run_define( // 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. - // The entity class node is NOT owned: it's shared authorship with - // `model class define` (hierarchy, label), so it stays additive. + // 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 shape_iri = format!("{}/shape", entity.trim_end_matches('/')); - let mut owned: Vec = specs - .iter() - .map(|p| format!("{shape_iri}/{}", local_name(&p.path))) - .collect(); - owned.insert(0, shape_iri); + 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(); - replace_nodes(&mode, &graph, &owned_refs).await?; + 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!( @@ -383,6 +457,71 @@ mod tests { 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![ diff --git a/fluree-db-cli/src/commands/model/mod.rs b/fluree-db-cli/src/commands/model/mod.rs index 01f05619b3..ef81346609 100644 --- a/fluree-db-cli/src/commands/model/mod.rs +++ b/fluree-db-cli/src/commands/model/mod.rs @@ -17,7 +17,7 @@ pub mod entity; use crate::cli::ModelAction; use crate::context::{self, LedgerMode}; -use crate::error::CliResult; +use crate::error::{CliError, CliResult}; use fluree_db_api::server_defaults::FlureeDir; use serde_json::Value; @@ -108,19 +108,27 @@ pub(crate) async fn update(mode: &LedgerMode, body: &Value) -> CliResult<()> { /// 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. +/// 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)).await + 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), then insert the compiled nodes. -pub(crate) fn replace_txn(graph: &Value, owned_ids: &[&str]) -> Value { +/// 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() { @@ -128,6 +136,11 @@ pub(crate) fn replace_txn(graph: &Value, owned_ids: &[&str]) -> Value { 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() @@ -139,6 +152,34 @@ pub(crate) fn replace_txn(graph: &Value, owned_ids: &[&str]) -> Value { }) } +/// 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; @@ -150,7 +191,7 @@ mod tests { {"@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 txn = replace_txn(&graph, &["https://x/A", "https://x/B"], &[]); let where_clause = txn["where"].as_array().unwrap(); assert_eq!(where_clause.len(), 2); @@ -176,9 +217,29 @@ mod tests { // 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"]); + 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"); + } }