a73x

13a5ff9d

Ask one question about what a delegate may do

a73x   2026-08-18 19:39

Commit message
Ask one question about what a delegate may do

The ceiling was enforced at three sites — the update hook, the release
handler, the repo-creation branch — so the answer for a command shape nobody
had added yet was "allowed", by omission.

The exec dispatcher now classifies every request and asks
delegate::permits once, before any command-specific handling. Both matches
are exhaustive with no wildcard arm: a new ExecCommand does not compile until
it is classified, and a new Action does not compile until its answer is
written down. A table over every command shape records what those answers
currently are.

The hook keeps its own per-ref check — it runs in the receive-pack process,
where it cannot call this — and now says so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

src/server/governance/delegate.rs
Old New
@@ -1,16 +1,22 @@
1 //! Delegate certificates: the one policy decision, in one place. 1 //! Delegate certificates: the two policy decisions, in one place.
2 //! 2 //!
3 //! A certificate is a delegate of the person it names. This function is the 3 //! A certificate is a delegate of the person it names. `validate` is the
4 //! entire answer to "is this certificate a valid delegate right now" — auth 4 //! entire answer to "is this certificate a valid delegate right now" — auth
5 //! calls it when the connection opens, and the regime calls it again on every 5 //! calls it when the connection opens, and the regime calls it again on every
6 //! subsequent command, which is what makes revocation (cadir/ entry removed, 6 //! subsequent command, which is what makes revocation (cadir/ entry removed,
7 //! person's keys removed, cert expired) take effect on the next command 7 //! person's keys removed, cert expired) take effect on the next command
8 //! rather than the next connection. 8 //! rather than the next connection.
9 //!
10 //! `permits` is the entire answer to the second question, "may a delegate ask
11 //! for this at all" — the ceiling. The dispatcher routes every exec request
12 //! through it, so a command shape nobody has thought about yet is refused
13 //! rather than allowed by omission.
9 14
10 use russh::keys::ssh_key::certificate::CertType; 15 use russh::keys::ssh_key::certificate::CertType;
11 use russh::keys::ssh_key::Certificate; 16 use russh::keys::ssh_key::Certificate;
12 17
13 use super::Governance; 18 use super::Governance;
19 use crate::ssh::session::{ExecCommand, GitCmd, ReleaseCmd};
14 20
15 #[derive(Debug)] 21 #[derive(Debug)]
16 pub struct Delegate { 22 pub struct Delegate {
@@ -81,6 +87,72 @@ pub fn validate(
81 Ok(Delegate { person, key_id }) 87 Ok(Delegate { person, key_id })
82 } 88 }
83 89
90 /// What a command amounts to, at the granularity the delegate ceiling judges.
91 ///
92 /// Coarser than `ExecCommand` on purpose: the ceiling is a policy about kinds
93 /// of act, not about argument shapes, so `upload` and `delete` are one thing
94 /// here.
95 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
96 pub enum Action {
97 /// `git-upload-pack`: read a repository.
98 Fetch,
99 /// `git-receive-pack` into a repository that already exists. *Which* refs
100 /// may move is a separate question, answered by the update hook in the
101 /// receive-pack process.
102 Push,
103 /// `git-receive-pack` into a repository that does not exist yet, which is
104 /// the `C` permission rather than a write to anything.
105 CreateRepo,
106 /// `collab-release list`.
107 ReleaseList,
108 /// `collab-release upload` or `delete`.
109 ReleaseMutate,
110 }
111
112 /// Classify an exec request, given whether its repository exists yet.
113 ///
114 /// Exhaustive with no wildcard arm: a new `ExecCommand` shape does not compile
115 /// until someone says which action it is, which is what stops a new verb from
116 /// reaching the ceiling unclassified.
117 pub fn action_of(command: &ExecCommand, repo_exists: bool) -> Action {
118 match command {
119 ExecCommand::Git {
120 cmd: GitCmd::UploadPack,
121 ..
122 } => Action::Fetch,
123 ExecCommand::Git {
124 cmd: GitCmd::ReceivePack,
125 ..
126 } if repo_exists => Action::Push,
127 ExecCommand::Git {
128 cmd: GitCmd::ReceivePack,
129 ..
130 } => Action::CreateRepo,
131 ExecCommand::Release(ReleaseCmd::List { .. }) => Action::ReleaseList,
132 ExecCommand::Release(ReleaseCmd::Upload { .. } | ReleaseCmd::Delete { .. }) => {
133 Action::ReleaseMutate
134 }
135 }
136 }
137
138 /// May a delegate certificate do this at all?
139 ///
140 /// The whole ceiling, in one expression the dispatcher must pass through: a
141 /// delegate reads what its person reads and writes into `refs/collab/*`;
142 /// creating repositories and publishing releases stay the person's own. The
143 /// per-ref half of the push answer lives in the update hook (`hook::run`),
144 /// which runs in the receive-pack process and cannot call this.
145 ///
146 /// Exhaustive with no wildcard arm, again on purpose: a new `Action` has no
147 /// default answer, so widening the ceiling has to be a deliberate edit here
148 /// rather than a side effect of adding a command.
149 pub fn permits(action: Action) -> bool {
150 match action {
151 Action::Fetch | Action::Push | Action::ReleaseList => true,
152 Action::CreateRepo | Action::ReleaseMutate => false,
153 }
154 }
155
84 #[cfg(test)] 156 #[cfg(test)]
85 mod tests { 157 mod tests {
86 use super::*; 158 use super::*;
@@ -397,4 +469,108 @@ mod tests {
397 let err = validate(&cert, &gov, NOW).unwrap_err(); 469 let err = validate(&cert, &gov, NOW).unwrap_err();
398 assert!(err.contains("key ID"), "got {err}"); 470 assert!(err.contains("key ID"), "got {err}");
399 } 471 }
472
473 /// Every command shape the dispatcher can build, and the ceiling's answer
474 /// for each. `action_of` and `permits` are exhaustive matches, so a new
475 /// command or action breaks the build; this table is what makes the
476 /// *answer* visible rather than merely decided.
477 #[test]
478 fn the_ceiling_answers_every_command_shape() {
479 let repo = || "r.git".to_string();
480 let cases: &[(ExecCommand, bool, Action, bool)] = &[
481 (
482 ExecCommand::Git {
483 cmd: GitCmd::UploadPack,
484 repo: repo(),
485 },
486 true,
487 Action::Fetch,
488 true,
489 ),
490 (
491 ExecCommand::Git {
492 cmd: GitCmd::UploadPack,
493 repo: repo(),
494 },
495 false,
496 Action::Fetch,
497 true,
498 ),
499 (
500 ExecCommand::Git {
501 cmd: GitCmd::ReceivePack,
502 repo: repo(),
503 },
504 true,
505 Action::Push,
506 true,
507 ),
508 (
509 ExecCommand::Git {
510 cmd: GitCmd::ReceivePack,
511 repo: repo(),
512 },
513 false,
514 Action::CreateRepo,
515 false,
516 ),
517 (
518 ExecCommand::Release(ReleaseCmd::List { repo: repo() }),
519 true,
520 Action::ReleaseList,
521 true,
522 ),
523 (
524 ExecCommand::Release(ReleaseCmd::Upload {
525 repo: repo(),
526 version: "v1".to_string(),
527 filename: "a.tar.gz".to_string(),
528 force: false,
529 }),
530 true,
531 Action::ReleaseMutate,
532 false,
533 ),
534 (
535 ExecCommand::Release(ReleaseCmd::Upload {
536 repo: repo(),
537 version: "v1".to_string(),
538 filename: "a.tar.gz".to_string(),
539 force: true,
540 }),
541 true,
542 Action::ReleaseMutate,
543 false,
544 ),
545 (
546 ExecCommand::Release(ReleaseCmd::Delete {
547 repo: repo(),
548 version: "v1".to_string(),
549 filename: None,
550 }),
551 true,
552 Action::ReleaseMutate,
553 false,
554 ),
555 (
556 ExecCommand::Release(ReleaseCmd::Delete {
557 repo: repo(),
558 version: "v1".to_string(),
559 filename: Some("a.tar.gz".to_string()),
560 }),
561 true,
562 Action::ReleaseMutate,
563 false,
564 ),
565 ];
566
567 for (command, repo_exists, expected, permitted) in cases {
568 let action = action_of(command, *repo_exists);
569 assert_eq!(
570 action, *expected,
571 "{command:?} with repo_exists={repo_exists}"
572 );
573 assert_eq!(permits(action), *permitted, "{action:?}");
574 }
575 }
400 } 576 }
src/server/governance/hook.rs
Old New
@@ -122,9 +122,13 @@ pub fn run(refname: &str, old: &str, new: &str) -> Result<(), String> {
122 // Empty is legitimate: an ungoverned server has no names to pass. 122 // Empty is legitimate: an ungoverned server has no names to pass.
123 let principal = std::env::var(ENV_PRINCIPAL).unwrap_or_default(); 123 let principal = std::env::var(ENV_PRINCIPAL).unwrap_or_default();
124 124
125 // The delegate ceiling. Checked ahead of and independent of governance 125 // The delegate ceiling, per ref: the half of `delegate::permits`'s Push
126 // state: ENV_DELEGATE is set only by the server, only for a delegate 126 // answer that only receive-pack can see. This process is spawned by git,
127 // session, so its presence alone is a sufficient trigger. Nesting this 127 // not by the session, so it re-states the policy rather than calling it.
128 //
129 // Checked ahead of and independent of governance state: ENV_DELEGATE is
130 // set only by the server, only for a delegate session, so its presence
131 // alone is a sufficient trigger. Nesting this
128 // inside `GovernanceState::Active` would let a settings repository that 132 // inside `GovernanceState::Active` would let a settings repository that
129 // goes briefly unreadable-as-Absent between the session's regime check 133 // goes briefly unreadable-as-Absent between the session's regime check
130 // and this hook's own re-read skip the ceiling entirely — hard-coded 134 // and this hook's own re-read skip the ceiling entirely — hard-coded
src/server/ssh/session.rs
Old New
@@ -258,30 +258,6 @@ impl SshHandler {
258 ReleaseCmd::List { .. } => Access::Read, 258 ReleaseCmd::List { .. } => Access::Read,
259 ReleaseCmd::Upload { .. } | ReleaseCmd::Delete { .. } => Access::Rewind, 259 ReleaseCmd::Upload { .. } | ReleaseCmd::Delete { .. } => Access::Rewind,
260 }; 260 };
261 // Artifacts are not collab refs. A delegate may list what its person
262 // may see; publishing and deleting are outside the ceiling. Unlike the
263 // unknown/unauthorized-repo case above, a delegate has already
264 // authenticated and can `release list` this same repo, so there is no
265 // repository existence to hide here — naming the ceiling is honest,
266 // not a probe.
267 if let Regime::Governed {
268 delegate: Some(key_id),
269 ..
270 } = regime
271 {
272 if needed != Access::Read {
273 warn!("Rejected release command from delegate {key_id}");
274 return reply_and_close(
275 session,
276 channel,
277 &format!(
278 "error: delegate {key_id} may only write refs/collab/*; \
279 releases are out of reach\n"
280 ),
281 1,
282 );
283 }
284 }
285 let authorized = match regime { 261 let authorized = match regime {
286 Regime::Closed => false, 262 Regime::Closed => false,
287 Regime::Ungoverned => match needed { 263 Regime::Ungoverned => match needed {
@@ -788,6 +764,34 @@ impl Handler for SshHandler {
788 764
789 let regime = self.regime(); 765 let regime = self.regime();
790 766
767 // The delegate ceiling, asked once for whatever the command turns
768 // out to be — see governance::delegate::permits. Per-command sites
769 // would each have to remember to ask; this one cannot be skipped
770 // by adding a verb.
771 if let Regime::Governed {
772 delegate: Some(key_id),
773 ..
774 } = &regime
775 {
776 let action = governance::delegate::action_of(&exec_cmd, resolved_path.exists());
777 if !governance::delegate::permits(action) {
778 warn!("Rejected {action:?} from delegate {key_id}: outside the ceiling");
779 // A release channel carries plain text, so the refusal can
780 // say what it is; a git exec's channel is pkt-line from the
781 // first byte, where prose would be a protocol error. No
782 // repository existence leaks either way: the answer is the
783 // same whether or not the repo is there.
784 let reply = match action {
785 governance::delegate::Action::ReleaseMutate => format!(
786 "error: delegate {key_id} may only write refs/collab/*; \
787 releases are out of reach\n"
788 ),
789 _ => String::new(),
790 };
791 return reply_and_close(session, channel, &reply, 1);
792 }
793 }
794
791 let git_cmd = match exec_cmd { 795 let git_cmd = match exec_cmd {
792 ExecCommand::Git { cmd, .. } => cmd, 796 ExecCommand::Git { cmd, .. } => cmd,
793 ExecCommand::Release(rel) => { 797 ExecCommand::Release(rel) => {
@@ -860,17 +864,17 @@ impl Handler for SshHandler {
860 // allocator, so under governance it is a permission of its own: 864 // allocator, so under governance it is a permission of its own:
861 // `C` on a pattern the requested name matches. 865 // `C` on a pattern the requested name matches.
862 if let Regime::Governed { 866 if let Regime::Governed {
863 governance, 867 governance, name, ..
864 name,
865 delegate,
866 } = &regime 868 } = &regime
867 { 869 {
868 let allowed = delegate.is_none() 870 // A delegate never reaches this: creating a repository is
869 && repo_key.as_deref().is_some_and(|key| { 871 // refused at the ceiling gate above, whatever `C` its
870 governance 872 // person may hold here.
871 .conf 873 let allowed = repo_key.as_deref().is_some_and(|key| {
872 .allows_repo(key, &Subject::new(name), Access::Create) 874 governance
873 }); 875 .conf
876 .allows_repo(key, &Subject::new(name), Access::Create)
877 });
874 if !allowed { 878 if !allowed {
875 warn!( 879 warn!(
876 "Rejected exec request: principal {} may not create {:?}", 880 "Rejected exec request: principal {} may not create {:?}",