Skip to content

Commit b6877a6

Browse files
authored
fix(core): honor wildcard capabilities in Ucan::can, matching is_attenuated_by (#200)
* fix(core): honor wildcard capabilities in Ucan::can, matching is_attenuated_by Ucan::can did strict equality on both resource and action, so a validly delegated wildcard capability (with: "*", or can: "*" / "repo/admin") could never match a concrete runtime request even though is_attenuated_by treats those same values as wildcards during delegation-chain verification. Not a security issue: the mismatch only produces false negatives (denying a capability the delegation chain actually grants), never false positives. Dormant today since no production code path calls can() yet — auth's caller_authorized_to_push is pure did_matches(), with the UCAN-capability check noted as future (Phase 2) work. Makes can() mirror is_attenuated_by's semantics: the stored capability plays the "parent" role, so with == "*" matches any resource and can == "*" or "repo/admin" matches any action. Closes #199 * test: add can_honors_action_wildcard unit test for Ucan::can
1 parent b07d41e commit b6877a6

1 file changed

Lines changed: 67 additions & 4 deletions

File tree

‎crates/gitlawb-core/src/ucan.rs‎

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,17 @@ impl Ucan {
187187
}
188188

189189
/// Check if this UCAN grants a specific capability on a resource.
190+
///
191+
/// Mirrors `Capability::is_attenuated_by`'s wildcard semantics: a stored
192+
/// capability of `with: "*"` or `can: "*"` / `"repo/admin"` covers any
193+
/// requested resource/action, since a valid delegation chain can produce
194+
/// exactly that capability (see `is_attenuated_by`).
190195
pub fn can(&self, resource: &str, action: &str) -> bool {
191-
self.payload
192-
.att
193-
.iter()
194-
.any(|cap| cap.with == resource && cap.can == action)
196+
self.payload.att.iter().any(|cap| {
197+
let resource_ok = cap.with == resource || cap.with == "*";
198+
let action_ok = cap.can == action || cap.can == "*" || cap.can == caps::REPO_ADMIN;
199+
resource_ok && action_ok
200+
})
195201
}
196202

197203
/// Encode to a compact JSON string (the wire format).
@@ -312,6 +318,63 @@ mod tests {
312318
assert!(!ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
313319
}
314320

321+
#[test]
322+
fn can_honors_resource_wildcard() {
323+
let issuer = Keypair::generate();
324+
let audience = Keypair::generate().did();
325+
326+
let ucan = Ucan::issue(
327+
&issuer,
328+
audience,
329+
vec![Capability::new("*", caps::GIT_PUSH)],
330+
None,
331+
)
332+
.unwrap();
333+
334+
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
335+
assert!(ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
336+
assert!(!ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
337+
}
338+
339+
#[test]
340+
fn can_honors_repo_admin_action_wildcard() {
341+
let issuer = Keypair::generate();
342+
let audience = Keypair::generate().did();
343+
344+
let ucan = Ucan::issue(
345+
&issuer,
346+
audience,
347+
vec![Capability::new(
348+
"gitlawb://repos/test/repo",
349+
caps::REPO_ADMIN,
350+
)],
351+
None,
352+
)
353+
.unwrap();
354+
355+
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
356+
assert!(ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
357+
assert!(!ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
358+
}
359+
360+
#[test]
361+
fn can_honors_action_wildcard() {
362+
let issuer = Keypair::generate();
363+
let audience = Keypair::generate().did();
364+
365+
let ucan = Ucan::issue(
366+
&issuer,
367+
audience,
368+
vec![Capability::new("gitlawb://repos/test/repo", "*")],
369+
None,
370+
)
371+
.unwrap();
372+
373+
assert!(ucan.can("gitlawb://repos/test/repo", caps::GIT_PUSH));
374+
assert!(ucan.can("gitlawb://repos/test/repo", caps::PR_MERGE));
375+
assert!(!ucan.can("gitlawb://repos/other/repo", caps::GIT_PUSH));
376+
}
377+
315378
#[test]
316379
fn bootstrap_ucan() {
317380
let issuer = Keypair::generate();

0 commit comments

Comments
 (0)