diff --git a/crates/feature-permissions/README.md b/crates/feature-permissions/README.md index 31b3fadd3..700545288 100644 --- a/crates/feature-permissions/README.md +++ b/crates/feature-permissions/README.md @@ -56,5 +56,46 @@ A text/storage/management assignment uses an explicit discriminant, for example: The same Serde types validate dynamic inputs; unknown fields (including payloads) are rejected. [`typescript`] generates the Web contract. Management projections are serializable observations, not credentials: deserialization cannot manufacture a -runtime resolution, namespace handle or delegation grant. The consumer owns -policy persistence and its route/subject authentication, not this DTO layer. +runtime resolution, namespace handle or delegation grant. The consumer owns policy persistence and its route/subject authentication, not +this DTO layer. + +To add a Feature, the trusted loader registers the established identity and gives +its registrar a namespace handle. The declaration contains only operation keys +and explicit edges; no central domain list needs updating: + +```rust +use feature_permissions::*; +let mut host = PermissionHost::new(WorkspaceId::parse("workspace-a")?); +// Host-only: a Plugin receives its bound registrar, not this registration API. +let feature = host.register_feature(FeatureIdentity::parse("plugin:example")?)?; +let namespace = host.bind_namespace(&feature, NamespaceId::parse("example")?)?; +let read = PermissionKey::parse("example.documents.read")?; +host.declare(&namespace, NamespaceDeclaration { + permissions: vec![PermissionDefinition { + key: read.clone(), description: "Read example documents".into(), children: vec![], + }], + sets: vec![SetContribution { + set: PermissionSetId::parse("companion")?, + children: vec![PermissionReference::Namespace(NamespacePermissions { + namespace: NamespaceId::parse("example")?, keys: vec![read.clone()], + })], + }], +})?; +let assignment = PermissionAssignment { + grants: vec![PermissionReference::Set(PermissionSetId::parse("companion")?)], + ..Default::default() +}; +let resolved = host.resolve(Some(&assignment)); +assert!(host.allows(&namespace, &resolved, &read)?); +# Ok::<(), Box>(()) +``` + +Wire inputs cannot manufacture an owner handle or executable resolution: + +```compile_fail +let _: feature_permissions::NamespaceHandle = serde_json::from_str("{}").unwrap(); +``` + +```compile_fail +let _: feature_permissions::ResolvedPermissions = serde_json::from_str("{}").unwrap(); +``` diff --git a/crates/feature-permissions/examples/generate_typescript.rs b/crates/feature-permissions/examples/generate_typescript.rs new file mode 100644 index 000000000..cc78f7a26 --- /dev/null +++ b/crates/feature-permissions/examples/generate_typescript.rs @@ -0,0 +1,3 @@ +fn main() { + print!("{}", feature_permissions::typescript()); +} diff --git a/crates/feature-permissions/src/registry.rs b/crates/feature-permissions/src/registry.rs index f00012637..af9cb8d3e 100644 --- a/crates/feature-permissions/src/registry.rs +++ b/crates/feature-permissions/src/registry.rs @@ -50,6 +50,12 @@ impl NamespaceHandle { pub fn namespace(&self) -> &NamespaceId { &self.namespace } + + /// Compare the actual registrations, never caller-supplied owner names. + pub fn is_owned_by(&self, feature: &FeatureHandle) -> bool { + Arc::ptr_eq(&self.feature.host, &feature.host) + && Arc::ptr_eq(&self.feature.registration, &feature.registration) + } } #[derive(Clone, Debug)] struct FeatureRecord { diff --git a/crates/feature-permissions/tests/fixtures/assignment.json b/crates/feature-permissions/tests/fixtures/assignment.json new file mode 100644 index 000000000..62288bcb5 --- /dev/null +++ b/crates/feature-permissions/tests/fixtures/assignment.json @@ -0,0 +1,22 @@ +{ + "grants": [ + { "kind": "set", "value": "ticket" }, + { + "kind": "namespace", + "value": { + "namespace": "ticket", + "keys": ["ticket.hoge", "ticket.hoge.fuga.finish"] + } + } + ], + "exclusions": [ + { + "kind": "namespace", + "value": { + "namespace": "ticket", + "keys": ["ticket.hoge.unlinked"] + } + } + ], + "revoked": false +} diff --git a/crates/feature-permissions/tests/fixtures/assignment.toml b/crates/feature-permissions/tests/fixtures/assignment.toml new file mode 100644 index 000000000..97ccd3b1c --- /dev/null +++ b/crates/feature-permissions/tests/fixtures/assignment.toml @@ -0,0 +1,17 @@ +revoked = false + +[[grants]] +kind = "set" +value = "ticket" + +[[grants]] +kind = "namespace" +[grants.value] +namespace = "ticket" +keys = ["ticket.hoge", "ticket.hoge.fuga.finish"] + +[[exclusions]] +kind = "namespace" +[exclusions.value] +namespace = "ticket" +keys = ["ticket.hoge.unlinked"] diff --git a/crates/feature-permissions/tests/fixtures/catalog.json b/crates/feature-permissions/tests/fixtures/catalog.json new file mode 100644 index 000000000..fab83bc8c --- /dev/null +++ b/crates/feature-permissions/tests/fixtures/catalog.json @@ -0,0 +1,45 @@ +{ + "workspace": "workspace-a", + "namespaces": [ + { + "namespace": "ticket", + "feature": "builtin:ticket", + "enabled": true, + "declaration": { + "permissions": [ + { + "key": "ticket.hoge", + "description": "Root operation with explicit children", + "children": ["ticket.hoge.fuga"] + }, + { + "key": "ticket.hoge.fuga", + "description": "Intermediate operation with an explicit child", + "children": ["ticket.hoge.fuga.finish"] + }, + { + "key": "ticket.hoge.fuga.finish", + "description": "Leaf operation", + "children": [] + }, + { + "key": "ticket.hoge.unlinked", + "description": "Same prefix without an inclusion edge", + "children": [] + } + ], + "sets": [ + { + "set": "ticket", + "children": [ + { + "kind": "namespace", + "value": { "namespace": "ticket", "keys": ["ticket.hoge"] } + } + ] + } + ] + } + } + ] +} diff --git a/crates/feature-permissions/tests/fixtures/catalog.toml b/crates/feature-permissions/tests/fixtures/catalog.toml new file mode 100644 index 000000000..6537e2721 --- /dev/null +++ b/crates/feature-permissions/tests/fixtures/catalog.toml @@ -0,0 +1,35 @@ +workspace = "workspace-a" + +[[namespaces]] +namespace = "ticket" +feature = "builtin:ticket" +enabled = true + +[[namespaces.declaration.permissions]] +key = "ticket.hoge" +description = "Root operation with explicit children" +children = ["ticket.hoge.fuga"] + +[[namespaces.declaration.permissions]] +key = "ticket.hoge.fuga" +description = "Intermediate operation with an explicit child" +children = ["ticket.hoge.fuga.finish"] + +[[namespaces.declaration.permissions]] +key = "ticket.hoge.fuga.finish" +description = "Leaf operation" +children = [] + +[[namespaces.declaration.permissions]] +key = "ticket.hoge.unlinked" +description = "Same prefix without an inclusion edge" +children = [] + +[[namespaces.declaration.sets]] +set = "ticket" + +[[namespaces.declaration.sets.children]] +kind = "namespace" +[namespaces.declaration.sets.children.value] +namespace = "ticket" +keys = ["ticket.hoge"] diff --git a/crates/feature-permissions/tests/generated_contract.rs b/crates/feature-permissions/tests/generated_contract.rs new file mode 100644 index 000000000..485e43e42 --- /dev/null +++ b/crates/feature-permissions/tests/generated_contract.rs @@ -0,0 +1,9 @@ +//! The generated Web contract is an external artifact, not a second hand-written +//! list of domains. Normal crate tests reject drift from the declaration types. +#[test] +fn checked_in_web_contract_matches_the_payload_free_rust_types() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../web/workspace/src/lib/generated/feature-permissions.generated.ts"); + let saved = std::fs::read_to_string(path).expect("run the generate_typescript example"); + assert_eq!(saved, feature_permissions::typescript()); +} diff --git a/crates/feature-permissions/tests/resolution.rs b/crates/feature-permissions/tests/resolution.rs new file mode 100644 index 000000000..668d73666 --- /dev/null +++ b/crates/feature-permissions/tests/resolution.rs @@ -0,0 +1,912 @@ +//! Core component tests through the public API only. These do not stand in for +//! execution-context/provider integration or actual operation authorization. + +use feature_permissions::*; +use std::collections::BTreeSet; + +fn key(value: &str) -> PermissionKey { + PermissionKey::parse(value).unwrap() +} + +fn namespace(value: &str) -> NamespaceId { + NamespaceId::parse(value).unwrap() +} + +fn set(value: &str) -> PermissionReference { + PermissionReference::Set(PermissionSetId::parse(value).unwrap()) +} + +fn keys(ns: &str, values: &[&str]) -> PermissionReference { + PermissionReference::Namespace(NamespacePermissions { + namespace: namespace(ns), + keys: values.iter().map(|value| key(value)).collect(), + }) +} + +fn assignment(grants: Vec) -> PermissionAssignment { + PermissionAssignment { + grants, + ..PermissionAssignment::default() + } +} + +fn host(workspace: &str) -> PermissionHost { + PermissionHost::new(WorkspaceId::parse(workspace).unwrap()) +} + +fn register( + host: &mut PermissionHost, + identity: &str, + ns: &str, +) -> (FeatureHandle, NamespaceHandle) { + let feature = host + .register_feature(FeatureIdentity::parse(identity).unwrap()) + .unwrap(); + let handle = host.bind_namespace(&feature, namespace(ns)).unwrap(); + (feature, handle) +} + +fn declaration( + definitions: &[(&str, &[&str])], + contributions: &[(&str, Vec)], +) -> NamespaceDeclaration { + NamespaceDeclaration { + permissions: definitions + .iter() + .map(|(name, children)| PermissionDefinition { + key: key(name), + description: format!("Operation {name}"), + children: children.iter().map(|child| key(child)).collect(), + }) + .collect(), + sets: contributions + .iter() + .map(|(name, children)| SetContribution { + set: PermissionSetId::parse(*name).unwrap(), + children: children.clone(), + }) + .collect(), + } +} + +fn assert_keys(resolved: &ResolvedPermissions, expected: &[&str]) { + assert_eq!( + resolved + .projection() + .allowed + .keys() + .cloned() + .collect::>(), + expected.iter().map(|value| key(value)).collect() + ); +} + +fn has_origin(resolved: &ResolvedPermissions, operation: &str, feature: &str, set_name: &str) { + let paths = &resolved.projection().allowed[&key(operation)]; + assert!(!paths.is_empty()); + for path in paths { + assert!(path.contains(&OriginStep::Feature( + FeatureIdentity::parse(feature).unwrap() + ))); + assert!(path.contains(&OriginStep::Set(PermissionSetId::parse(set_name).unwrap()))); + assert_eq!(path.last(), Some(&OriginStep::Key(key(operation)))); + } +} + +#[test] +fn existing_set_holder_tracks_two_builtins_and_trusted_plugin_without_redistribution() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + let (_, workdir) = register(&mut host, "builtin:workdir", "workdir"); + host.declare( + &ticket, + declaration( + &[("ticket.read", &[]), ("ticket.write", &[])], + &[("companion", vec![keys("ticket", &["ticket.read"])])], + ), + ) + .unwrap(); + host.declare( + &workdir, + declaration( + &[("workdir.read", &[])], + &[("companion", vec![keys("workdir", &["workdir.read"])])], + ), + ) + .unwrap(); + // This same stored assignment is reused throughout; no per-subject update. + let holder = assignment(vec![set("companion"), keys("ticket", &["ticket.write"])]); + let before = host.resolve(Some(&holder)); + assert_keys(&before, &["ticket.read", "ticket.write", "workdir.read"]); + + let (plugin_feature, plugin) = register(&mut host, "plugin:external-search@1", "search"); + host.declare( + &plugin, + declaration( + &[("search.query.deep", &[])], + &[("companion", vec![keys("search", &["search.query.deep"])])], + ), + ) + .unwrap(); + let installed = host.resolve(Some(&holder)); + assert_keys( + &installed, + &[ + "ticket.read", + "ticket.write", + "workdir.read", + "search.query.deep", + ], + ); + has_origin(&installed, "ticket.read", "builtin:ticket", "companion"); + has_origin(&installed, "workdir.read", "builtin:workdir", "companion"); + has_origin( + &installed, + "search.query.deep", + "plugin:external-search@1", + "companion", + ); + assert_eq!( + host.allows(&ticket, &before, &key("ticket.read")), + Err(PermissionError::StaleResolution) + ); + + host.set_enabled(&plugin_feature, false).unwrap(); + let disabled = host.resolve(Some(&holder)); + assert_keys(&disabled, &["ticket.read", "ticket.write", "workdir.read"]); + assert_eq!( + disabled.projection().diagnostics, + vec![PermissionDiagnostic::DisabledFeature( + FeatureIdentity::parse("plugin:external-search@1").unwrap() + )] + ); + let direct_plugin = assignment(vec![keys("search", &["search.query.deep"])]); + assert_keys(&host.resolve(Some(&direct_plugin)), &[]); + host.set_enabled(&plugin_feature, true).unwrap(); + assert_keys( + &host.resolve(Some(&holder)), + &[ + "ticket.read", + "ticket.write", + "workdir.read", + "search.query.deep", + ], + ); + host.remove_feature(&plugin_feature).unwrap(); + let removed = host.resolve(Some(&holder)); + assert_keys(&removed, &["ticket.read", "ticket.write", "workdir.read"]); + assert!(removed.projection().diagnostics.is_empty()); + assert!( + !host + .catalog() + .namespaces + .iter() + .any(|ns| ns.namespace == namespace("search")) + ); + assert_eq!( + host.allows(&plugin, &removed, &key("search.query.deep")), + Err(PermissionError::ForeignHandle) + ); +} + +#[test] +fn ownership_comes_from_handles_not_raw_owner_fields_or_namespace_spelling() { + let mut host = host("workspace-a"); + let (builtin, ticket) = register(&mut host, "builtin:ticket", "ticket"); + let (plugin, search) = register(&mut host, "plugin:search", "search"); + let baseline = host.catalog(); + assert_eq!( + host.bind_namespace(&plugin, namespace("ticket")) + .unwrap_err(), + PermissionError::NamespaceCollision(namespace("ticket")) + ); + assert_eq!( + host.register_feature(FeatureIdentity::parse("builtin:ticket").unwrap()) + .unwrap_err(), + PermissionError::FeatureCollision(FeatureIdentity::parse("builtin:ticket").unwrap()) + ); + assert_eq!( + host.declare(&search, declaration(&[("ticket.read", &[])], &[])), + Err(PermissionError::Ownership(key("ticket.read"))) + ); + // The rule is symmetric: builtin identity is not permission to impersonate a Plugin. + assert_eq!( + host.declare(&ticket, declaration(&[("search.query", &[])], &[])), + Err(PermissionError::Ownership(key("search.query"))) + ); + // A set contribution is no alternate way to declare another Feature's keys. + assert_eq!( + host.declare( + &search, + declaration( + &[], + &[("companion", vec![keys("ticket", &["ticket.read"])])] + ), + ), + Err(PermissionError::ForeignHandle) + ); + assert_eq!( + host.declare( + &search, + declaration( + &[], + &[("companion", vec![keys("search", &["ticket.read"])])] + ), + ), + Err(PermissionError::Ownership(key("ticket.read"))) + ); + assert!( + serde_json::from_str::( + r#"{"owner":"builtin:ticket","permissions":[],"sets":[]}"# + ) + .is_err() + ); + assert_eq!(host.catalog(), baseline); + host.remove_feature(&builtin).unwrap(); + let (_, replacement) = register(&mut host, "builtin:ticket", "ticket"); + assert_eq!( + host.declare(&ticket, declaration(&[], &[])), + Err(PermissionError::ForeignHandle) + ); + host.declare(&replacement, declaration(&[("ticket.read", &[])], &[])) + .unwrap(); +} + +#[test] +fn namespace_registration_and_empty_namespace_assignment_do_not_grant_operations() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare(&ticket, declaration(&[("ticket.read", &[])], &[])) + .unwrap(); + for policy in [None, Some(assignment(vec![keys("ticket", &[])]))] { + let resolved = host.resolve(policy.as_ref()); + assert_keys(&resolved, &[]); + assert_eq!( + host.allows(&ticket, &resolved, &key("ticket.read")), + Ok(false) + ); + } +} + +#[test] +fn handles_and_results_are_not_portable_even_between_hosts_with_the_same_workspace_id() { + let mut original = host("workspace-a"); + let (feature, ticket) = register(&mut original, "builtin:ticket", "ticket"); + original + .declare(&ticket, declaration(&[("ticket.read", &[])], &[])) + .unwrap(); + let policy = assignment(vec![keys("ticket", &["ticket.read"])]); + let resolved = original.resolve(Some(&policy)); + let child = original + .issue_delegation(&resolved, &[key("ticket.read")]) + .unwrap(); + let job = original + .issue_job_grant("job-a".into(), &resolved, &[key("ticket.read")]) + .unwrap(); + for workspace in ["workspace-a", "workspace-b"] { + let mut other = host(workspace); + let (_, local) = register(&mut other, "builtin:ticket", "ticket"); + other + .declare(&local, declaration(&[("ticket.read", &[])], &[])) + .unwrap(); + let local_result = other.resolve(Some(&policy)); + let local_child = other + .issue_delegation(&local_result, &[key("ticket.read")]) + .unwrap(); + assert_eq!( + other + .bind_namespace(&feature, namespace("foreign")) + .unwrap_err(), + PermissionError::ForeignHandle + ); + assert_eq!( + other.set_enabled(&feature, false), + Err(PermissionError::ForeignHandle) + ); + assert_eq!( + other.remove_feature(&feature), + Err(PermissionError::ForeignHandle) + ); + assert_eq!( + other.declare(&ticket, declaration(&[], &[])), + Err(PermissionError::ForeignHandle) + ); + assert_eq!( + other.allows(&ticket, &local_result, &key("ticket.read")), + Err(PermissionError::ForeignHandle) + ); + assert_eq!( + other.allows(&local, &resolved, &key("ticket.read")), + Err(PermissionError::ForeignHandle) + ); + assert_eq!( + other + .issue_delegation(&resolved, &[key("ticket.read")]) + .unwrap_err(), + PermissionError::ForeignHandle + ); + assert_eq!( + other + .resolve_limited(&local_result, &local_result, &child) + .unwrap_err(), + PermissionError::ForeignHandle + ); + assert_eq!( + other + .resolve_job("job-a", &local_result, &local_result, &local_child, &job) + .unwrap_err(), + PermissionError::ForeignHandle + ); + } +} + +fn diamond() -> NamespaceDeclaration { + declaration( + &[ + ("ticket.hoge", &["ticket.hoge.fuga", "ticket.branch"]), + ("ticket.hoge.fuga", &["ticket.hoge.fuga.deep"]), + ("ticket.branch", &["ticket.hoge.fuga.deep"]), + ("ticket.hoge.fuga.deep", &[]), + ("ticket.hoge.unrelated", &[]), + ], + &[("companion", vec![keys("ticket", &["ticket.hoge"])])], + ) +} + +#[test] +fn explicit_multilevel_children_keep_distinct_paths_but_never_infer_a_prefix_grant() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare(&ticket, diamond()).unwrap(); + let policy = assignment(vec![set("companion"), set("companion")]); + let resolved = host.resolve(Some(&policy)); + assert_keys( + &resolved, + &[ + "ticket.hoge", + "ticket.hoge.fuga", + "ticket.branch", + "ticket.hoge.fuga.deep", + ], + ); + assert_eq!( + host.allows(&ticket, &resolved, &key("ticket.hoge.unrelated")), + Ok(false) + ); + let paths = &resolved.projection().allowed[&key("ticket.hoge.fuga.deep")]; + assert_eq!( + paths.len(), + 2, + "duplicate grants must not duplicate provenance" + ); + let key_paths: BTreeSet> = paths + .iter() + .map(|path| { + path.iter() + .filter_map(|step| match step { + OriginStep::Key(key) => Some(key.clone()), + _ => None, + }) + .collect() + }) + .collect(); + assert_eq!( + key_paths, + BTreeSet::from([ + vec![ + key("ticket.hoge"), + key("ticket.hoge.fuga"), + key("ticket.hoge.fuga.deep") + ], + vec![ + key("ticket.hoge"), + key("ticket.branch"), + key("ticket.hoge.fuga.deep") + ], + ]) + ); + has_origin( + &resolved, + "ticket.hoge.fuga.deep", + "builtin:ticket", + "companion", + ); + assert_eq!( + resolved.projection(), + host.resolve(Some(&policy)).projection(), + "resolution and provenance ordering must be deterministic" + ); +} + +#[test] +fn invalid_graph_updates_reject_atomically_without_retiring_a_valid_result() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare(&ticket, diamond()).unwrap(); + let baseline = host.catalog(); + let policy = assignment(vec![set("companion")]); + let valid = host.resolve(Some(&policy)); + let mut dangling_removal = diamond(); + dangling_removal + .permissions + .retain(|definition| definition.key != key("ticket.hoge.fuga.deep")); + let invalid = [ + (dangling_removal, "unknown_key"), + (declaration(&[("ticket.a", &["ticket.a"])], &[]), "cycle"), + ( + declaration( + &[("ticket.a", &["ticket.b"]), ("ticket.b", &["ticket.a"])], + &[], + ), + "cycle", + ), + ( + declaration(&[("ticket.a", &["ticket.missing"])], &[]), + "unknown_key", + ), + ( + declaration(&[], &[("a", vec![set("missing")])]), + "unknown_set", + ), + ( + declaration(&[], &[("a", vec![set("b")]), ("b", vec![set("a")])]), + "cycle", + ), + ( + declaration(&[("ticket.a", &[]), ("ticket.a", &[])], &[]), + "duplicate", + ), + ( + declaration( + &[("ticket.a", &["ticket.b", "ticket.b"]), ("ticket.b", &[])], + &[], + ), + "duplicate", + ), + ( + declaration(&[], &[("a", vec![]), ("a", vec![])]), + "duplicate", + ), + ( + declaration(&[], &[("a", vec![keys("ticket", &["ticket.missing"])])]), + "unknown_key", + ), + ]; + for (candidate, expected) in invalid { + let error = host.declare(&ticket, candidate).unwrap_err(); + assert!( + matches!( + (&error, expected), + (PermissionError::Cycle(_), "cycle") + | (PermissionError::UnknownKey(_), "unknown_key") + | (PermissionError::UnknownSet(_), "unknown_set") + | (PermissionError::Duplicate(_), "duplicate") + ), + "unexpected rejection: {error:?} for {expected}" + ); + assert_eq!(host.catalog(), baseline); + assert_eq!( + host.allows(&ticket, &valid, &key("ticket.hoge.fuga.deep")), + Ok(true) + ); + assert_eq!(host.resolve(Some(&policy)).projection(), valid.projection()); + } +} + +#[test] +fn changing_and_removing_children_withdraws_old_permissions_and_rejects_stale_results() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare(&ticket, diamond()).unwrap(); + let policy = assignment(vec![keys("ticket", &["ticket.hoge"])]); + let old = host.resolve(Some(&policy)); + host.declare( + &ticket, + declaration(&[("ticket.hoge", &[]), ("ticket.hoge.fuga", &[])], &[]), + ) + .unwrap(); + assert_eq!( + host.allows(&ticket, &old, &key("ticket.hoge.fuga.deep")), + Err(PermissionError::StaleResolution) + ); + let fresh = host.resolve(Some(&policy)); + assert_keys(&fresh, &["ticket.hoge"]); + assert_eq!( + host.allows(&ticket, &fresh, &key("ticket.hoge.fuga")), + Ok(false) + ); + let withdrawn = assignment(vec![keys("ticket", &["ticket.hoge.fuga.deep"])]); + let withdrawn_result = host.resolve(Some(&withdrawn)); + assert_keys(&withdrawn_result, &[]); + assert_eq!( + withdrawn_result.projection().diagnostics, + vec![PermissionDiagnostic::UnknownKey(key( + "ticket.hoge.fuga.deep" + ))] + ); + host.declare(&ticket, declaration(&[], &[])).unwrap(); + assert_keys(&host.resolve(Some(&policy)), &[]); +} + +#[test] +fn exclusions_expand_children_and_win_over_all_paths_without_reviving_defaults() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare(&ticket, diamond()).unwrap(); + let mut policy = assignment(vec![ + set("companion"), + keys("ticket", &["ticket.hoge.fuga.deep"]), + ]); + policy.exclusions = vec![keys("ticket", &["ticket.hoge.fuga"])]; + let resolved = host.resolve(Some(&policy)); + assert_keys(&resolved, &["ticket.hoge", "ticket.branch"]); + assert_eq!( + resolved.projection().diagnostics, + vec![ + PermissionDiagnostic::Excluded(key("ticket.hoge.fuga")), + PermissionDiagnostic::Excluded(key("ticket.hoge.fuga.deep")), + ] + ); + // A child request expands through the same graph and cannot bypass exclusion. + assert_eq!( + host.issue_delegation(&resolved, &[key("ticket.hoge")]) + .unwrap_err(), + PermissionError::DelegationExceeded(key("ticket.hoge.fuga")) + ); + policy.exclusions = vec![set("companion")]; + assert_keys(&host.resolve(Some(&policy)), &[]); + policy.revoked = true; + let revoked = host.resolve(Some(&policy)); + assert_keys(&revoked, &[]); + assert_eq!( + revoked.projection().diagnostics, + vec![PermissionDiagnostic::Revoked] + ); + let unset = host.resolve(None); + assert_keys(&unset, &[]); + assert_eq!( + unset.projection().diagnostics, + vec![PermissionDiagnostic::Unset] + ); + let empty = host.resolve(Some(&PermissionAssignment::default())); + assert_keys(&empty, &[]); + assert!(empty.projection().diagnostics.is_empty()); +} + +#[test] +fn invalid_assignment_references_deny_the_whole_assignment_with_deterministic_diagnostics() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare(&ticket, diamond()).unwrap(); + for (invalid, diagnostic) in [ + ( + set("missing"), + PermissionDiagnostic::UnknownSet(PermissionSetId::parse("missing").unwrap()), + ), + ( + keys("absent", &[]), + PermissionDiagnostic::UnknownNamespace(namespace("absent")), + ), + ( + keys("ticket", &["ticket.missing"]), + PermissionDiagnostic::UnknownKey(key("ticket.missing")), + ), + ( + keys("ticket", &["search.query"]), + PermissionDiagnostic::UnknownKey(key("search.query")), + ), + ] { + for as_exclusion in [false, true] { + let mut policy = assignment(vec![set("companion")]); + if as_exclusion { + policy.exclusions = vec![invalid.clone(), invalid.clone()]; + } else { + policy.grants.extend([invalid.clone(), invalid.clone()]); + } + let resolved = host.resolve(Some(&policy)); + assert_keys(&resolved, &[]); + assert_eq!(resolved.projection().diagnostics, vec![diagnostic.clone()]); + assert_eq!( + resolved.projection(), + host.resolve(Some(&policy)).projection() + ); + } + } +} + +#[test] +fn removed_feature_leaves_dangling_children_diagnostic_instead_of_using_cached_permissions() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + let (plugin, search) = register(&mut host, "plugin:search", "search"); + host.declare(&search, declaration(&[("search.query", &[])], &[])) + .unwrap(); + host.declare( + &ticket, + declaration(&[("ticket.read", &["search.query"])], &[]), + ) + .unwrap(); + let policy = assignment(vec![keys("ticket", &["ticket.read"])]); + let old = host.resolve(Some(&policy)); + assert_keys(&old, &["ticket.read", "search.query"]); + host.remove_feature(&plugin).unwrap(); + assert_eq!( + host.allows(&ticket, &old, &key("ticket.read")), + Err(PermissionError::StaleResolution) + ); + let fresh = host.resolve(Some(&policy)); + assert_keys(&fresh, &[]); + assert_eq!( + fresh.projection().diagnostics, + vec![PermissionDiagnostic::UnknownKey(key("search.query"))] + ); +} + +#[test] +fn feature_reregistration_does_not_revive_old_child_or_job_grants_for_identical_keys() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare( + &ticket, + declaration( + &[("ticket.read", &[])], + &[("companion", vec![keys("ticket", &["ticket.read"])])], + ), + ) + .unwrap(); + let (plugin, search) = register(&mut host, "plugin:search", "search"); + let plugin_declaration = declaration( + &[("search.query", &[])], + &[("companion", vec![keys("search", &["search.query"])])], + ); + host.declare(&search, plugin_declaration.clone()).unwrap(); + let holder = assignment(vec![set("companion")]); + let requested = [key("ticket.read"), key("search.query")]; + let original = host.resolve(Some(&holder)); + let old_child = host.issue_delegation(&original, &requested).unwrap(); + let old_job = host + .issue_job_grant("job-a".into(), &original, &requested) + .unwrap(); + assert_keys( + &host + .resolve_job("job-a", &original, &original, &old_child, &old_job) + .unwrap(), + &["ticket.read", "search.query"], + ); + + host.remove_feature(&plugin).unwrap(); + let removed = host.resolve(Some(&holder)); + assert_keys(&removed, &["ticket.read"]); + assert_keys( + &host + .resolve_limited(&removed, &removed, &old_child) + .unwrap(), + &["ticket.read"], + ); + // Reuse every public identifier and declaration, but not the registration. + let (_, replacement) = register(&mut host, "plugin:search", "search"); + host.declare(&replacement, plugin_declaration).unwrap(); + let current = host.resolve(Some(&holder)); + assert_keys(¤t, &["ticket.read", "search.query"]); + assert_eq!( + host.allows(&replacement, ¤t, &key("search.query")), + Ok(true) + ); + assert_eq!( + host.allows(&replacement, &original, &key("search.query")), + Err(PermissionError::StaleResolution) + ); + // The key snapshot still includes search.query; its old registration is the + // reason it cannot authorize the replacement. Unrelated Feature keys survive. + assert_eq!(old_child.keys(), &BTreeSet::from(requested.clone())); + assert_keys( + &host + .resolve_limited(¤t, ¤t, &old_child) + .unwrap(), + &["ticket.read"], + ); + let fresh_child = host.issue_delegation(¤t, &requested).unwrap(); + let fresh_job = host + .issue_job_grant("job-a".into(), ¤t, &requested) + .unwrap(); + // A fresh child isolates the old Job token: the old child cannot mask a + // missing registration check in JobGrant. Conversely use a fresh Job below. + assert_keys( + &host + .resolve_job("job-a", ¤t, ¤t, &fresh_child, &old_job) + .unwrap(), + &["ticket.read"], + ); + assert_keys( + &host + .resolve_job("job-a", ¤t, ¤t, &old_child, &fresh_job) + .unwrap(), + &["ticket.read"], + ); + // Positive control: reissuance, not identifier reuse, grants the new operation. + assert_keys( + &host + .resolve_job("job-a", ¤t, ¤t, &fresh_child, &fresh_job) + .unwrap(), + &["ticket.read", "search.query"], + ); +} + +#[test] +fn issued_child_and_job_keys_are_frozen_across_set_and_children_expansion() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare( + &ticket, + declaration( + &[ + ("ticket.hoge", &["ticket.hoge.read"]), + ("ticket.hoge.read", &[]), + ("ticket.hoge.write", &[]), + ], + &[("companion", vec![keys("ticket", &["ticket.hoge"])])], + ), + ) + .unwrap(); + let holder = assignment(vec![set("companion")]); + let old = host.resolve(Some(&holder)); + let child = host.issue_delegation(&old, &[key("ticket.hoge")]).unwrap(); + let job = host + .issue_job_grant("job-a".into(), &old, &[key("ticket.hoge")]) + .unwrap(); + let narrow_job = host + .issue_job_grant("job-b".into(), &old, &[key("ticket.hoge.read")]) + .unwrap(); + let frozen = BTreeSet::from([key("ticket.hoge"), key("ticket.hoge.read")]); + assert_eq!(child.keys(), &frozen); + + host.declare( + &ticket, + declaration( + &[ + ("ticket.hoge", &["ticket.hoge.read", "ticket.hoge.write"]), + ("ticket.hoge.read", &[]), + ("ticket.hoge.write", &[]), + ], + &[("companion", vec![keys("ticket", &["ticket.hoge"])])], + ), + ) + .unwrap(); + let (_, plugin) = register(&mut host, "plugin:search", "search"); + host.declare( + &plugin, + declaration( + &[("search.query", &[])], + &[("companion", vec![keys("search", &["search.query"])])], + ), + ) + .unwrap(); + assert_eq!( + host.issue_delegation(&old, &[key("ticket.hoge")]) + .unwrap_err(), + PermissionError::StaleResolution + ); + assert_eq!( + host.resolve_limited(&old, &old, &child).unwrap_err(), + PermissionError::StaleResolution + ); + let current = host.resolve(Some(&holder)); + assert_keys( + ¤t, + &[ + "ticket.hoge", + "ticket.hoge.read", + "ticket.hoge.write", + "search.query", + ], + ); + assert_eq!(child.keys(), &frozen); + let limited = host.resolve_limited(¤t, ¤t, &child).unwrap(); + assert_keys(&limited, &["ticket.hoge", "ticket.hoge.read"]); + assert_eq!( + host.resolve_limited(¤t, &old, &child).unwrap_err(), + PermissionError::StaleResolution + ); + assert_eq!( + host.resolve_limited(&old, ¤t, &child).unwrap_err(), + PermissionError::StaleResolution + ); + assert_eq!( + host.resolve_job("job-a", ¤t, &old, &child, &job) + .unwrap_err(), + PermissionError::StaleResolution + ); + let current_child = host + .issue_delegation(¤t, &[key("ticket.hoge"), key("search.query")]) + .unwrap(); + assert_keys( + &host + .resolve_job("job-a", ¤t, ¤t, ¤t_child, &job) + .unwrap(), + &["ticket.hoge", "ticket.hoge.read"], + ); + assert_keys( + &host + .resolve_job("job-b", ¤t, ¤t, ¤t_child, &narrow_job) + .unwrap(), + &["ticket.hoge.read"], + ); + assert_eq!( + host.resolve_job("another-job", ¤t, ¤t, &child, &job) + .unwrap_err(), + PermissionError::WrongJob + ); +} + +#[test] +fn current_parent_execution_delegation_and_explicit_revocation_each_limit_children_and_jobs() { + let mut host = host("workspace-a"); + let (_, ticket) = register(&mut host, "builtin:ticket", "ticket"); + host.declare( + &ticket, + declaration(&[("ticket.read", &[]), ("ticket.write", &[])], &[]), + ) + .unwrap(); + let full_policy = assignment(vec![keys("ticket", &["ticket.read", "ticket.write"])]); + let narrow_policy = assignment(vec![keys("ticket", &["ticket.read"])]); + let revoked_policy = PermissionAssignment { + revoked: true, + ..full_policy.clone() + }; + let full = host.resolve(Some(&full_policy)); + let narrow = host.resolve(Some(&narrow_policy)); + let revoked = host.resolve(Some(&revoked_policy)); + let mut child = host + .issue_delegation(&full, &[key("ticket.read"), key("ticket.write")]) + .unwrap(); + let mut job = host + .issue_job_grant( + "job-a".into(), + &full, + &[key("ticket.read"), key("ticket.write")], + ) + .unwrap(); + // Execution permission alone is not delegation authority (and vice versa). + for (execution, delegation, expected) in [ + (&full, &narrow, vec!["ticket.read"]), + (&narrow, &full, vec!["ticket.read"]), + (&full, &revoked, vec![]), + (&revoked, &full, vec![]), + ] { + assert_keys( + &host.resolve_limited(execution, delegation, &child).unwrap(), + &expected, + ); + assert_keys( + &host + .resolve_job("job-a", execution, delegation, &child, &job) + .unwrap(), + &expected, + ); + } + assert_eq!( + host.issue_delegation(&narrow, &[key("ticket.write")]) + .unwrap_err(), + PermissionError::DelegationExceeded(key("ticket.write")) + ); + assert_eq!( + host.issue_delegation(&full, &[key("ticket.missing")]) + .unwrap_err(), + PermissionError::UnknownKey(key("ticket.missing")) + ); + job.revoke(); + assert_eq!( + host.resolve_job("job-a", &full, &full, &child, &job) + .unwrap_err(), + PermissionError::RevokedGrant + ); + assert_keys( + &host.resolve_limited(&full, &full, &child).unwrap(), + &["ticket.read", "ticket.write"], + ); + child.revoke(); + assert_eq!( + host.resolve_limited(&full, &full, &child).unwrap_err(), + PermissionError::RevokedGrant + ); +} diff --git a/crates/feature-permissions/tests/wire_contract.rs b/crates/feature-permissions/tests/wire_contract.rs new file mode 100644 index 000000000..cd012dfed --- /dev/null +++ b/crates/feature-permissions/tests/wire_contract.rs @@ -0,0 +1,287 @@ +//! Public JSON/TOML boundary tests using the production Serde DTOs and shared Web +//! fixtures. No resolver, Host, storage, or authentication boundary is replaced or +//! exercised here. Run with `cargo test -p feature-permissions --test wire_contract`. + +use feature_permissions::{ + NamespaceCatalog, NamespaceDeclaration, NamespacePermissions, PermissionAssignment, + PermissionCatalog, PermissionDefinition, PermissionReference, SetContribution, + ValidatePermissionsRequest, +}; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use std::fmt::Debug; + +const ASSIGNMENT_JSON: &str = include_str!("fixtures/assignment.json"); +const ASSIGNMENT_TOML: &str = include_str!("fixtures/assignment.toml"); +const CATALOG_JSON: &str = include_str!("fixtures/catalog.json"); +const CATALOG_TOML: &str = include_str!("fixtures/catalog.toml"); + +fn paired_fixture(json: &str, toml: &str) -> T +where + T: DeserializeOwned + Serialize + PartialEq + Debug, +{ + let from_json: T = serde_json::from_str(json).expect("valid JSON fixture"); + let from_toml: T = toml::from_str(toml).expect("valid TOML fixture"); + assert_eq!( + from_json, from_toml, + "text formats must have the same meaning" + ); + assert_eq!( + serde_json::to_value(&from_json).unwrap(), + serde_json::from_str::(json).unwrap(), + "serialization must not drop or synthesize wire fields" + ); + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&from_toml).unwrap()).unwrap(), + from_json + ); + assert_eq!( + toml::from_str::(&toml::to_string(&from_json).unwrap()).unwrap(), + from_toml + ); + from_json +} + +fn reject_in_both_formats(rule: &str, input: Value) { + assert!( + serde_json::from_value::(input.clone()).is_err(), + "JSON accepted {rule}: {input}" + ); + let text = toml::to_string(&input).expect("rejection case must be representable as TOML"); + assert!( + toml::from_str::(&text).is_err(), + "TOML accepted {rule}: {text}" + ); +} + +fn reject_authority_and_payload_fields(input: Value) { + for (field, value) in [ + ("payload", json!({ "path": "/untrusted" })), + ("owner", json!("other-feature")), + ("workspace", json!("other-workspace")), + ] { + let mut modified = input.clone(); + modified + .as_object_mut() + .unwrap() + .insert(field.into(), value); + reject_in_both_formats::(field, modified); + } +} + +#[test] +fn assignment_text_formats_preserve_discriminants_and_multisegment_keys() { + let assignment: PermissionAssignment = paired_fixture(ASSIGNMENT_JSON, ASSIGNMENT_TOML); + // The same spelling is intentional: the tag, not a prefix, chooses the kind. + assert!( + matches!(&assignment.grants[0], PermissionReference::Set(id) if id.as_str() == "ticket") + ); + let PermissionReference::Namespace(namespace) = &assignment.grants[1] else { + panic!("namespace reference lost its discriminant"); + }; + assert_eq!(namespace.namespace.as_str(), "ticket"); + assert_eq!( + namespace + .keys + .iter() + .map(|key| key.as_str()) + .collect::>(), + ["ticket.hoge", "ticket.hoge.fuga.finish"] + ); +} + +#[test] +fn namespace_catalog_roundtrip_preserves_only_explicit_multilevel_children() { + let catalog: PermissionCatalog = paired_fixture(CATALOG_JSON, CATALOG_TOML); + let namespace = &catalog.namespaces[0]; + // Also roundtrip the standalone management DTO, not only its outer envelope. + let standalone: NamespaceCatalog = + serde_json::from_value(serde_json::to_value(namespace).unwrap()).unwrap(); + assert_eq!(&standalone, namespace); + let permissions = &standalone.declaration.permissions; + let edges: Vec<_> = permissions + .iter() + .map(|permission| { + ( + permission.key.as_str(), + permission + .children + .iter() + .map(|key| key.as_str()) + .collect::>(), + ) + }) + .collect(); + assert_eq!( + edges, + [ + ("ticket.hoge", vec!["ticket.hoge.fuga"]), + ("ticket.hoge.fuga", vec!["ticket.hoge.fuga.finish"]), + ("ticket.hoge.fuga.finish", vec![]), + ("ticket.hoge.unlinked", vec![]), + ] + ); + assert_eq!( + serde_json::to_value(&standalone.declaration.sets[0]).unwrap(), + json!({ + "set": "ticket", + "children": [{"kind": "namespace", "value": {"namespace": "ticket", "keys": ["ticket.hoge"]}}] + }) + ); +} + +#[test] +fn explicit_empty_and_revocation_survive_without_defaults() { + for input in [ + json!({"grants": [], "exclusions": [], "revoked": false}), + json!({"grants": [], "exclusions": [], "revoked": true}), + json!({ + "grants": [{"kind": "namespace", "value": {"namespace": "ticket", "keys": []}}], + "exclusions": [], + "revoked": false + }), + ] { + paired_fixture::( + &serde_json::to_string(&input).unwrap(), + &toml::to_string(&input).unwrap(), + ); + } + for missing in ["grants", "exclusions", "revoked"] { + let mut input = json!({"grants": [], "exclusions": [], "revoked": false}); + input.as_object_mut().unwrap().remove(missing); + reject_in_both_formats::(missing, input); + } +} + +#[test] +fn reference_kind_and_shape_must_match_in_json_and_toml() { + for (rule, input) in [ + ("unknown kind", json!({"kind": "all", "value": "ticket"})), + ( + "case-sensitive kind", + json!({"kind": "Set", "value": "ticket"}), + ), + ("missing kind", json!({"value": "ticket"})), + ("missing value", json!({"kind": "set"})), + ( + "set with namespace shape", + json!({"kind": "set", "value": {"namespace": "ticket", "keys": []}}), + ), + ( + "namespace with set shape", + json!({"kind": "namespace", "value": "ticket"}), + ), + ( + "namespace missing keys", + json!({"kind": "namespace", "value": {"namespace": "ticket"}}), + ), + ( + "keys are not a scalar", + json!({"kind": "namespace", "value": {"namespace": "ticket", "keys": "ticket.hoge"}}), + ), + ( + "keys carry no data", + json!({"kind": "namespace", "value": {"namespace": "ticket", "keys": [{"key": "ticket.hoge", "payload": {}}]}}), + ), + ] { + reject_in_both_formats::(rule, input); + } +} + +#[test] +fn external_identifiers_reject_invalid_segments_without_truncation() { + for invalid in ["", "Ticket", "ticket.read", "ticket read", "*", "1ticket"] { + reject_in_both_formats::( + "invalid set identifier", + json!({"kind": "set", "value": invalid}), + ); + reject_in_both_formats::( + "invalid namespace identifier", + json!({"namespace": invalid, "keys": []}), + ); + } + for invalid in [ + "ticket", + "ticket..read", + ".ticket.read", + "ticket.read.", + "ticket.Read", + "ticket.*", + "ticket.read path", + ] { + reject_in_both_formats::( + "invalid assigned key", + json!({"namespace": "ticket", "keys": [invalid]}), + ); + reject_in_both_formats::( + "invalid declared key", + json!({"key": invalid, "description": "", "children": []}), + ); + reject_in_both_formats::( + "invalid child key", + json!({"key": "ticket.hoge", "description": "", "children": [invalid]}), + ); + } + for (field, invalid) in [ + ("workspace", ""), + ("workspace", "other workspace"), + ("feature", ""), + ("feature", "other\nfeature"), + ] { + let mut catalog: Value = serde_json::from_str(CATALOG_JSON).unwrap(); + if field == "workspace" { + catalog["workspace"] = json!(invalid); + } else { + catalog["namespaces"][0]["feature"] = json!(invalid); + } + reject_in_both_formats::("invalid catalog identity", catalog); + } +} + +#[test] +fn grant_inputs_reject_payload_and_caller_supplied_authority_at_every_layer() { + let assignment: Value = serde_json::from_str(ASSIGNMENT_JSON).unwrap(); + reject_authority_and_payload_fields::(assignment.clone()); + for reference in assignment["grants"].as_array().unwrap() { + reject_authority_and_payload_fields::(reference.clone()); + } + reject_authority_and_payload_fields::( + assignment["grants"][1]["value"].clone(), + ); + let request = json!({"assignment": assignment}); + let parsed: ValidatePermissionsRequest = serde_json::from_value(request.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), request); + reject_authority_and_payload_fields::(request.clone()); + let mut nested = request; + nested["assignment"]["workspace"] = json!("other-workspace"); + reject_in_both_formats::("nested workspace spoof", nested); +} + +#[test] +fn assignment_fields_reject_untyped_grants_and_nonboolean_revocation() { + for (field, invalid) in [ + ("grants", json!("ticket.hoge")), + ("grants", json!([{"ticket.hoge": true}])), + ("exclusions", json!({})), + ("revoked", json!("false")), + ] { + let mut input = json!({"grants": [], "exclusions": [], "revoked": false}); + input[field] = invalid; + reject_in_both_formats::(field, input); + } +} + +#[test] +fn declaration_inputs_reject_payload_and_caller_supplied_authority_at_every_layer() { + let catalog: Value = serde_json::from_str(CATALOG_JSON).unwrap(); + let declaration = &catalog["namespaces"][0]["declaration"]; + reject_authority_and_payload_fields::(declaration.clone()); + reject_authority_and_payload_fields::( + declaration["permissions"][0].clone(), + ); + reject_authority_and_payload_fields::(declaration["sets"][0].clone()); + // A catalog reports authenticated identities; declaration inputs cannot supply them. + let mut input = declaration.clone(); + input["feature"] = json!("other-feature"); + reject_in_both_formats::("feature identity injection", input); +} diff --git a/crates/server-api/examples/generate_feature_permissions_openapi.rs b/crates/server-api/examples/generate_feature_permissions_openapi.rs new file mode 100644 index 000000000..f213ba2e4 --- /dev/null +++ b/crates/server-api/examples/generate_feature_permissions_openapi.rs @@ -0,0 +1,7 @@ +fn main() -> Result<(), Box> { + print!( + "{}", + server_api::feature_permissions::permission_contract_openapi()?.to_json()? + ); + Ok(()) +} diff --git a/crates/server-api/src/feature_permissions.rs b/crates/server-api/src/feature_permissions.rs new file mode 100644 index 000000000..48fbb8df4 --- /dev/null +++ b/crates/server-api/src/feature_permissions.rs @@ -0,0 +1,140 @@ +//! Declaration/resolution management transport only. These generated routes are +//! intentionally not mounted by this crate. The Workspace authority must bind +//! authenticated context, read current policy and authorize management access +//! before implementing/mounting them; no input can issue a namespace handle. + +use crate::{RepositoryApiError, ServerRequestContext}; +use feature_permissions::types::{ + PermissionCatalog, PermissionResolution, ValidatePermissionsRequest, +}; + +// Transparent wrappers keep the wire representation identical while the REST +// owner, not the resolver crate, attests to OpenAPI/Serde agreement. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(transparent)] +pub struct PermissionCatalogResponse(pub PermissionCatalog); +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(transparent)] +pub struct PermissionValidationRequest(pub ValidatePermissionsRequest); +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(transparent)] +pub struct PermissionResolutionResponse(pub PermissionResolution); +impl api_macros::openapi::OpenApiSchema for PermissionCatalogResponse {} +impl api_macros::openapi::OpenApiSchema for PermissionValidationRequest {} +impl api_macros::openapi::OpenApiSchema for PermissionResolutionResponse {} + +#[api_macros::api(reqwest, axum, openapi)] +pub trait FeaturePermissionsApi { + #[get("/api/w/{workspace_id}/permission-catalog", status = 200, error_status = 403, additional_error_statuses = [400, 404, 500], bearer_auth = true, browser_auth = true)] + async fn permission_catalog( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + ) -> Result; + + #[post("/api/w/{workspace_id}/permissions/validate", status = 200, error_status = 403, additional_error_statuses = [400, 404, 500], bearer_auth = true, browser_auth = true, normalize_body_errors = true)] + async fn validate_permissions( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: PermissionValidationRequest, + ) -> Result; +} + +/// Deterministic standalone contract. Its source identity hashes the normalized +/// schema, not a deployment URL or another API's unrelated route inventory. +pub fn permission_contract_openapi() +-> Result { + use sha2::{Digest, Sha256}; + let document = |source_digest: &str| { + feature_permissions_api_openapi(api_macros::openapi::OpenApiInfo { + title: "Feature permission declaration contract", + version: env!("CARGO_PKG_VERSION"), + source_digest, + }) + }; + let normalized = + document("sha256:0000000000000000000000000000000000000000000000000000000000000000")? + .to_json()?; + let digest: String = Sha256::digest(normalized.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + document(&format!("sha256:{digest}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use feature_permissions::*; + + /// Wire contract + actual resolver, not a mounted HTTP authorization test. + #[test] + fn management_wire_preserves_tagged_assignments_and_explicit_children_into_resolution() { + let catalog: PermissionCatalog = serde_json::from_str(include_str!( + "../../feature-permissions/tests/fixtures/catalog.json" + )) + .unwrap(); + let mut host = PermissionHost::new(catalog.workspace.clone()); + for namespace in catalog.namespaces { + let owner = host.register_feature(namespace.feature).unwrap(); + let handle = host.bind_namespace(&owner, namespace.namespace).unwrap(); + host.declare(&handle, namespace.declaration).unwrap(); + } + let assignment: PermissionAssignment = serde_json::from_str(include_str!( + "../../feature-permissions/tests/fixtures/assignment.json" + )) + .unwrap(); + let request = PermissionValidationRequest(ValidatePermissionsRequest { assignment }); + let request: PermissionValidationRequest = + serde_json::from_slice(&serde_json::to_vec(&request).unwrap()).unwrap(); + let result = host.resolve(Some(&request.0.assignment)); + let response: PermissionResolution = + serde_json::from_slice(&serde_json::to_vec(result.projection()).unwrap()).unwrap(); + assert!( + response + .allowed + .contains_key(&PermissionKey::parse("ticket.hoge.fuga.finish").unwrap()) + ); + assert!( + !response + .allowed + .contains_key(&PermissionKey::parse("ticket.hoge.unlinked").unwrap()) + ); + assert!( + serde_json::from_value::( + serde_json::json!({"assignment":request.0.assignment, "workspace":"other"}) + ) + .is_err() + ); + } + + #[test] + fn generated_management_contract_publishes_payload_free_schema_and_context_bound_routes() { + let document = permission_contract_openapi().unwrap(); + let document: serde_json::Value = + serde_json::from_str(&document.to_json().unwrap()).unwrap(); + let checked_in: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../openapi/feature-permissions-api.json"), + ) + .expect("run generate_feature_permissions_openapi"), + ) + .unwrap(); + assert_eq!(document, checked_in); + let paths = document["paths"].as_object().unwrap(); + assert!(paths.contains_key("/api/w/{workspace_id}/permission-catalog")); + assert!(paths.contains_key("/api/w/{workspace_id}/permissions/validate")); + let schema = + serde_json::to_value(schemars::schema_for!(ValidatePermissionsRequest)).unwrap(); + let serialized = serde_json::to_string(&schema).unwrap(); + assert!(serialized.contains("namespace")); + assert!(serialized.contains("set")); + assert!(!serialized.contains("payload")); + assert_eq!(schema["additionalProperties"], false); + } +} diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 3577ccf8e..01a9f9988 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -8,6 +8,7 @@ pub use api_macros::reqwest as client_support; pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation}; pub mod authorization; mod chat; +pub mod feature_permissions; pub use chat::*; mod subject_body; pub use subject_body::*; diff --git a/crates/worker/src/feature.rs b/crates/worker/src/feature.rs index 0f03a3c42..6cd814fb7 100644 --- a/crates/worker/src/feature.rs +++ b/crates/worker/src/feature.rs @@ -41,6 +41,8 @@ use background::{ FeatureBackgroundTaskRegistryBuilder, }; +pub mod permissions; + /// Stable source-qualified identifier for a feature module. #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub struct FeatureId(String); @@ -718,6 +720,16 @@ impl FeatureDescriptor { /// Feature module contribution boundary. pub trait FeatureModule: Send + Sync { fn descriptor(&self) -> FeatureDescriptor; + + /// Declare operation keys through the Host-bound owner registrar. This does + /// not activate the Feature or grant permissions to any execution subject. + fn declare_permissions( + &self, + _registrar: &mut permissions::FeaturePermissionRegistrar<'_>, + ) -> Result<(), FeatureInstallError> { + Ok(()) + } + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError>; } @@ -2114,6 +2126,15 @@ impl FeatureRegistryBuilder { .collect() } + /// Build a declaration catalog for the authenticated Workspace, independently + /// of Worker policy and activation. Only trusted selected modules run here. + pub fn permission_registry( + &self, + workspace: feature_permissions::WorkspaceId, + ) -> Result { + permissions::FeaturePermissionRegistry::from_builder(self, workspace) + } + pub fn plan(&self) -> Result { build_feature_install_plan(&self.descriptors()) } diff --git a/crates/worker/src/feature/permissions.rs b/crates/worker/src/feature/permissions.rs new file mode 100644 index 000000000..ec9bdbd58 --- /dev/null +++ b/crates/worker/src/feature/permissions.rs @@ -0,0 +1,148 @@ +//! Declaration-only bridge from the selected, trusted Feature modules to the +//! payload-free permission catalog. Actual policy adoption and operation checks +//! belong to the Workspace authority, not Feature activation. +//! +//! The registry builder obtains each owner from the registered module descriptor. +//! A module receives only its registrar; neither wire data nor a module-selected +//! owner string can change that registrar's registration identity. + +use super::{FeatureId, FeatureInstallError, FeatureModule, FeatureRegistryBuilder}; +use feature_permissions::{ + FeatureHandle, FeatureIdentity, NamespaceDeclaration, NamespaceHandle, NamespaceId, + PermissionHost, WorkspaceId, +}; +use std::collections::BTreeMap; + +pub struct FeaturePermissionRegistrar<'a> { + host: &'a mut PermissionHost, + feature: FeatureHandle, +} + +impl FeaturePermissionRegistrar<'_> { + /// The returned handle may be retained by the Feature for exact-key queries. + /// It is not a grant and cannot query another registered namespace. + pub fn namespace( + &mut self, + namespace: NamespaceId, + ) -> Result { + self.host + .bind_namespace(&self.feature, namespace) + .map_err(invalid) + } + + pub fn declare( + &mut self, + namespace: &NamespaceHandle, + declaration: NamespaceDeclaration, + ) -> Result<(), FeatureInstallError> { + if !namespace.is_owned_by(&self.feature) { + return Err(invalid( + "namespace handle belongs to another Feature registration", + )); + } + self.host.declare(namespace, declaration).map_err(invalid) + } +} + +pub struct FeaturePermissionRegistry { + host: PermissionHost, + features: BTreeMap, +} + +fn invalid(error: impl std::fmt::Display) -> FeatureInstallError { + FeatureInstallError::InvalidDescriptor(error.to_string()) +} + +impl FeaturePermissionRegistry { + /// Called by the trusted Host after selecting builtin/trusted Plugin modules. + /// Registration alone grants nothing to a Subject or Worker. A failed + /// declaration drops the whole candidate registry, never a partial catalog. + pub(super) fn from_builder( + builder: &FeatureRegistryBuilder, + workspace: WorkspaceId, + ) -> Result { + builder.plan().map_err(invalid)?; + let mut result = Self { + host: PermissionHost::new(workspace), + features: BTreeMap::new(), + }; + for module in &builder.modules { + result.register(module.as_ref())?; + } + Ok(result) + } + + fn register(&mut self, module: &dyn FeatureModule) -> Result<(), FeatureInstallError> { + let id = module.descriptor().id; + let identity = FeatureIdentity::parse(id.as_str()).map_err(invalid)?; + let feature = self.host.register_feature(identity).map_err(invalid)?; + module.declare_permissions(&mut FeaturePermissionRegistrar { + host: &mut self.host, + feature: feature.clone(), + })?; + self.features.insert(id, feature); + Ok(()) + } + + pub fn host(&self) -> &PermissionHost { + &self.host + } + + /// Replace a declaration only through its already bound namespace handle. + /// Failed updates retain the prior catalog and its usable resolutions. + pub fn update( + &mut self, + namespace: &NamespaceHandle, + declaration: NamespaceDeclaration, + ) -> Result<(), FeatureInstallError> { + self.host.declare(namespace, declaration).map_err(invalid) + } + + /// The trusted loader owns enable/disable, not a Feature's self-report. + pub fn set_enabled( + &mut self, + feature: &FeatureId, + enabled: bool, + ) -> Result<(), FeatureInstallError> { + let handle = self + .features + .get(feature) + .ok_or_else(|| invalid("Feature is not registered"))?; + self.host.set_enabled(handle, enabled).map_err(invalid) + } + + pub fn remove(&mut self, feature: &FeatureId) -> Result<(), FeatureInstallError> { + let handle = self + .features + .get(feature) + .ok_or_else(|| invalid("Feature is not registered"))?; + self.host.remove_feature(handle).map_err(invalid)?; + self.features.remove(feature); + Ok(()) + } + + /// A newly trusted Plugin can contribute without copying grants to existing + /// subjects. The Host selects this module; guest declaration data does not. + pub fn add_module(&mut self, module: &dyn FeatureModule) -> Result<(), FeatureInstallError> { + // Construct on a fresh host would retire all prior handles. Instead stage + // this registration, removing it on failure while preserving existing ones. + let id = module.descriptor().id; + if self.features.contains_key(&id) { + return Err(invalid("Feature is already registered")); + } + let identity = FeatureIdentity::parse(id.as_str()).map_err(invalid)?; + let feature = self.host.register_feature(identity).map_err(invalid)?; + if let Err(error) = module.declare_permissions(&mut FeaturePermissionRegistrar { + host: &mut self.host, + feature: feature.clone(), + }) { + self.host.remove_feature(&feature).map_err(invalid)?; + return Err(error); + } + self.features.insert(id, feature); + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/worker/src/feature/permissions/tests.rs b/crates/worker/src/feature/permissions/tests.rs new file mode 100644 index 000000000..442b9e08e --- /dev/null +++ b/crates/worker/src/feature/permissions/tests.rs @@ -0,0 +1,263 @@ +//! Declaration bridge + real LocalWorkdirSession/provider constraints. No live +//! Server/Runtime, policy persistence or production operation-authority hookup. +use super::*; +use crate::feature::{FeatureDescriptor, FeatureInstallContext, FeatureRuntimeKind}; +use feature_permissions::*; +use std::sync::{Arc, Mutex}; + +struct DeclaredFeature { + descriptor: FeatureDescriptor, + namespace: &'static str, + key: &'static str, + handle: Arc>>, +} +impl FeatureModule for DeclaredFeature { + fn descriptor(&self) -> FeatureDescriptor { + self.descriptor.clone() + } + fn install(&self, _: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + Ok(()) + } + fn declare_permissions( + &self, + registrar: &mut FeaturePermissionRegistrar<'_>, + ) -> Result<(), FeatureInstallError> { + let handle = registrar.namespace(NamespaceId::parse(self.namespace).unwrap())?; + registrar.declare( + &handle, + NamespaceDeclaration { + permissions: vec![PermissionDefinition { + key: PermissionKey::parse(self.key).unwrap(), + description: "Fixture operation".into(), + children: vec![], + }], + sets: vec![SetContribution { + set: PermissionSetId::parse("companion").unwrap(), + children: vec![PermissionReference::Namespace(NamespacePermissions { + namespace: NamespaceId::parse(self.namespace).unwrap(), + keys: vec![PermissionKey::parse(self.key).unwrap()], + })], + }], + }, + )?; + *self.handle.lock().unwrap() = Some(handle); + Ok(()) + } +} +fn feature(namespace: &'static str, key: &'static str, plugin: bool) -> DeclaredFeature { + let mut descriptor = FeatureDescriptor::builtin(namespace, namespace); + if plugin { + descriptor.id = FeatureId::new(format!("plugin:{namespace}")).unwrap(); + descriptor.runtime = FeatureRuntimeKind::ExternalPlugin; + } + DeclaredFeature { + descriptor, + namespace, + key, + handle: Arc::new(Mutex::new(None)), + } +} +fn policy() -> PermissionAssignment { + PermissionAssignment { + grants: vec![PermissionReference::Set( + PermissionSetId::parse("companion").unwrap(), + )], + ..Default::default() + } +} + +#[test] +fn registered_modules_receive_owner_bound_handles_and_trusted_plugin_updates_existing_set_holder() { + let ticket = feature("ticket", "ticket.hoge.fuga", false); + let ticket_handle = ticket.handle.clone(); + let workdir = feature("workdir", "workdir.read", false); + let mut registry = FeatureRegistryBuilder::new() + .with_module(ticket) + .with_module(workdir) + .permission_registry(WorkspaceId::parse("workspace-a").unwrap()) + .unwrap(); + let assignment = policy(); + assert_eq!( + registry + .host() + .resolve(Some(&assignment)) + .projection() + .allowed + .len(), + 2 + ); + let plugin = feature("external", "external.read.deep", true); + let plugin_handle = plugin.handle.clone(); + registry.add_module(&plugin).unwrap(); + let resolved = registry.host().resolve(Some(&assignment)); + assert_eq!(resolved.projection().allowed.len(), 3); + let ticket_handle = ticket_handle.lock().unwrap().clone().unwrap(); + let plugin_handle = plugin_handle.lock().unwrap().clone().unwrap(); + assert!( + registry + .host() + .allows( + &ticket_handle, + &resolved, + &PermissionKey::parse("ticket.hoge.fuga").unwrap() + ) + .unwrap() + ); + assert_eq!( + registry.host().allows( + &plugin_handle, + &resolved, + &PermissionKey::parse("ticket.hoge.fuga").unwrap() + ), + Err(PermissionError::Ownership( + PermissionKey::parse("ticket.hoge.fuga").unwrap() + )) + ); + registry.set_enabled(&plugin.descriptor.id, false).unwrap(); + let resolved = registry.host().resolve(Some(&assignment)); + assert_eq!(resolved.projection().allowed.len(), 2); + assert!( + !registry + .host() + .allows( + &plugin_handle, + &resolved, + &PermissionKey::parse("external.read.deep").unwrap() + ) + .unwrap() + ); + registry.remove(&plugin.descriptor.id).unwrap(); + assert_eq!( + registry + .host() + .resolve(Some(&assignment)) + .projection() + .allowed + .len(), + 2 + ); +} + +#[test] +fn namespace_collision_rejects_the_entire_candidate_without_replacing_the_original_feature() { + let mut registry = FeatureRegistryBuilder::new() + .with_module(feature("ticket", "ticket.read", false)) + .permission_registry(WorkspaceId::parse("workspace-a").unwrap()) + .unwrap(); + let mut plugin = feature("ticket", "ticket.steal", true); + plugin.descriptor.id = FeatureId::new("plugin:impostor").unwrap(); + assert!(registry.add_module(&plugin).is_err()); + let catalog = registry.host().catalog(); + assert_eq!(catalog.namespaces.len(), 1); + assert_eq!(catalog.namespaces[0].feature.as_str(), "builtin:ticket"); + assert_eq!( + catalog.namespaces[0].declaration.permissions[0] + .key + .as_str(), + "ticket.read" + ); +} + +#[tokio::test] +async fn allowed_operation_key_does_not_override_real_provider_scope_recursion_or_symlink_checks() { + use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; + use workdir::{LocalWorkdirSession, ReadRequest, WorkdirPath, WorkdirSession}; + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("checkout"); + std::fs::create_dir_all(root.join("nested")).unwrap(); + std::fs::write(root.join("allowed.txt"), "allowed").unwrap(); + std::fs::write(root.join("nested/deep.txt"), "outside recursive scope").unwrap(); + std::fs::write(temp.path().join("secret.txt"), "outside root").unwrap(); + let scope = Scope::from_config(&ScopeConfig { + allow: vec![ScopeRule { + target: root.clone(), + permission: Permission::Read, + recursive: false, + symlink_policy: Default::default(), + }], + deny: vec![], + }) + .unwrap(); + let session = LocalWorkdirSession::new(scope, root.clone()); + let module = feature("workdir", "workdir.read", false); + let handle = module.handle.clone(); + let registry = FeatureRegistryBuilder::new() + .with_module(module) + .permission_registry(WorkspaceId::parse("workspace-a").unwrap()) + .unwrap(); + let resolved = registry.host().resolve(Some(&policy())); + assert!( + registry + .host() + .allows( + handle.lock().unwrap().as_ref().unwrap(), + &resolved, + &PermissionKey::parse("workdir.read").unwrap() + ) + .unwrap() + ); + let read = |path| ReadRequest { + path: WorkdirPath::new(path).unwrap(), + offset: 0, + limit: 20, + max_bytes: 1024, + }; + assert_eq!( + session.read(read("allowed.txt")).await.unwrap().bytes, + b"allowed" + ); + assert_eq!( + session + .read(read("nested/deep.txt")) + .await + .unwrap_err() + .denial_reason(), + Some(workdir::WorkdirDenialReason::PathOutOfScope) + ); + #[cfg(unix)] + { + std::os::unix::fs::symlink(temp.path().join("secret.txt"), root.join("escape.txt")) + .unwrap(); + assert_eq!( + session + .read(read("escape.txt")) + .await + .unwrap_err() + .denial_reason(), + Some(workdir::WorkdirDenialReason::SymlinkTargetOutOfScope) + ); + } +} + +#[test] +fn registrar_rejects_a_foreign_handle_even_when_its_wire_keys_match_that_namespace() { + struct ForeignHandleFeature(NamespaceHandle); + impl FeatureModule for ForeignHandleFeature { + fn descriptor(&self) -> FeatureDescriptor { + let mut descriptor = FeatureDescriptor::builtin("impostor", "Impostor"); + descriptor.id = FeatureId::new("plugin:impostor").unwrap(); + descriptor.runtime = FeatureRuntimeKind::ExternalPlugin; + descriptor + } + fn install(&self, _: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + Ok(()) + } + fn declare_permissions( + &self, + registrar: &mut FeaturePermissionRegistrar<'_>, + ) -> Result<(), FeatureInstallError> { + let declaration = serde_json::from_str(r#"{"permissions":[{"key":"ticket.steal","description":"forged","children":[]}],"sets":[]}"#).unwrap(); + registrar.declare(&self.0, declaration) + } + } + let module = feature("ticket", "ticket.read", false); + let handle = module.handle.clone(); + let mut registry = FeatureRegistryBuilder::new() + .with_module(module) + .permission_registry(WorkspaceId::parse("workspace-a").unwrap()) + .unwrap(); + let before = registry.host().catalog(); + let impostor = ForeignHandleFeature(handle.lock().unwrap().clone().unwrap()); + assert!(registry.add_module(&impostor).is_err()); + assert_eq!(before, registry.host().catalog()); +} diff --git a/openapi/feature-permissions-api.json b/openapi/feature-permissions-api.json new file mode 100644 index 000000000..64af1c82f --- /dev/null +++ b/openapi/feature-permissions-api.json @@ -0,0 +1,653 @@ +{ + "components": { + "schemas": { + "Diagnostic": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "$ref": "#/components/schemas/DiagnosticSeverity" + } + }, + "required": [ + "code", + "severity", + "message" + ], + "type": "object" + }, + "DiagnosticSeverity": { + "enum": [ + "info", + "warning", + "error" + ], + "type": "string" + }, + "NamespaceCatalog": { + "additionalProperties": false, + "properties": { + "declaration": { + "$ref": "#/components/schemas/NamespaceDeclaration" + }, + "enabled": { + "type": "boolean" + }, + "feature": { + "type": "string" + }, + "namespace": { + "type": "string" + } + }, + "required": [ + "namespace", + "feature", + "enabled", + "declaration" + ], + "type": "object" + }, + "NamespaceDeclaration": { + "additionalProperties": false, + "description": "This input deliberately has no owner field. The Host supplies the registered\nidentity/namespace handle independently of guest-controlled declaration data.", + "properties": { + "permissions": { + "items": { + "$ref": "#/components/schemas/PermissionDefinition" + }, + "type": "array" + }, + "sets": { + "items": { + "$ref": "#/components/schemas/SetContribution" + }, + "type": "array" + } + }, + "required": [ + "permissions", + "sets" + ], + "type": "object" + }, + "NamespacePermissions": { + "additionalProperties": false, + "properties": { + "keys": { + "items": { + "type": "string" + }, + "type": "array" + }, + "namespace": { + "type": "string" + } + }, + "required": [ + "namespace", + "keys" + ], + "type": "object" + }, + "OriginStep": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "set", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "feature", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "key", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + } + ] + }, + "PermissionAssignment": { + "additionalProperties": false, + "description": "`None` at the consuming policy boundary means unset. An empty assignment is\nexplicit deny-all; this crate never chooses or reinstates a default.", + "properties": { + "exclusions": { + "items": { + "$ref": "#/components/schemas/PermissionReference" + }, + "type": "array" + }, + "grants": { + "items": { + "$ref": "#/components/schemas/PermissionReference" + }, + "type": "array" + }, + "revoked": { + "type": "boolean" + } + }, + "required": [ + "grants", + "exclusions", + "revoked" + ], + "type": "object" + }, + "PermissionCatalog": { + "additionalProperties": false, + "properties": { + "namespaces": { + "items": { + "$ref": "#/components/schemas/NamespaceCatalog" + }, + "type": "array" + }, + "workspace": { + "type": "string" + } + }, + "required": [ + "workspace", + "namespaces" + ], + "type": "object" + }, + "PermissionDefinition": { + "additionalProperties": false, + "properties": { + "children": { + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "type": "string" + }, + "key": { + "type": "string" + } + }, + "required": [ + "key", + "description", + "children" + ], + "type": "object" + }, + "PermissionDiagnostic": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "unset", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "revoked", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "unknown_set", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "unknown_namespace", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "unknown_key", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "disabled_feature", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "excluded", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + } + ] + }, + "PermissionReference": { + "description": "The discriminant, not an identifier's spelling, selects a reference kind.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "set", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "namespace", + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/NamespacePermissions" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + } + ] + }, + "PermissionResolution": { + "additionalProperties": false, + "description": "A management projection, not an executable credential. Only the Host's opaque\nresolution can be queried through a namespace handle.", + "properties": { + "allowed": { + "additionalProperties": { + "items": { + "items": { + "$ref": "#/components/schemas/OriginStep" + }, + "type": "array" + }, + "type": "array" + }, + "type": "object" + }, + "diagnostics": { + "items": { + "$ref": "#/components/schemas/PermissionDiagnostic" + }, + "type": "array" + }, + "workspace": { + "type": "string" + } + }, + "required": [ + "workspace", + "allowed", + "diagnostics" + ], + "type": "object" + }, + "RepositoryApiError": { + "additionalProperties": false, + "description": "Error body shared by the existing repository routes and their generated adapters.", + "properties": { + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ], + "type": "object" + }, + "SetContribution": { + "additionalProperties": false, + "properties": { + "children": { + "items": { + "$ref": "#/components/schemas/PermissionReference" + }, + "type": "array" + }, + "set": { + "type": "string" + } + }, + "required": [ + "set", + "children" + ], + "type": "object" + }, + "ValidatePermissionsRequest": { + "additionalProperties": false, + "description": "The management boundary uses exactly the same assignment as text/storage.\nWorkspace comes from authenticated routing, not a repeated permission field.", + "properties": { + "assignment": { + "$ref": "#/components/schemas/PermissionAssignment" + } + }, + "required": [ + "assignment" + ], + "type": "object" + }, + "string": { + "type": "string" + } + }, + "securitySchemes": { + "bearerAuth": { + "scheme": "bearer", + "type": "http" + }, + "browserSession": { + "in": "cookie", + "name": "yoi_workspace_session", + "type": "apiKey" + } + } + }, + "info": { + "title": "Feature permission declaration contract", + "version": "0.1.0", + "x-yoi-source-digest": "sha256:3c85df5880ec5b52d693ba559f02534609f0667d7f95841f6eb58a36823824cb" + }, + "openapi": "3.1.0", + "paths": { + "/api/w/{workspace_id}/permission-catalog": { + "get": { + "operationId": "permission_catalog", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PermissionCatalog" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/permissions/validate": { + "post": { + "operationId": "validate_permissions", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidatePermissionsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PermissionResolution" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + } + } +} diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 8743b3819..caa1e9467 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts", "test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts", diff --git a/web/workspace/src/lib/generated/feature-permissions.generated.ts b/web/workspace/src/lib/generated/feature-permissions.generated.ts new file mode 100644 index 000000000..f61da4790 --- /dev/null +++ b/web/workspace/src/lib/generated/feature-permissions.generated.ts @@ -0,0 +1,18 @@ +// Generated by feature-permissions; do not edit. +export type WorkspaceId = string; +export type FeatureIdentity = string; +export type PermissionSetId = string; +export type NamespaceId = string; +export type PermissionKey = string; +export type NamespacePermissions = { namespace: NamespaceId, keys: Array, }; +export type PermissionReference = { "kind": "set", "value": PermissionSetId } | { "kind": "namespace", "value": NamespacePermissions }; +export type PermissionAssignment = { grants: Array, exclusions: Array, revoked: boolean, }; +export type PermissionDefinition = { key: PermissionKey, description: string, children: Array, }; +export type SetContribution = { set: PermissionSetId, children: Array, }; +export type NamespaceDeclaration = { permissions: Array, sets: Array, }; +export type NamespaceCatalog = { namespace: NamespaceId, feature: FeatureIdentity, enabled: boolean, declaration: NamespaceDeclaration, }; +export type PermissionCatalog = { workspace: WorkspaceId, namespaces: Array, }; +export type OriginStep = { "kind": "set", "value": PermissionSetId } | { "kind": "feature", "value": FeatureIdentity } | { "kind": "key", "value": PermissionKey }; +export type PermissionDiagnostic = { "kind": "unset" } | { "kind": "revoked" } | { "kind": "unknown_set", "value": PermissionSetId } | { "kind": "unknown_namespace", "value": NamespaceId } | { "kind": "unknown_key", "value": PermissionKey } | { "kind": "disabled_feature", "value": FeatureIdentity } | { "kind": "excluded", "value": PermissionKey }; +export type PermissionResolution = { workspace: WorkspaceId, allowed: { [key in PermissionKey]: Array> }, diagnostics: Array, }; +export type ValidatePermissionsRequest = { assignment: PermissionAssignment, }; diff --git a/web/workspace/src/lib/workspace/feature-permissions.test.ts b/web/workspace/src/lib/workspace/feature-permissions.test.ts new file mode 100644 index 000000000..61534c74b --- /dev/null +++ b/web/workspace/src/lib/workspace/feature-permissions.test.ts @@ -0,0 +1,80 @@ +import assignmentFixture from "../../../../../crates/feature-permissions/tests/fixtures/assignment.json" with { + type: "json", +}; +import catalogFixture from "../../../../../crates/feature-permissions/tests/fixtures/catalog.json" with { + type: "json", +}; +import type { + NamespaceCatalog, + PermissionAssignment, + PermissionReference, +} from "../generated/feature-permissions.generated.ts"; + +declare const Deno: { + test(name: string, fn: () => void): void; +}; + +// Rustが同じfixtureを本番Serdeで検証する。ここではWebが利用する公開形と +// children/多段キーの保持だけを確認し、認可や入力validatorの代用にしない。 +function assertEquals(actual: unknown, expected: unknown): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Expected ${JSON.stringify(expected)}, received ${ + JSON.stringify(actual) + }`, + ); + } +} + +Deno.test("shared assignment fixture preserves Set/Namespace tags and full keys in the Web wire shape", () => { + const expected: PermissionAssignment = { + grants: [ + { kind: "set", value: "ticket" }, + { + kind: "namespace", + value: { + namespace: "ticket", + keys: ["ticket.hoge", "ticket.hoge.fuga.finish"], + }, + }, + ], + exclusions: [{ + kind: "namespace", + value: { namespace: "ticket", keys: ["ticket.hoge.unlinked"] }, + }], + revoked: false, + }; + assertEquals(assignmentFixture, expected); + assertEquals(JSON.parse(JSON.stringify(assignmentFixture)), expected); +}); + +Deno.test("shared NamespaceCatalog fixture preserves multilevel children without deriving prefix edges", () => { + const namespace = catalogFixture.namespaces[0]!; + assertEquals(namespace.namespace, "ticket"); + assertEquals(namespace.feature, "builtin:ticket"); + assertEquals(namespace.enabled, true); + type CatalogPermission = + NamespaceCatalog["declaration"]["permissions"][number]; + const expectedEdges: Pick[] = [ + { key: "ticket.hoge", children: ["ticket.hoge.fuga"] }, + { key: "ticket.hoge.fuga", children: ["ticket.hoge.fuga.finish"] }, + { key: "ticket.hoge.fuga.finish", children: [] }, + { key: "ticket.hoge.unlinked", children: [] }, + ]; + assertEquals( + namespace.declaration.permissions.map(({ key, children }) => ({ + key, + children, + })), + expectedEdges, + ); + const expectedChildren: PermissionReference[] = [{ + kind: "namespace", + value: { namespace: "ticket", keys: ["ticket.hoge"] }, + }]; + assertEquals(namespace.declaration.sets, [{ + set: "ticket", + children: expectedChildren, + }]); + assertEquals(JSON.parse(JSON.stringify(namespace)), namespace); +});