Bind Feature declarations to Host handles and publish permission contract fixtures
T836: registered Feature callback/namespace registrar, explicit children/catalog resolution, frozen child and Job key tests, payload-free text/storage/API/Web wire contract and generated OpenAPI/TypeScript. Real LocalWorkdirSession checks preserve path, recursion and symlink denial independently of allowed keys. No policy adoption/migration, real-operation authorization or Worker defaults. Validated: feature-permissions 22 tests and 3 doctests; worker declaration/provider filter 4; server-api permission filter 2; typed Deno Web 2; exact generated artifacts and format/diff/naming. Full root check/changed-crate closure and independent MR verdict remain to be run before final reviewed handoff. Pinned dependency format is exact rustfmt output, including formatter optional punctuation/closure braces, not a claim of bytewise non-whitespace token identity.
This commit is contained in:
@@ -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<dyn std::error::Error>>(())
|
||||
```
|
||||
|
||||
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();
|
||||
```
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
print!("{}", feature_permissions::typescript());
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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<PermissionReference>) -> 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<PermissionReference>)],
|
||||
) -> 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::<BTreeSet<_>>(),
|
||||
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::<NamespaceDeclaration>(
|
||||
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<Vec<_>> = 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
|
||||
);
|
||||
}
|
||||
@@ -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<T>(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::<Value>(json).unwrap(),
|
||||
"serialization must not drop or synthesize wire fields"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<T>(&serde_json::to_string(&from_toml).unwrap()).unwrap(),
|
||||
from_json
|
||||
);
|
||||
assert_eq!(
|
||||
toml::from_str::<T>(&toml::to_string(&from_json).unwrap()).unwrap(),
|
||||
from_toml
|
||||
);
|
||||
from_json
|
||||
}
|
||||
|
||||
fn reject_in_both_formats<T: DeserializeOwned>(rule: &str, input: Value) {
|
||||
assert!(
|
||||
serde_json::from_value::<T>(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::<T>(&text).is_err(),
|
||||
"TOML accepted {rule}: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
fn reject_authority_and_payload_fields<T: DeserializeOwned>(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::<T>(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::<Vec<_>>(),
|
||||
["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::<Vec<_>>(),
|
||||
)
|
||||
})
|
||||
.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::<PermissionAssignment>(
|
||||
&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::<PermissionAssignment>(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::<PermissionReference>(rule, input);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_identifiers_reject_invalid_segments_without_truncation() {
|
||||
for invalid in ["", "Ticket", "ticket.read", "ticket read", "*", "1ticket"] {
|
||||
reject_in_both_formats::<PermissionReference>(
|
||||
"invalid set identifier",
|
||||
json!({"kind": "set", "value": invalid}),
|
||||
);
|
||||
reject_in_both_formats::<NamespacePermissions>(
|
||||
"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::<NamespacePermissions>(
|
||||
"invalid assigned key",
|
||||
json!({"namespace": "ticket", "keys": [invalid]}),
|
||||
);
|
||||
reject_in_both_formats::<PermissionDefinition>(
|
||||
"invalid declared key",
|
||||
json!({"key": invalid, "description": "", "children": []}),
|
||||
);
|
||||
reject_in_both_formats::<PermissionDefinition>(
|
||||
"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::<PermissionCatalog>("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::<PermissionAssignment>(assignment.clone());
|
||||
for reference in assignment["grants"].as_array().unwrap() {
|
||||
reject_authority_and_payload_fields::<PermissionReference>(reference.clone());
|
||||
}
|
||||
reject_authority_and_payload_fields::<NamespacePermissions>(
|
||||
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::<ValidatePermissionsRequest>(request.clone());
|
||||
let mut nested = request;
|
||||
nested["assignment"]["workspace"] = json!("other-workspace");
|
||||
reject_in_both_formats::<ValidatePermissionsRequest>("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::<PermissionAssignment>(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::<NamespaceDeclaration>(declaration.clone());
|
||||
reject_authority_and_payload_fields::<PermissionDefinition>(
|
||||
declaration["permissions"][0].clone(),
|
||||
);
|
||||
reject_authority_and_payload_fields::<SetContribution>(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::<NamespaceDeclaration>("feature identity injection", input);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
print!(
|
||||
"{}",
|
||||
server_api::feature_permissions::permission_contract_openapi()?.to_json()?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -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<PermissionCatalogResponse, RepositoryApiError>;
|
||||
|
||||
#[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<PermissionResolutionResponse, RepositoryApiError>;
|
||||
}
|
||||
|
||||
/// 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<api_macros::openapi::OpenApiDocument, api_macros::openapi::OpenApiError> {
|
||||
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::<ValidatePermissionsRequest>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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, FeatureInstallError> {
|
||||
permissions::FeaturePermissionRegistry::from_builder(self, workspace)
|
||||
}
|
||||
|
||||
pub fn plan(&self) -> Result<FeatureInstallPlan, FeaturePlanError> {
|
||||
build_feature_install_plan(&self.descriptors())
|
||||
}
|
||||
|
||||
@@ -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<NamespaceHandle, FeatureInstallError> {
|
||||
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<FeatureId, FeatureHandle>,
|
||||
}
|
||||
|
||||
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<Self, FeatureInstallError> {
|
||||
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;
|
||||
@@ -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<Mutex<Option<NamespaceHandle>>>,
|
||||
}
|
||||
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());
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<PermissionKey>, };
|
||||
export type PermissionReference = { "kind": "set", "value": PermissionSetId } | { "kind": "namespace", "value": NamespacePermissions };
|
||||
export type PermissionAssignment = { grants: Array<PermissionReference>, exclusions: Array<PermissionReference>, revoked: boolean, };
|
||||
export type PermissionDefinition = { key: PermissionKey, description: string, children: Array<PermissionKey>, };
|
||||
export type SetContribution = { set: PermissionSetId, children: Array<PermissionReference>, };
|
||||
export type NamespaceDeclaration = { permissions: Array<PermissionDefinition>, sets: Array<SetContribution>, };
|
||||
export type NamespaceCatalog = { namespace: NamespaceId, feature: FeatureIdentity, enabled: boolean, declaration: NamespaceDeclaration, };
|
||||
export type PermissionCatalog = { workspace: WorkspaceId, namespaces: Array<NamespaceCatalog>, };
|
||||
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<Array<OriginStep>> }, diagnostics: Array<PermissionDiagnostic>, };
|
||||
export type ValidatePermissionsRequest = { assignment: PermissionAssignment, };
|
||||
@@ -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<CatalogPermission, "key" | "children">[] = [
|
||||
{ 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);
|
||||
});
|
||||
Reference in New Issue
Block a user