Policies grant subjects an issuer certifies (keyless step 4a) 3a6effc74835
A rule may list subject grants: a pattern over certificate subjects (repo:OWNER/NAME:bookmark:B, agent:NAME@ACCOUNT), the issuer, and the scope. Encoded only when present, so existing policies keep their bytes and hashes; a threshold counts keys and subjects. Policy::subject_grant answers whether a certified subject may sign for a reference; revoking the issuer ends its grants. oxy policy grant-subject adds one, and policy show lists them. Spec 14.5. Tests: round trip, unchanged bytes without subjects, pattern, issuer, scope and revocation, a subjects-only rule; the CLI end to end. Refs andymccall/oxygenhub#241
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-17 16:08 UTC
- Signature
- ✔ signed by Claude Code
- Hash
3a6effc748350aec3330a4faa1c265ee23c55003ec02605b86c4979550fcadf3- Depends on
- 28fc23d7706a 75167672ebcd 7ba155f20aa0 83e0677b07fe 86e682c242c6 c6aa5bd9d1c8 fce44ae9ad29 ff0af1ee299d
- Depended on by
- f0a517d28237
- In bookmarks
- carried-not-removed doctor-trust identical-sides keyless-tags main merge-remote-bookmark paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state
- In tags
- v0.10.0 v0.11.0 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
- Edits
- 24 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified crates/oxy-core/src/policy.rs +162 −4
| … | 53 unchanged lines |
||
| 54 | 54 | pub threshold: u32, |
|
| 55 | 55 | pub require: Require, |
|
| 56 | 56 | pub allow_conflicts: bool, |
|
| 57 | + | /// Who else may sign, by what an issuer certified (§14.7): a short-lived |
|
| 58 | + | /// key whose certificate is the issuer's, for a subject the pattern |
|
| 59 | + | /// matches, counts as one signer within the scope given. |
|
| 60 | + | pub subjects: Vec<SubjectGrant>, |
|
| 57 | 61 | } |
|
| 58 | 62 | ||
| 63 | + | /// A rule's grant to the holders of certificates an issuer gives (§14.7): |
|
| 64 | + | /// `repo:andy/demo:bookmark:main`, `agent:claude@andy`, with `*` as in |
|
| 65 | + | /// reference patterns. |
|
| 59 | 66 | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 67 | + | pub struct SubjectGrant { |
|
| 68 | + | pub pattern: String, |
|
| 69 | + | pub issuer: KeyId, |
|
| 70 | + | pub scope: identity::Scope, |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | impl SubjectGrant { |
|
| 74 | + | fn to_value(&self) -> Value { |
|
| 75 | + | Value::map([ |
|
| 76 | + | (0, Value::Text(self.pattern.clone())), |
|
| 77 | + | (1, Value::Bytes(self.issuer.0.to_vec())), |
|
| 78 | + | ( |
|
| 79 | + | 2, |
|
| 80 | + | Value::Array( |
|
| 81 | + | self.scope |
|
| 82 | + | .names() |
|
| 83 | + | .into_iter() |
|
| 84 | + | .map(|n| Value::Text(n.to_string())) |
|
| 85 | + | .collect(), |
|
| 86 | + | ), |
|
| 87 | + | ), |
|
| 88 | + | ]) |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | fn from_value(v: &Value) -> Result<SubjectGrant, ObjectError> { |
|
| 92 | + | let m = map(v, "subject")?; |
|
| 93 | + | let mut scope = identity::Scope::NONE; |
|
| 94 | + | for n in array(field(m, 2, "subject scope")?, "subject scope")? { |
|
| 95 | + | let n = text(n, "subject scope")?; |
|
| 96 | + | scope = scope |
|
| 97 | + | | identity::Scope::named(n) |
|
| 98 | + | .ok_or_else(|| ObjectError::Schema(format!("subject: unknown scope {n:?}")))?; |
|
| 99 | + | } |
|
| 100 | + | Ok(SubjectGrant { |
|
| 101 | + | pattern: text(field(m, 0, "subject pattern")?, "subject pattern")?.to_owned(), |
|
| 102 | + | issuer: oref(field(m, 1, "subject issuer")?, "subject issuer")?, |
|
| 103 | + | scope, |
|
| 104 | + | }) |
|
| 105 | + | } |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 60 | 109 | pub struct Policy { |
|
| 61 | 110 | pub repo: [u8; 16], |
|
| 62 | 111 | pub serial: u64, |
|
| … | 50 unchanged lines |
||
| 113 | 162 | self.rules |
|
| 114 | 163 | .iter() |
|
| 115 | 164 | .map(|r| { |
|
| 116 | - | Value::map([ |
|
| 165 | + | let mut v = Value::map([ |
|
| 117 | 166 | (0, Value::Text(r.pattern.clone())), |
|
| 118 | 167 | (1, Self::keys_value(&r.keys)), |
|
| 119 | 168 | (2, Value::Uint(r.threshold as u64)), |
|
| 120 | 169 | (3, Value::Uint(r.require.tag())), |
|
| 121 | 170 | (4, Value::Bool(r.allow_conflicts)), |
|
| 122 | - | ]) |
|
| 171 | + | ]); |
|
| 172 | + | // Only when there are any, so a policy without |
|
| 173 | + | // them encodes, and hashes, as before. |
|
| 174 | + | if !r.subjects.is_empty() { |
|
| 175 | + | if let Value::Map(m) = &mut v { |
|
| 176 | + | m.insert( |
|
| 177 | + | 5, |
|
| 178 | + | Value::Array( |
|
| 179 | + | r.subjects.iter().map(SubjectGrant::to_value).collect(), |
|
| 180 | + | ), |
|
| 181 | + | ); |
|
| 182 | + | } |
|
| 183 | + | } |
|
| 184 | + | v |
|
| 123 | 185 | }) |
|
| 124 | 186 | .collect(), |
|
| 125 | 187 | ), |
|
| … | 36 unchanged lines |
||
| 162 | 224 | allow_conflicts: field(rm, 4, "allow_conflicts")? |
|
| 163 | 225 | .as_bool() |
|
| 164 | 226 | .ok_or_else(|| ObjectError::Schema("allow_conflicts: expected bool".into()))?, |
|
| 227 | + | subjects: match rm.get(&5) { |
|
| 228 | + | None => vec![], |
|
| 229 | + | Some(v) => array(v, "subjects")? |
|
| 230 | + | .iter() |
|
| 231 | + | .map(SubjectGrant::from_value) |
|
| 232 | + | .collect::<Result<_, _>>()?, |
|
| 233 | + | }, |
|
| 165 | 234 | }); |
|
| 166 | 235 | } |
|
| 167 | 236 | let identities = array(field(m, 5, "identities")?, "identities")? |
|
| … | 18 unchanged lines |
||
| 186 | 255 | )); |
|
| 187 | 256 | } |
|
| 188 | 257 | for r in &p.rules { |
|
| 189 | - | if r.threshold == 0 || r.threshold as usize > r.keys.len() { |
|
| 258 | + | if r.threshold == 0 || r.threshold as usize > r.keys.len() + r.subjects.len() { |
|
| 190 | 259 | return Err(ObjectError::Schema(format!( |
|
| 191 | - | "rule {:?}: threshold must be between 1 and the number of keys", |
|
| 260 | + | "rule {:?}: threshold must be between 1 and the number of keys and subjects", |
|
| 192 | 261 | r.pattern |
|
| 193 | 262 | ))); |
|
| 194 | 263 | } |
|
| … | 20 unchanged lines |
||
| 215 | 284 | .find(|r| glob_matches(&r.pattern, reference)) |
|
| 216 | 285 | } |
|
| 217 | 286 | ||
| 287 | + | /// Whether the rule for `reference` lets a certificate from `issuer` |
|
| 288 | + | /// for `subject` sign within `scope` (§14.7). The certificate itself, |
|
| 289 | + | /// and the time it is checked at, are the caller's to verify. |
|
| 290 | + | pub fn subject_grant( |
|
| 291 | + | &self, |
|
| 292 | + | reference: &str, |
|
| 293 | + | issuer: &KeyId, |
|
| 294 | + | subject: &str, |
|
| 295 | + | scope: identity::Scope, |
|
| 296 | + | ) -> bool { |
|
| 297 | + | !self.is_revoked(issuer) |
|
| 298 | + | && self.rule_for(reference).is_some_and(|r| { |
|
| 299 | + | r.subjects.iter().any(|g| { |
|
| 300 | + | g.issuer == *issuer && g.scope.has(scope) && glob_matches(&g.pattern, subject) |
|
| 301 | + | }) |
|
| 302 | + | }) |
|
| 303 | + | } |
|
| 304 | + | ||
| 218 | 305 | /// Whether `key` may update `reference`: listed in the matching rule and not revoked. |
|
| 219 | 306 | pub fn authorizes(&self, key: &KeyId, reference: &str) -> bool { |
|
| 220 | 307 | !self.is_revoked(key) |
|
| … | 71 unchanged lines |
||
| 292 | 379 | threshold: 1, |
|
| 293 | 380 | require: Require::Any, |
|
| 294 | 381 | allow_conflicts: true, |
|
| 382 | + | subjects: vec![], |
|
| 295 | 383 | }], |
|
| 296 | 384 | identities: vec![root], |
|
| 297 | 385 | revoked: vec![], |
|
| … | 79 unchanged lines |
||
| 377 | 465 | assert!(!glob_matches("main", "main2")); |
|
| 378 | 466 | assert!(glob_matches("*-rc*", "1.0-rc2")); |
|
| 379 | 467 | assert!(glob_matches("", "")); |
|
| 468 | + | } |
|
| 469 | + | ||
| 470 | + | /// Subjects an issuer certifies (oxygenhub#241): kept through |
|
| 471 | + | /// encoding, absent from a policy without them, and granted only for |
|
| 472 | + | /// their issuer, pattern and scope, and not when the issuer is revoked. |
|
| 473 | + | #[test] |
|
| 474 | + | fn a_rule_grants_certified_subjects() { |
|
| 475 | + | use crate::identity::Scope; |
|
| 476 | + | let root = sk(1).identity("Root", "r@x", 0); |
|
| 477 | + | let hub = sk(3).identity("Hub", "h@x", 0); |
|
| 478 | + | let mut p = Policy::single_root([9; 16], root.clone(), 10); |
|
| 479 | + | let plain = p.encode(); |
|
| 480 | + | p.rules[0].subjects.push(SubjectGrant { |
|
| 481 | + | pattern: "repo:andy/demo:bookmark:main".into(), |
|
| 482 | + | issuer: hub.hash(), |
|
| 483 | + | scope: Scope::REFS | Scope::CHANGES, |
|
| 484 | + | }); |
|
| 485 | + | p.rules[0].subjects.push(SubjectGrant { |
|
| 486 | + | pattern: "agent:*@andy".into(), |
|
| 487 | + | issuer: hub.hash(), |
|
| 488 | + | scope: Scope::CHANGES, |
|
| 489 | + | }); |
|
| 490 | + | let back = Policy::decode(&p.encode()).unwrap(); |
|
| 491 | + | assert_eq!(back, p); |
|
| 492 | + | let mut again = back.clone(); |
|
| 493 | + | again.rules[0].subjects.clear(); |
|
| 494 | + | assert_eq!( |
|
| 495 | + | again.encode(), |
|
| 496 | + | plain, |
|
| 497 | + | "no subjects, the same bytes as before" |
|
| 498 | + | ); |
|
| 499 | + | ||
| 500 | + | let main = "repo:andy/demo:bookmark:main"; |
|
| 501 | + | assert!(p.subject_grant("main", &hub.hash(), main, Scope::REFS)); |
|
| 502 | + | assert!(p.subject_grant("main", &hub.hash(), "agent:claude@andy", Scope::CHANGES)); |
|
| 503 | + | assert!( |
|
| 504 | + | !p.subject_grant("main", &hub.hash(), "agent:claude@andy", Scope::REFS), |
|
| 505 | + | "not its scope" |
|
| 506 | + | ); |
|
| 507 | + | assert!( |
|
| 508 | + | !p.subject_grant( |
|
| 509 | + | "main", |
|
| 510 | + | &hub.hash(), |
|
| 511 | + | "repo:andy/demo:bookmark:dev", |
|
| 512 | + | Scope::REFS |
|
| 513 | + | ), |
|
| 514 | + | "not its pattern" |
|
| 515 | + | ); |
|
| 516 | + | assert!( |
|
| 517 | + | !p.subject_grant("main", &root.hash(), main, Scope::REFS), |
|
| 518 | + | "not its issuer" |
|
| 519 | + | ); |
|
| 520 | + | p.revoked.push(hub.hash()); |
|
| 521 | + | assert!( |
|
| 522 | + | !p.subject_grant("main", &hub.hash(), main, Scope::REFS), |
|
| 523 | + | "a revoked issuer grants nothing" |
|
| 524 | + | ); |
|
| 525 | + | ||
| 526 | + | // A rule of subjects alone is a rule. |
|
| 527 | + | let mut only = Policy::single_root([9; 16], root, 10); |
|
| 528 | + | only.rules[0].keys.clear(); |
|
| 529 | + | only.rules[0].subjects.push(SubjectGrant { |
|
| 530 | + | pattern: "*".into(), |
|
| 531 | + | issuer: hub.hash(), |
|
| 532 | + | scope: Scope::REFS, |
|
| 533 | + | }); |
|
| 534 | + | assert!(Policy::decode(&only.encode()).is_ok()); |
|
| 535 | + | only.rules[0].subjects.clear(); |
|
| 536 | + | assert!(Policy::decode(&only.encode()).is_err(), "nobody to sign"); |
|
| 380 | 537 | } |
|
| 381 | 538 | ||
| 382 | 539 | #[test] |
|
| … | 9 unchanged lines |
||
| 392 | 549 | threshold: 1, |
|
| 393 | 550 | require: Require::Signed, |
|
| 394 | 551 | allow_conflicts: false, |
|
| 552 | + | subjects: vec![], |
|
| 395 | 553 | }, |
|
| 396 | 554 | ); |
|
| 397 | 555 | p.rules[1].keys.push(dev.hash()); |
|
| … | 54 unchanged lines |
modified crates/oxy-core/src/repo/policy.rs +44 −0
| … | 335 unchanged lines |
||
| 336 | 336 | threshold: 1, |
|
| 337 | 337 | require: Require::Any, |
|
| 338 | 338 | allow_conflicts: true, |
|
| 339 | + | subjects: vec![], |
|
| 339 | 340 | }), |
|
| 340 | 341 | } |
|
| 341 | 342 | if p.identity(&key).is_none() { |
|
| … | 2 unchanged lines |
||
| 344 | 345 | self.sign_policy(p, &format!("oxy policy grant {}", key.short(12))) |
|
| 345 | 346 | } |
|
| 346 | 347 | ||
| 348 | + | /// Let holders of certificates `issuer` gives, for subjects matching |
|
| 349 | + | /// `subject`, sign within `scope` on `pattern` (default every |
|
| 350 | + | /// bookmark; §14.7). The issuer's identity is embedded so verifiers need |
|
| 351 | + | /// no fetch. |
|
| 352 | + | pub fn policy_grant_subject( |
|
| 353 | + | &self, |
|
| 354 | + | subject: &str, |
|
| 355 | + | issuer: Identity, |
|
| 356 | + | scope: crate::identity::Scope, |
|
| 357 | + | pattern: Option<&str>, |
|
| 358 | + | ) -> Result<PolicyUpdate> { |
|
| 359 | + | if subject.trim().is_empty() { |
|
| 360 | + | return fail("a subject pattern is needed, such as repo:OWNER/NAME:bookmark:main"); |
|
| 361 | + | } |
|
| 362 | + | let mut p = self.next_policy()?; |
|
| 363 | + | let key = issuer.hash(); |
|
| 364 | + | let pattern = pattern.unwrap_or("*"); |
|
| 365 | + | let grant = crate::policy::SubjectGrant { |
|
| 366 | + | pattern: subject.into(), |
|
| 367 | + | issuer: key, |
|
| 368 | + | scope, |
|
| 369 | + | }; |
|
| 370 | + | match p.rules.iter_mut().find(|r| r.pattern == pattern) { |
|
| 371 | + | Some(rule) => { |
|
| 372 | + | rule.subjects |
|
| 373 | + | .retain(|g| !(g.pattern == grant.pattern && g.issuer == grant.issuer)); |
|
| 374 | + | rule.subjects.push(grant); |
|
| 375 | + | } |
|
| 376 | + | None => p.rules.push(Rule { |
|
| 377 | + | pattern: pattern.into(), |
|
| 378 | + | keys: vec![], |
|
| 379 | + | threshold: 1, |
|
| 380 | + | require: Require::Any, |
|
| 381 | + | allow_conflicts: true, |
|
| 382 | + | subjects: vec![grant], |
|
| 383 | + | }), |
|
| 384 | + | } |
|
| 385 | + | if p.identity(&key).is_none() { |
|
| 386 | + | p.identities.push(issuer); |
|
| 387 | + | } |
|
| 388 | + | self.sign_policy(p, &format!("oxy policy grant-subject {subject}")) |
|
| 389 | + | } |
|
| 390 | + | ||
| 347 | 391 | /// Revoke `key`: it may no longer update anything, and existing rules |
|
| 348 | 392 | /// drop it. A root key can be revoked by another root as long as the |
|
| 349 | 393 | /// remaining roots still meet the threshold. |
|
| … | 111 unchanged lines |
modified docs/spec.md +3 −1
| … | 656 unchanged lines |
||
| 657 | 657 | serial [2]: u64 |
|
| 658 | 658 | roots [3]: { keys: [KeyId], threshold: u32 } |
|
| 659 | 659 | rules [4]: [ { pattern: glob over reference names, keys: [KeyId], threshold: u32, |
|
| 660 | - | require: "signed" | "sealed" | "any" /* default */, allow_conflicts: bool } ] |
|
| 660 | + | require: "signed" | "sealed" | "any" /* default */, allow_conflicts: bool, |
|
| 661 | + | subjects [5]: [ { pattern: glob over subjects, issuer: KeyId, scope: [text] } ] /* only when any */ } ] |
|
| 661 | 662 | identities[5]: [Identity] // embedded so verification needs no fetch |
|
| 662 | 663 | revoked [6]: [KeyId] |
|
| 663 | 664 | expires [7]: u64 |
|
| … | 3 unchanged lines |
||
| 667 | 668 | - The initial policy is signed by the root keys; every update MUST be signed by `roots.threshold` root keys of the policy it replaces and have a higher `serial`. An update that a single holder cannot complete is a **proposal**: the signed policy (`[policy bytes, [Signature]]`, §16.3) is passed to other root holders, each of whom adds a signature with `oxy policy sign`; whoever completes the threshold applies it. Rotating a root key (§14.2) substitutes the successor in the roots and rules; such a policy is signed by both the previous and the successor key so a fresh clone can bootstrap from it. |
|
| 668 | 669 | - A change's signer is authorized by a rule if the rule lists it, or lists a successor of it, or lists the change's `author` (or a successor) when the signer is that author's delegate. |
|
| 669 | 670 | - A reference attestation's signer is authorized by a rule if the rule lists it, or lists an identity whose `delegates` name it with the `refs` scope, not expired at the attestation's `hlc.wall_ms` (§14.2). Delegation is the identity's own signed statement and the policy names that identity, so a delegate with `refs` moves references exactly as far as its delegator may; revoking either key, or the delegation expiring, ends it. Hosts use this to land and to commit browser edits on a user's behalf with their own key. A delegate without `refs` (an agent) is never authorized for a reference or tag, even when its delegator is. |
|
| 671 | + | - A rule may also grant **subjects an issuer certifies** (§14.7): `oxy policy grant-subject SUBJECT --issuer IDENTITY --scope SCOPES` adds `{pattern, issuer, scope}` and embeds the issuer's identity. A signer is then authorized when it is a short-lived key whose certificate verifies with that issuer, names a subject the pattern matches, gives the scope needed (`changes` for changes, `refs` for reference attestations and tags) and covers a time the signer did not choose; it counts as one signer toward the threshold. A rule with subjects only has no keys, and its threshold counts keys and subjects together. A policy without subjects encodes as before (field 5 is left out), so its hash is unchanged. |
|
| 670 | 672 | - A change or attestation is **authorized** for reference `r` if its signer is listed in the first rule whose pattern matches `r`, and the rule's threshold of distinct authorized signers is met across the attestation and each change in the closure of `new_state` not previously in the closure of `old_state`. `require` selects which authenticators count for changes: individual signatures only, seals only, or either. Every change MUST additionally satisfy the author-or-delegate rule of §14.4 regardless of `require`. |
|
| 671 | 673 | - Clients MUST verify policy on fetch, MUST refuse to update a `remotes/` reference whose attestation is unauthorized, and MUST warn loudly on an expired policy. Servers MUST enforce the same rules on push. |
|
| 672 | 674 | - A repository without a `policy` reference is **unverified**; every command that talks to a remote MUST print a one-line warning until one is created. `oxy policy init` creates one with the author's key as the sole root; `oxy policy grant` and `oxy policy revoke` produce successors. |
|
| … | 344 unchanged lines |
modified src/cli/identity.rs +48 −0
| … | 614 unchanged lines |
||
| 615 | 615 | ("threshold", json::n(rule.threshold)), |
|
| 616 | 616 | ("require", json::s(rule.require.name())), |
|
| 617 | 617 | ("allow_conflicts", json::b(rule.allow_conflicts)), |
|
| 618 | + | ( |
|
| 619 | + | "subjects", |
|
| 620 | + | json::arr(rule.subjects.iter().map(|g| { |
|
| 621 | + | json::obj([ |
|
| 622 | + | ("subject", json::s(&g.pattern)), |
|
| 623 | + | ("issuer", ident(&g.issuer)), |
|
| 624 | + | ("scope", json::s(&g.scope.to_string())), |
|
| 625 | + | ]) |
|
| 626 | + | })), |
|
| 627 | + | ), |
|
| 618 | 628 | ]) |
|
| 619 | 629 | })), |
|
| 620 | 630 | ), |
|
| … | 57 unchanged lines |
||
| 678 | 688 | for k in &rule.keys { |
|
| 679 | 689 | println!(" {} {}", ui::hash(k.short(12)), ui::author(name(k))); |
|
| 680 | 690 | } |
|
| 691 | + | for g in &rule.subjects { |
|
| 692 | + | println!( |
|
| 693 | + | " {} {} {}", |
|
| 694 | + | ui::author(&g.pattern), |
|
| 695 | + | ui::dim(format!( |
|
| 696 | + | "certified by {} ({}),", |
|
| 697 | + | name(&g.issuer), |
|
| 698 | + | g.issuer.short(12) |
|
| 699 | + | )), |
|
| 700 | + | ui::dim(format!("scope {}", g.scope)) |
|
| 701 | + | ); |
|
| 702 | + | } |
|
| 681 | 703 | } |
|
| 682 | 704 | if !p.revoked.is_empty() { |
|
| 683 | 705 | println!("{}", ui::section("revoked")); |
|
| … | 25 unchanged lines |
||
| 709 | 731 | id.email, |
|
| 710 | 732 | key.short(12), |
|
| 711 | 733 | ui::bookmark(pattern.unwrap_or("*")) |
|
| 734 | + | ); |
|
| 735 | + | report_update(&u, out)?; |
|
| 736 | + | Ok(0) |
|
| 737 | + | } |
|
| 738 | + | ||
| 739 | + | pub fn policy_grant_subject( |
|
| 740 | + | subject: &str, |
|
| 741 | + | issuer_file: &Path, |
|
| 742 | + | scope: &str, |
|
| 743 | + | pattern: Option<&str>, |
|
| 744 | + | out: Option<PathBuf>, |
|
| 745 | + | ) -> Result<i32> { |
|
| 746 | + | let repo = open()?; |
|
| 747 | + | let issuer = Identity::decode(&std::fs::read(issuer_file)?) |
|
| 748 | + | .map_err(|e| anyhow::anyhow!("{}: not an identity file: {e}", issuer_file.display()))?; |
|
| 749 | + | let scope = oxy_core::identity::Scope::parse(scope).map_err(|e| anyhow::anyhow!(e))?; |
|
| 750 | + | let key = issuer.hash(); |
|
| 751 | + | let u = repo.policy_grant_subject(subject, issuer.clone(), scope, pattern)?; |
|
| 752 | + | println!( |
|
| 753 | + | "{} {} certified by {} ({}) on {}, scope {}", |
|
| 754 | + | ui::done("Granted"), |
|
| 755 | + | ui::author(subject), |
|
| 756 | + | issuer.name, |
|
| 757 | + | key.short(12), |
|
| 758 | + | ui::bookmark(pattern.unwrap_or("*")), |
|
| 759 | + | scope |
|
| 712 | 760 | ); |
|
| 713 | 761 | report_update(&u, out)?; |
|
| 714 | 762 | Ok(0) |
|
| … | 110 unchanged lines |
modified src/cli/mod.rs +24 −0
| … | 764 unchanged lines |
||
| 765 | 765 | #[arg(long)] |
|
| 766 | 766 | out: Option<PathBuf>, |
|
| 767 | 767 | }, |
|
| 768 | + | /// Allow holders of an issuer's certificates for SUBJECT to sign (keyless) |
|
| 769 | + | GrantSubject { |
|
| 770 | + | /// Who: `repo:OWNER/NAME:bookmark:main`, `agent:NAME@ACCOUNT`; `*` matches any run of characters |
|
| 771 | + | subject: String, |
|
| 772 | + | /// The issuer's identity file, e.g. a hub's /.well-known/oxyhub-key |
|
| 773 | + | #[arg(long)] |
|
| 774 | + | issuer: PathBuf, |
|
| 775 | + | /// What its keys may sign: changes, refs, seal |
|
| 776 | + | #[arg(long, default_value = "changes,refs")] |
|
| 777 | + | scope: String, |
|
| 778 | + | /// Reference pattern the grant applies to (default: every bookmark) |
|
| 779 | + | #[arg(long)] |
|
| 780 | + | pattern: Option<String>, |
|
| 781 | + | /// Where to write the proposal when more root signatures are needed |
|
| 782 | + | #[arg(long)] |
|
| 783 | + | out: Option<PathBuf>, |
|
| 784 | + | }, |
|
| 768 | 785 | /// Revoke a key (KeyId prefix of an identity in the policy) |
|
| 769 | 786 | Revoke { |
|
| 770 | 787 | /// KeyId prefix of the identity |
|
| … | 257 unchanged lines |
||
| 1028 | 1045 | pattern, |
|
| 1029 | 1046 | out, |
|
| 1030 | 1047 | } => identity::policy_grant(&identity_file, pattern.as_deref(), out), |
|
| 1048 | + | PolicyCmd::GrantSubject { |
|
| 1049 | + | subject, |
|
| 1050 | + | issuer, |
|
| 1051 | + | scope, |
|
| 1052 | + | pattern, |
|
| 1053 | + | out, |
|
| 1054 | + | } => identity::policy_grant_subject(&subject, &issuer, &scope, pattern.as_deref(), out), |
|
| 1031 | 1055 | PolicyCmd::Revoke { key, out } => identity::policy_revoke(&key, out), |
|
| 1032 | 1056 | PolicyCmd::Root { cmd } => match cmd { |
|
| 1033 | 1057 | RootCmd::Add { identity_file, out } => { |
|
| … | 59 unchanged lines |
modified tests/cli_json.rs +30 −0
| … | 199 unchanged lines |
||
| 200 | 200 | assert_eq!(oxy(r, &home, &["policy", "init"]).0, 0); |
|
| 201 | 201 | let policy = json(r, &home, &["policy", "show", "--json"]); |
|
| 202 | 202 | assert_eq!(policy["roots"][0]["name"], "Ada"); |
|
| 203 | + | // Keyless (oxygenhub#241): a hub's certified subject may sign too. |
|
| 204 | + | let hub = |
|
| 205 | + | oxy_core::identity::SecretKey::generate().identity("oxygenhub.io", "[email protected]", 1); |
|
| 206 | + | let hub_file = r.join("hub.identity"); |
|
| 207 | + | std::fs::write(&hub_file, hub.encode()).unwrap(); |
|
| 208 | + | let grant = [ |
|
| 209 | + | "policy", |
|
| 210 | + | "grant-subject", |
|
| 211 | + | "repo:ada/demo:bookmark:main", |
|
| 212 | + | "--issuer", |
|
| 213 | + | hub_file.to_str().unwrap(), |
|
| 214 | + | "--scope", |
|
| 215 | + | "refs", |
|
| 216 | + | ]; |
|
| 217 | + | assert_eq!(oxy(r, &home, &grant).0, 0); |
|
| 218 | + | let policy = json(r, &home, &["policy", "show", "--json"]); |
|
| 219 | + | let subjects = &policy["rules"][0]["subjects"][0]; |
|
| 220 | + | assert_eq!( |
|
| 221 | + | ( |
|
| 222 | + | subjects["subject"].as_str(), |
|
| 223 | + | subjects["issuer"]["name"].as_str(), |
|
| 224 | + | subjects["scope"].as_str() |
|
| 225 | + | ), |
|
| 226 | + | ( |
|
| 227 | + | Some("repo:ada/demo:bookmark:main"), |
|
| 228 | + | Some("oxygenhub.io"), |
|
| 229 | + | Some("refs") |
|
| 230 | + | ) |
|
| 231 | + | ); |
|
| 232 | + | std::fs::remove_file(&hub_file).unwrap(); |
|
| 203 | 233 | assert_eq!( |
|
| 204 | 234 | oxy(r, &home, &["tag", "create", "v1", "-m", "first tag"]).0, |
|
| 205 | 235 | 0 |
|
| … | 12 unchanged lines |