Preserve explicit permission exclusions through disabled Feature declarations

This commit is contained in:
2026-11-04 11:47:45 +09:00
parent e166117c60
commit d1cd2855e8
3 changed files with 147 additions and 9 deletions
+5 -3
View File
@@ -19,9 +19,11 @@ the same references. Installing a declaration validates its candidate graph
atomically. Duplicate definitions, unknown references and cycles reject the update atomically. Duplicate definitions, unknown references and cycles reject the update
without changing the prior catalog. Duplicate assignment paths are deduplicated, without changing the prior catalog. Duplicate assignment paths are deduplicated,
with distinct provenance paths retained in deterministic order. Invalid assignment with distinct provenance paths retained in deterministic order. Invalid assignment
references deny the whole assignment with diagnostics. Disabled contributions are references deny the whole assignment with diagnostics. Disabled positive
omitted independently; removing a Feature retires its handles and withdraws only contributions are omitted independently. Explicit exclusions still traverse disabled
its declarations. Dangling references deny rather than recovering an old result. Features' declared keys and set edges, so disabling a Plugin cannot revive another
Feature's excluded operation. Removing a Feature retires its handles and withdraws
only its declarations. Dangling references deny rather than recovering an old result.
Every change invalidates prior [`ResolvedPermissions`]. The Host re-resolves the Every change invalidates prior [`ResolvedPermissions`]. The Host re-resolves the
current assignment before use. A resolution retains its actual catalog snapshot; current assignment before use. A resolution retains its actual catalog snapshot;
+18 -4
View File
@@ -317,11 +317,11 @@ impl PermissionHost {
None => projection.diagnostics.push(PermissionDiagnostic::Unset), None => projection.diagnostics.push(PermissionDiagnostic::Unset),
Some(a) if a.revoked => projection.diagnostics.push(PermissionDiagnostic::Revoked), Some(a) if a.revoked => projection.diagnostics.push(PermissionDiagnostic::Revoked),
Some(a) => { Some(a) => {
let mut expansion = Expansion::new(self); let mut expansion = Expansion::new(self, ExpansionPurpose::Grant);
for reference in &a.grants { for reference in &a.grants {
expansion.reference(reference, vec![]); expansion.reference(reference, vec![]);
} }
let mut exclusions = Expansion::new(self); let mut exclusions = Expansion::new(self, ExpansionPurpose::Exclusion);
for reference in &a.exclusions { for reference in &a.exclusions {
exclusions.reference(reference, vec![]); exclusions.reference(reference, vec![]);
} }
@@ -391,7 +391,7 @@ impl PermissionHost {
requested: &[PermissionKey], requested: &[PermissionKey],
) -> Result<LimitedGrant, PermissionError> { ) -> Result<LimitedGrant, PermissionError> {
self.check_resolution(ceiling)?; self.check_resolution(ceiling)?;
let mut expansion = Expansion::new(self); let mut expansion = Expansion::new(self, ExpansionPurpose::Grant);
for key in requested { for key in requested {
expansion.key(key, vec![]); expansion.key(key, vec![]);
} }
@@ -572,15 +572,25 @@ fn validate_graph(
Ok(()) Ok(())
} }
#[derive(PartialEq, Eq)]
enum ExpansionPurpose {
Grant,
// Disabling an owner withdraws positive contributions, not the meaning of
// an explicit deny. Its retained declaration still defines exclusion edges.
Exclusion,
}
struct Expansion<'a> { struct Expansion<'a> {
host: &'a PermissionHost, host: &'a PermissionHost,
purpose: ExpansionPurpose,
allowed: BTreeMap<PermissionKey, BTreeSet<Vec<OriginStep>>>, allowed: BTreeMap<PermissionKey, BTreeSet<Vec<OriginStep>>>,
diagnostics: BTreeSet<PermissionDiagnostic>, diagnostics: BTreeSet<PermissionDiagnostic>,
} }
impl<'a> Expansion<'a> { impl<'a> Expansion<'a> {
fn new(host: &'a PermissionHost) -> Self { fn new(host: &'a PermissionHost, purpose: ExpansionPurpose) -> Self {
Self { Self {
host, host,
purpose,
allowed: BTreeMap::new(), allowed: BTreeMap::new(),
diagnostics: BTreeSet::new(), diagnostics: BTreeSet::new(),
} }
@@ -620,8 +630,10 @@ impl<'a> Expansion<'a> {
.insert(PermissionDiagnostic::DisabledFeature( .insert(PermissionDiagnostic::DisabledFeature(
record.owner.clone(), record.owner.clone(),
)); ));
if self.purpose == ExpansionPurpose::Grant {
continue; continue;
} }
}
let mut next = path.clone(); let mut next = path.clone();
next.push(OriginStep::Set(set.clone())); next.push(OriginStep::Set(set.clone()));
next.push(OriginStep::Feature(record.owner.clone())); next.push(OriginStep::Feature(record.owner.clone()));
@@ -656,8 +668,10 @@ impl<'a> Expansion<'a> {
if !self.host.catalog.features[&record.owner].enabled { if !self.host.catalog.features[&record.owner].enabled {
self.diagnostics self.diagnostics
.insert(PermissionDiagnostic::DisabledFeature(record.owner.clone())); .insert(PermissionDiagnostic::DisabledFeature(record.owner.clone()));
if self.purpose == ExpansionPurpose::Grant {
return; return;
} }
}
if path.contains(&OriginStep::Key(key.clone())) { if path.contains(&OriginStep::Key(key.clone())) {
return; return;
} }
@@ -910,3 +910,125 @@ fn current_parent_execution_delegation_and_explicit_revocation_each_limit_childr
PermissionError::RevokedGrant PermissionError::RevokedGrant
); );
} }
fn assert_exclusion_survives_feature_disable(exclusion: PermissionReference) {
let mut host = host("workspace-a");
let (_, ticket) = register(&mut host, "builtin:ticket", "ticket");
host.declare(
&ticket,
declaration(
&[("ticket.read", &[]), ("ticket.keep", &[])],
&[(
"companion",
vec![keys("ticket", &["ticket.read", "ticket.keep"])],
)],
),
)
.unwrap();
let (plugin, bundle) = register(&mut host, "plugin:bundle", "bundle");
host.declare(
&bundle,
declaration(
&[
("bundle.block", &["ticket.read"]),
("bundle.block.nested", &["bundle.block"]),
("bundle.query", &[]),
],
&[
("companion", vec![keys("bundle", &["bundle.query"])]),
("blocked", vec![keys("bundle", &["bundle.block.nested"])]),
("blocked-alias", vec![set("blocked")]),
],
),
)
.unwrap();
let unrestricted_policy = assignment(vec![set("companion")]);
let restricted_policy = PermissionAssignment {
exclusions: vec![exclusion],
..unrestricted_policy.clone()
};
// Issue before the deny is added, so a frozen child/Job cannot evade it.
let initial = host.resolve(Some(&unrestricted_policy));
let requested = [key("ticket.read"), key("ticket.keep")];
let child = host.issue_delegation(&initial, &requested).unwrap();
let job = host
.issue_job_grant("job-a".into(), &initial, &requested)
.unwrap();
for enabled in [true, false, true] {
host.set_enabled(&plugin, enabled).unwrap();
let unrestricted = host.resolve(Some(&unrestricted_policy));
let restricted = host.resolve(Some(&restricted_policy));
assert!(
!host
.allows(&ticket, &restricted, &key("ticket.read"))
.unwrap()
);
assert!(
host.allows(&ticket, &restricted, &key("ticket.keep"))
.unwrap()
);
assert_eq!(
host.allows(&bundle, &restricted, &key("bundle.query"))
.unwrap(),
enabled,
"disable must still withdraw the Plugin's positive contribution"
);
assert!(
restricted
.projection()
.diagnostics
.contains(&PermissionDiagnostic::Excluded(key("ticket.read")))
);
assert_eq!(
restricted
.projection()
.diagnostics
.contains(&PermissionDiagnostic::DisabledFeature(
FeatureIdentity::parse("plugin:bundle").unwrap()
)),
!enabled
);
assert_eq!(
host.issue_delegation(&restricted, &[key("ticket.read")])
.unwrap_err(),
PermissionError::DelegationExceeded(key("ticket.read"))
);
assert_eq!(
host.issue_job_grant("job-b".into(), &restricted, &[key("ticket.read")])
.unwrap_err(),
PermissionError::DelegationExceeded(key("ticket.read"))
);
for (execution, delegation) in [
(&restricted, &unrestricted),
(&unrestricted, &restricted),
(&restricted, &restricted),
] {
assert_keys(
&host.resolve_limited(execution, delegation, &child).unwrap(),
&["ticket.keep"],
);
assert_keys(
&host
.resolve_job("job-a", execution, delegation, &child, &job)
.unwrap(),
&["ticket.keep"],
);
}
}
}
#[test]
fn disabled_feature_cannot_erase_direct_cross_feature_exclusion() {
assert_exclusion_survives_feature_disable(keys("bundle", &["bundle.block"]));
}
#[test]
fn disabled_feature_cannot_erase_multilevel_cross_feature_exclusion() {
assert_exclusion_survives_feature_disable(keys("bundle", &["bundle.block.nested"]));
}
#[test]
fn disabled_feature_cannot_erase_set_cross_feature_exclusion() {
assert_exclusion_survives_feature_disable(set("blocked-alias"));
}