2551fecd
Name the resolved person, not just the fingerprint, in governance rejections
a73x 2026-08-19 10:31
Commit message
src/server/ssh/session.rs
| Old | New | ||
|---|---|---|---|
| @@ -36,6 +36,25 @@ enum Regime { | |||
| 36 | Closed, | 36 | Closed, |
| 37 | } | 37 | } |
| 38 | 38 | ||
| 39 | /// The principal string a log line should show. | ||
| 40 | /// | ||
| 41 | /// A governed *key* session authenticates as a bare fingerprint but resolves | ||
| 42 | /// to a name in `keydir/` — the name rules are written against, and the one | ||
| 43 | /// an operator reading a rejection wants to see. Everyone else already | ||
| 44 | /// carries the right string in `principal`: a delegate's is set once at | ||
| 45 | /// `auth_openssh_certificate` as `"{person} (via {key_id})"`, and an | ||
| 46 | /// ungoverned or closed session has no name to resolve to. | ||
| 47 | fn log_principal(regime: &Regime, principal: &str) -> String { | ||
| 48 | match regime { | ||
| 49 | Regime::Governed { | ||
| 50 | name, | ||
| 51 | delegate: None, | ||
| 52 | .. | ||
| 53 | } => format!("{name} ({principal})"), | ||
| 54 | _ => principal.to_string(), | ||
| 55 | } | ||
| 56 | } | ||
| 57 | |||
| 39 | /// Configuration shared across all SSH connections. | 58 | /// Configuration shared across all SSH connections. |
| 40 | #[derive(Debug, Clone)] | 59 | #[derive(Debug, Clone)] |
| 41 | pub struct SshServerConfig { | 60 | pub struct SshServerConfig { |
| @@ -278,7 +297,8 @@ impl SshHandler { | |||
| 278 | if !authorized { | 297 | if !authorized { |
| 279 | warn!( | 298 | warn!( |
| 280 | "Rejected release command: principal {} not authorized on {:?}", | 299 | "Rejected release command: principal {} not authorized on {:?}", |
| 281 | principal, resolved_path | 300 | log_principal(regime, principal), |
| 301 | resolved_path | ||
| 282 | ); | 302 | ); |
| 283 | return reply_and_close(session, channel, NOT_FOUND, 1); | 303 | return reply_and_close(session, channel, NOT_FOUND, 1); |
| 284 | } | 304 | } |
| @@ -763,6 +783,10 @@ impl Handler for SshHandler { | |||
| 763 | }; | 783 | }; |
| 764 | 784 | ||
| 765 | let regime = self.regime(); | 785 | let regime = self.regime(); |
| 786 | // What a rejection line should call this session. ACL checks | ||
| 787 | // below keep using `principal` (the fingerprint form policies are | ||
| 788 | // keyed on); this is only for what an operator reads. | ||
| 789 | let log_principal = log_principal(®ime, &principal); | ||
| 766 | 790 | ||
| 767 | // The delegate ceiling, asked once for whatever the command turns | 791 | // The delegate ceiling, asked once for whatever the command turns |
| 768 | // out to be — see governance::delegate::permits. Per-command sites | 792 | // out to be — see governance::delegate::permits. Per-command sites |
| @@ -853,32 +877,46 @@ impl Handler for SshHandler { | |||
| 853 | if !authorized { | 877 | if !authorized { |
| 854 | warn!( | 878 | warn!( |
| 855 | "Rejected exec request: principal {} is not authorized for {} on {:?}", | 879 | "Rejected exec request: principal {} is not authorized for {} on {:?}", |
| 856 | principal, | 880 | log_principal, |
| 857 | git_cmd.as_str(), | 881 | git_cmd.as_str(), |
| 858 | resolved_path | 882 | resolved_path |
| 859 | ); | 883 | ); |
| 860 | return reply_and_close(session, channel, "", 1); | 884 | return reply_and_close(session, channel, "", 1); |
| 861 | } | 885 | } |
| 862 | } else { | 886 | } else { |
| 863 | // Auto-creation is what makes wild repos work without a central | 887 | // Auto-creation is what makes wild repos work without a |
| 864 | // allocator, so under governance it is a permission of its own: | 888 | // central allocator, so under governance it is a permission |
| 865 | // `C` on a pattern the requested name matches. | 889 | // of its own: `C` on a pattern the requested name matches — |
| 890 | // but only for git-receive-pack, the only command that could | ||
| 891 | // ever create anything. A git-upload-pack (fetch/clone) of an | ||
| 892 | // absent path was never going to create it either way, so | ||
| 893 | // asking `C` here would answer a question the client never | ||
| 894 | // raised and mislabel a typo or an unpushed repo as a | ||
| 895 | // permission refusal — the confusing case this distinguishes. | ||
| 866 | if let Regime::Governed { | 896 | if let Regime::Governed { |
| 867 | governance, name, .. | 897 | governance, name, .. |
| 868 | } = ®ime | 898 | } = ®ime |
| 869 | { | 899 | { |
| 870 | // A delegate never reaches this: creating a repository is | 900 | if git_cmd == GitCmd::ReceivePack { |
| 871 | // refused at the ceiling gate above, whatever `C` its | 901 | // A delegate never reaches this: creating a |
| 872 | // person may hold here. | 902 | // repository is refused at the ceiling gate above, |
| 873 | let allowed = repo_key.as_deref().is_some_and(|key| { | 903 | // whatever `C` its person may hold here. |
| 874 | governance | 904 | let allowed = repo_key.as_deref().is_some_and(|key| { |
| 875 | .conf | 905 | governance |
| 876 | .allows_repo(key, &Subject::new(name), Access::Create) | 906 | .conf |
| 877 | }); | 907 | .allows_repo(key, &Subject::new(name), Access::Create) |
| 878 | if !allowed { | 908 | }); |
| 909 | if !allowed { | ||
| 910 | warn!( | ||
| 911 | "Rejected exec request: no such repository {:?}, and {} may not create it", | ||
| 912 | resolved_path, log_principal | ||
| 913 | ); | ||
| 914 | return reply_and_close(session, channel, "", 1); | ||
| 915 | } | ||
| 916 | } else { | ||
| 879 | warn!( | 917 | warn!( |
| 880 | "Rejected exec request: principal {} may not create {:?}", | 918 | "Rejected exec request: no such repository {:?}", |
| 881 | principal, resolved_path | 919 | resolved_path |
| 882 | ); | 920 | ); |
| 883 | return reply_and_close(session, channel, "", 1); | 921 | return reply_and_close(session, channel, "", 1); |
| 884 | } | 922 | } |
| @@ -901,7 +939,7 @@ impl Handler for SshHandler { | |||
| 901 | } | 939 | } |
| 902 | Ok(false) => { | 940 | Ok(false) => { |
| 903 | warn!( | 941 | warn!( |
| 904 | "Rejected exec request: repo path does not exist: {:?}", | 942 | "Rejected exec request: no such repository {:?}", |
| 905 | resolved_path | 943 | resolved_path |
| 906 | ); | 944 | ); |
| 907 | return reply_and_close(session, channel, "", 1); | 945 | return reply_and_close(session, channel, "", 1); |
| @@ -929,15 +967,23 @@ impl Handler for SshHandler { | |||
| 929 | let (tx, rx) = mpsc::channel::<Vec<u8>>(64); | 967 | let (tx, rx) = mpsc::channel::<Vec<u8>>(64); |
| 930 | self.stdin_tx = Some((channel, tx)); | 968 | self.stdin_tx = Some((channel, tx)); |
| 931 | 969 | ||
| 932 | // Spawn the git subprocess | 970 | // Spawn the git subprocess. `tokio::spawn` does not inherit the |
| 971 | // current span, so without `.instrument` every "Git subprocess | ||
| 972 | // error" line would log with no connection attached — carry it | ||
| 973 | // over explicitly. | ||
| 933 | let handle = session.handle(); | 974 | let handle = session.handle(); |
| 934 | tokio::spawn(async move { | 975 | let spawn_span = Span::current(); |
| 935 | if let Err(e) = | 976 | tokio::spawn( |
| 936 | run_git_command(handle, channel, git_cmd, &resolved_path, child_env, rx).await | 977 | async move { |
| 937 | { | 978 | if let Err(e) = |
| 938 | error!("Git subprocess error: {}", e); | 979 | run_git_command(handle, channel, git_cmd, &resolved_path, child_env, rx) |
| 980 | .await | ||
| 981 | { | ||
| 982 | error!("Git subprocess error: {}", e); | ||
| 983 | } | ||
| 939 | } | 984 | } |
| 940 | }); | 985 | .instrument(spawn_span), |
| 986 | ); | ||
| 941 | 987 | ||
| 942 | Ok(()) | 988 | Ok(()) |
| 943 | } | 989 | } |
tests/governance_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -9,7 +9,7 @@ | |||
| 9 | mod common; | 9 | mod common; |
| 10 | 10 | ||
| 11 | use common::ServerHarness; | 11 | use common::ServerHarness; |
| 12 | use std::process::Output; | 12 | use std::process::{Command, Output}; |
| 13 | 13 | ||
| 14 | /// The configuration most of these tests run under. | 14 | /// The configuration most of these tests run under. |
| 15 | /// | 15 | /// |
| @@ -788,3 +788,94 @@ fn settings_supersedes_server_toml_on_the_anonymous_axis_in_both_directions() { | |||
| 788 | "server.toml must not be able to publish what access.conf withholds" | 788 | "server.toml must not be able to publish what access.conf withholds" |
| 789 | ); | 789 | ); |
| 790 | } | 790 | } |
| 791 | |||
| 792 | // ---- Log identity: rejections name the person, not just the key -------- | ||
| 793 | |||
| 794 | /// The `key:SHA256:...` principal string OpenSSH's own `ssh-keygen -lf` | ||
| 795 | /// would print for `pubkey_path` — an external oracle, not the server's own | ||
| 796 | /// fingerprint code, so this proves agreement with an independent source | ||
| 797 | /// rather than self-consistency. | ||
| 798 | fn fingerprint_oracle(pubkey_path: &std::path::Path) -> String { | ||
| 799 | let out = Command::new("ssh-keygen") | ||
| 800 | .args(["-lf"]) | ||
| 801 | .arg(pubkey_path) | ||
| 802 | .output() | ||
| 803 | .expect("failed to run ssh-keygen -lf"); | ||
| 804 | assert!( | ||
| 805 | out.status.success(), | ||
| 806 | "ssh-keygen -lf failed: {}", | ||
| 807 | String::from_utf8_lossy(&out.stderr) | ||
| 808 | ); | ||
| 809 | let stdout = String::from_utf8_lossy(&out.stdout); | ||
| 810 | // "256 SHA256:Gzb/mmBlyDmoWq3hMyPjb3SRWklPJ/yDAoD1K+ATvJA name (ED25519)" | ||
| 811 | let fp = stdout | ||
| 812 | .split_whitespace() | ||
| 813 | .nth(1) | ||
| 814 | .expect("ssh-keygen -lf output missing fingerprint field"); | ||
| 815 | format!("key:{fp}") | ||
| 816 | } | ||
| 817 | |||
| 818 | /// A refused create names the person `settings.git` resolved the key to, | ||
| 819 | /// with the fingerprint kept alongside for the surprising-resolution case — | ||
| 820 | /// not just the bare fingerprint an operator has to map back to someone by | ||
| 821 | /// hand. | ||
| 822 | #[test] | ||
| 823 | fn a_refused_create_names_the_resolved_person_and_the_fingerprint() { | ||
| 824 | let harness = ServerHarness::new("governed-create-identity"); | ||
| 825 | harness.bootstrap_settings(ACCESS_CONF, KEYS); | ||
| 826 | let admin = harness.named_key("alex"); | ||
| 827 | let fingerprint = fingerprint_oracle(&admin.with_extension("pub")); | ||
| 828 | |||
| 829 | // "eitri" matches no `repo` pattern in ACCESS_CONF (not "settings", not | ||
| 830 | // "governed", not "agents/[a-z-]+"), so no rule grants alex `C` on it. | ||
| 831 | harness.work_repo().commit_file("e.txt", "1", "seed"); | ||
| 832 | let push = harness.ssh_push_from(harness.work_repo().dir.path(), &admin, "eitri", "main:main"); | ||
| 833 | assert_refused(&push, "a push creating a repo no rule grants C on"); | ||
| 834 | assert!( | ||
| 835 | !harness.repos_dir().join("eitri.git").exists(), | ||
| 836 | "the repository must not exist after a refused create" | ||
| 837 | ); | ||
| 838 | |||
| 839 | let log = harness.server_log(); | ||
| 840 | let rejection = log | ||
| 841 | .lines() | ||
| 842 | .find(|line| line.contains("Rejected exec request") && line.contains("eitri")) | ||
| 843 | .unwrap_or_else(|| panic!("expected a create-rejection line, got: {log}")); | ||
| 844 | assert!( | ||
| 845 | rejection.contains("alex") && rejection.contains(&fingerprint), | ||
| 846 | "rejection should name both the resolved person and the fingerprint, got: {rejection}" | ||
| 847 | ); | ||
| 848 | assert!( | ||
| 849 | rejection.contains("no such repository") && rejection.contains("may not create"), | ||
| 850 | "a denied create should say so, not just \"may not create\", got: {rejection}" | ||
| 851 | ); | ||
| 852 | } | ||
| 853 | |||
| 854 | /// The common case behind "no such repository" is a typo or a repo never | ||
| 855 | /// pushed here — not a permission gap. A fetch of an absent repository can | ||
| 856 | /// never create anything either way, so it must not be phrased as a denied | ||
| 857 | /// create, contrasting with the ACL-denial case above. | ||
| 858 | #[test] | ||
| 859 | fn a_fetch_of_an_absent_repo_is_not_phrased_as_a_denied_create() { | ||
| 860 | let harness = ServerHarness::new("governed-fetch-missing"); | ||
| 861 | harness.bootstrap_settings(ACCESS_CONF, KEYS); | ||
| 862 | let admin = harness.named_key("alex"); | ||
| 863 | |||
| 864 | let fetch = harness.ssh_fetch(harness.work_repo().dir.path(), &admin, "nonexistent"); | ||
| 865 | assert!( | ||
| 866 | !fetch.status.success(), | ||
| 867 | "fetching an absent repository should fail" | ||
| 868 | ); | ||
| 869 | |||
| 870 | let log = harness.server_log(); | ||
| 871 | let rejection = log | ||
| 872 | .lines() | ||
| 873 | .find(|line| line.contains("Rejected exec request") && line.contains("nonexistent")) | ||
| 874 | .unwrap_or_else(|| panic!("expected a missing-repo rejection line, got: {log}")); | ||
| 875 | assert!(rejection.contains("no such repository"), "got: {rejection}"); | ||
| 876 | assert!( | ||
| 877 | !rejection.contains("may not create"), | ||
| 878 | "a fetch was never going to create anything, so it must not be phrased as a denied \ | ||
| 879 | create, got: {rejection}" | ||
| 880 | ); | ||
| 881 | } | ||