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)
|
The same Serde types validate dynamic inputs; unknown fields (including payloads)
|
||||||
are rejected. [`typescript`] generates the Web contract. Management projections are
|
are rejected. [`typescript`] generates the Web contract. Management projections are
|
||||||
serializable observations, not credentials: deserialization cannot manufacture a
|
serializable observations, not credentials: deserialization cannot manufacture a
|
||||||
runtime resolution, namespace handle or delegation grant. The consumer owns
|
runtime resolution, namespace handle or delegation grant. The consumer owns policy persistence and its route/subject authentication, not
|
||||||
policy persistence and its route/subject authentication, not this DTO layer.
|
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 {
|
pub fn namespace(&self) -> &NamespaceId {
|
||||||
&self.namespace
|
&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)]
|
#[derive(Clone, Debug)]
|
||||||
struct FeatureRecord {
|
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 use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
|
||||||
pub mod authorization;
|
pub mod authorization;
|
||||||
mod chat;
|
mod chat;
|
||||||
|
pub mod feature_permissions;
|
||||||
pub use chat::*;
|
pub use chat::*;
|
||||||
mod subject_body;
|
mod subject_body;
|
||||||
pub use subject_body::*;
|
pub use subject_body::*;
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ use background::{
|
|||||||
FeatureBackgroundTaskRegistryBuilder,
|
FeatureBackgroundTaskRegistryBuilder,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub mod permissions;
|
||||||
|
|
||||||
/// Stable source-qualified identifier for a feature module.
|
/// Stable source-qualified identifier for a feature module.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
pub struct FeatureId(String);
|
pub struct FeatureId(String);
|
||||||
@@ -718,6 +720,16 @@ impl FeatureDescriptor {
|
|||||||
/// Feature module contribution boundary.
|
/// Feature module contribution boundary.
|
||||||
pub trait FeatureModule: Send + Sync {
|
pub trait FeatureModule: Send + Sync {
|
||||||
fn descriptor(&self) -> FeatureDescriptor;
|
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>;
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2114,6 +2126,15 @@ impl FeatureRegistryBuilder {
|
|||||||
.collect()
|
.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> {
|
pub fn plan(&self) -> Result<FeatureInstallPlan, FeaturePlanError> {
|
||||||
build_feature_install_plan(&self.descriptors())
|
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": "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",
|
"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",
|
"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: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: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",
|
"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