13a5ff9d
Ask one question about what a delegate may do
a73x 2026-08-18 19:39
Commit message
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 | } = ®ime | ||
| 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 | } = ®ime | 868 | } = ®ime |
| 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 {:?}", |