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:
2026-11-04 11:19:17 +09:00
parent 267f4d6842
commit a7f5ecb56a
20 changed files with 2711 additions and 3 deletions
+43 -2
View File
@@ -56,5 +56,46 @@ A text/storage/management assignment uses an explicit discriminant, for example:
The same Serde types validate dynamic inputs; unknown fields (including payloads)
are rejected. [`typescript`] generates the Web contract. Management projections are
serializable observations, not credentials: deserialization cannot manufacture a
runtime resolution, namespace handle or delegation grant. The consumer owns
policy persistence and its route/subject authentication, not this DTO layer.
runtime resolution, namespace handle or delegation grant. The consumer owns policy persistence and its route/subject authentication, not
this DTO layer.
To add a Feature, the trusted loader registers the established identity and gives
its registrar a namespace handle. The declaration contains only operation keys
and explicit edges; no central domain list needs updating:
```rust
use feature_permissions::*;
let mut host = PermissionHost::new(WorkspaceId::parse("workspace-a")?);
// Host-only: a Plugin receives its bound registrar, not this registration API.
let feature = host.register_feature(FeatureIdentity::parse("plugin:example")?)?;
let namespace = host.bind_namespace(&feature, NamespaceId::parse("example")?)?;
let read = PermissionKey::parse("example.documents.read")?;
host.declare(&namespace, NamespaceDeclaration {
permissions: vec![PermissionDefinition {
key: read.clone(), description: "Read example documents".into(), children: vec![],
}],
sets: vec![SetContribution {
set: PermissionSetId::parse("companion")?,
children: vec![PermissionReference::Namespace(NamespacePermissions {
namespace: NamespaceId::parse("example")?, keys: vec![read.clone()],
})],
}],
})?;
let assignment = PermissionAssignment {
grants: vec![PermissionReference::Set(PermissionSetId::parse("companion")?)],
..Default::default()
};
let resolved = host.resolve(Some(&assignment));
assert!(host.allows(&namespace, &resolved, &read)?);
# Ok::<(), Box<dyn std::error::Error>>(())
```
Wire inputs cannot manufacture an owner handle or executable resolution:
```compile_fail
let _: feature_permissions::NamespaceHandle = serde_json::from_str("{}").unwrap();
```
```compile_fail
let _: feature_permissions::ResolvedPermissions = serde_json::from_str("{}").unwrap();
```
@@ -0,0 +1,3 @@
fn main() {
print!("{}", feature_permissions::typescript());
}
@@ -50,6 +50,12 @@ impl NamespaceHandle {
pub fn namespace(&self) -> &NamespaceId {
&self.namespace
}
/// Compare the actual registrations, never caller-supplied owner names.
pub fn is_owned_by(&self, feature: &FeatureHandle) -> bool {
Arc::ptr_eq(&self.feature.host, &feature.host)
&& Arc::ptr_eq(&self.feature.registration, &feature.registration)
}
}
#[derive(Clone, Debug)]
struct FeatureRecord {
@@ -0,0 +1,22 @@
{
"grants": [
{ "kind": "set", "value": "ticket" },
{
"kind": "namespace",
"value": {
"namespace": "ticket",
"keys": ["ticket.hoge", "ticket.hoge.fuga.finish"]
}
}
],
"exclusions": [
{
"kind": "namespace",
"value": {
"namespace": "ticket",
"keys": ["ticket.hoge.unlinked"]
}
}
],
"revoked": false
}
@@ -0,0 +1,17 @@
revoked = false
[[grants]]
kind = "set"
value = "ticket"
[[grants]]
kind = "namespace"
[grants.value]
namespace = "ticket"
keys = ["ticket.hoge", "ticket.hoge.fuga.finish"]
[[exclusions]]
kind = "namespace"
[exclusions.value]
namespace = "ticket"
keys = ["ticket.hoge.unlinked"]
+45
View File
@@ -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"] }
}
]
}
]
}
}
]
}
+35
View File
@@ -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(&current, &["ticket.read", "search.query"]);
assert_eq!(
host.allows(&replacement, &current, &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(&current, &current, &old_child)
.unwrap(),
&["ticket.read"],
);
let fresh_child = host.issue_delegation(&current, &requested).unwrap();
let fresh_job = host
.issue_job_grant("job-a".into(), &current, &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", &current, &current, &fresh_child, &old_job)
.unwrap(),
&["ticket.read"],
);
assert_keys(
&host
.resolve_job("job-a", &current, &current, &old_child, &fresh_job)
.unwrap(),
&["ticket.read"],
);
// Positive control: reissuance, not identifier reuse, grants the new operation.
assert_keys(
&host
.resolve_job("job-a", &current, &current, &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(
&current,
&[
"ticket.hoge",
"ticket.hoge.read",
"ticket.hoge.write",
"search.query",
],
);
assert_eq!(child.keys(), &frozen);
let limited = host.resolve_limited(&current, &current, &child).unwrap();
assert_keys(&limited, &["ticket.hoge", "ticket.hoge.read"]);
assert_eq!(
host.resolve_limited(&current, &old, &child).unwrap_err(),
PermissionError::StaleResolution
);
assert_eq!(
host.resolve_limited(&old, &current, &child).unwrap_err(),
PermissionError::StaleResolution
);
assert_eq!(
host.resolve_job("job-a", &current, &old, &child, &job)
.unwrap_err(),
PermissionError::StaleResolution
);
let current_child = host
.issue_delegation(&current, &[key("ticket.hoge"), key("search.query")])
.unwrap();
assert_keys(
&host
.resolve_job("job-a", &current, &current, &current_child, &job)
.unwrap(),
&["ticket.hoge", "ticket.hoge.read"],
);
assert_keys(
&host
.resolve_job("job-b", &current, &current, &current_child, &narrow_job)
.unwrap(),
&["ticket.hoge.read"],
);
assert_eq!(
host.resolve_job("another-job", &current, &current, &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);
}
}
+1
View File
@@ -8,6 +8,7 @@ pub use api_macros::reqwest as client_support;
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
pub mod authorization;
mod chat;
pub mod feature_permissions;
pub use chat::*;
mod subject_body;
pub use subject_body::*;
+21
View File
@@ -41,6 +41,8 @@ use background::{
FeatureBackgroundTaskRegistryBuilder,
};
pub mod permissions;
/// Stable source-qualified identifier for a feature module.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct FeatureId(String);
@@ -718,6 +720,16 @@ impl FeatureDescriptor {
/// Feature module contribution boundary.
pub trait FeatureModule: Send + Sync {
fn descriptor(&self) -> FeatureDescriptor;
/// Declare operation keys through the Host-bound owner registrar. This does
/// not activate the Feature or grant permissions to any execution subject.
fn declare_permissions(
&self,
_registrar: &mut permissions::FeaturePermissionRegistrar<'_>,
) -> Result<(), FeatureInstallError> {
Ok(())
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError>;
}
@@ -2114,6 +2126,15 @@ impl FeatureRegistryBuilder {
.collect()
}
/// Build a declaration catalog for the authenticated Workspace, independently
/// of Worker policy and activation. Only trusted selected modules run here.
pub fn permission_registry(
&self,
workspace: feature_permissions::WorkspaceId,
) -> Result<permissions::FeaturePermissionRegistry, FeatureInstallError> {
permissions::FeaturePermissionRegistry::from_builder(self, workspace)
}
pub fn plan(&self) -> Result<FeatureInstallPlan, FeaturePlanError> {
build_feature_install_plan(&self.descriptors())
}
+148
View File
@@ -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());
}
+653
View File
@@ -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": []
}
]
}
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@8.3.2 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts",
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV src/lib/workspace/feature-permissions.test.ts tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/login.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/subjektiv/routes.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts src/lib/workspace/memory/permissions.test.ts src/lib/workspace/memory/subject-body.test.ts src/lib/workspace/memory/conversation-attempt.test.ts src/lib/workspace/memory/conversation-api.test.ts tests/subject-delete.test.ts tests/journal-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts src/lib/workspace/api/ticket-parent.test.ts src/lib/workspace/tickets/ticket-parent-controller.test.ts",
"test:journal:backend-contract": "cd ../.. && mkdir -p target && cargo test -p yoi-workspace-server --lib journal_browser_web_contract -- --nocapture > target/journal-browser-contract.log && cd web/workspace && deno run --allow-read=../../target/journal-browser-contract.log test/journal-backend-contract.ts ../../target/journal-browser-contract.log",
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
"test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts",
@@ -0,0 +1,18 @@
// Generated by feature-permissions; do not edit.
export type WorkspaceId = string;
export type FeatureIdentity = string;
export type PermissionSetId = string;
export type NamespaceId = string;
export type PermissionKey = string;
export type NamespacePermissions = { namespace: NamespaceId, keys: Array<PermissionKey>, };
export type PermissionReference = { "kind": "set", "value": PermissionSetId } | { "kind": "namespace", "value": NamespacePermissions };
export type PermissionAssignment = { grants: Array<PermissionReference>, exclusions: Array<PermissionReference>, revoked: boolean, };
export type PermissionDefinition = { key: PermissionKey, description: string, children: Array<PermissionKey>, };
export type SetContribution = { set: PermissionSetId, children: Array<PermissionReference>, };
export type NamespaceDeclaration = { permissions: Array<PermissionDefinition>, sets: Array<SetContribution>, };
export type NamespaceCatalog = { namespace: NamespaceId, feature: FeatureIdentity, enabled: boolean, declaration: NamespaceDeclaration, };
export type PermissionCatalog = { workspace: WorkspaceId, namespaces: Array<NamespaceCatalog>, };
export type OriginStep = { "kind": "set", "value": PermissionSetId } | { "kind": "feature", "value": FeatureIdentity } | { "kind": "key", "value": PermissionKey };
export type PermissionDiagnostic = { "kind": "unset" } | { "kind": "revoked" } | { "kind": "unknown_set", "value": PermissionSetId } | { "kind": "unknown_namespace", "value": NamespaceId } | { "kind": "unknown_key", "value": PermissionKey } | { "kind": "disabled_feature", "value": FeatureIdentity } | { "kind": "excluded", "value": PermissionKey };
export type PermissionResolution = { workspace: WorkspaceId, allowed: { [key in PermissionKey]: Array<Array<OriginStep>> }, diagnostics: Array<PermissionDiagnostic>, };
export type ValidatePermissionsRequest = { assignment: PermissionAssignment, };
@@ -0,0 +1,80 @@
import assignmentFixture from "../../../../../crates/feature-permissions/tests/fixtures/assignment.json" with {
type: "json",
};
import catalogFixture from "../../../../../crates/feature-permissions/tests/fixtures/catalog.json" with {
type: "json",
};
import type {
NamespaceCatalog,
PermissionAssignment,
PermissionReference,
} from "../generated/feature-permissions.generated.ts";
declare const Deno: {
test(name: string, fn: () => void): void;
};
// Rustが同じfixtureを本番Serdeで検証する。ここではWebが利用する公開形と
// children/多段キーの保持だけを確認し、認可や入力validatorの代用にしない。
function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`Expected ${JSON.stringify(expected)}, received ${
JSON.stringify(actual)
}`,
);
}
}
Deno.test("shared assignment fixture preserves Set/Namespace tags and full keys in the Web wire shape", () => {
const expected: PermissionAssignment = {
grants: [
{ kind: "set", value: "ticket" },
{
kind: "namespace",
value: {
namespace: "ticket",
keys: ["ticket.hoge", "ticket.hoge.fuga.finish"],
},
},
],
exclusions: [{
kind: "namespace",
value: { namespace: "ticket", keys: ["ticket.hoge.unlinked"] },
}],
revoked: false,
};
assertEquals(assignmentFixture, expected);
assertEquals(JSON.parse(JSON.stringify(assignmentFixture)), expected);
});
Deno.test("shared NamespaceCatalog fixture preserves multilevel children without deriving prefix edges", () => {
const namespace = catalogFixture.namespaces[0]!;
assertEquals(namespace.namespace, "ticket");
assertEquals(namespace.feature, "builtin:ticket");
assertEquals(namespace.enabled, true);
type CatalogPermission =
NamespaceCatalog["declaration"]["permissions"][number];
const expectedEdges: Pick<CatalogPermission, "key" | "children">[] = [
{ key: "ticket.hoge", children: ["ticket.hoge.fuga"] },
{ key: "ticket.hoge.fuga", children: ["ticket.hoge.fuga.finish"] },
{ key: "ticket.hoge.fuga.finish", children: [] },
{ key: "ticket.hoge.unlinked", children: [] },
];
assertEquals(
namespace.declaration.permissions.map(({ key, children }) => ({
key,
children,
})),
expectedEdges,
);
const expectedChildren: PermissionReference[] = [{
kind: "namespace",
value: { namespace: "ticket", keys: ["ticket.hoge"] },
}];
assertEquals(namespace.declaration.sets, [{
set: "ticket",
children: expectedChildren,
}]);
assertEquals(JSON.parse(JSON.stringify(namespace)), namespace);
});