OXYGENHUB

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