@@ -38,20 +38,6 @@ use std::str::FromStr;
3838type ControlPlaneTestContext =
3939 nexus_test_utils:: ControlPlaneTestContext < omicron_nexus:: Server > ;
4040
41- /// Strip lines starting with `#` from a snapshot file so that the file
42- /// can contain human-readable comments explaining why each entry is there.
43- fn strip_comments ( s : & str ) -> String {
44- let mut out: String = s
45- . lines ( )
46- . filter ( |line| !line. starts_with ( '#' ) )
47- . collect :: < Vec < _ > > ( )
48- . join ( "\n " ) ;
49- if s. ends_with ( '\n' ) {
50- out. push ( '\n' ) ;
51- }
52- out
53- }
54-
5541fn to_q ( d : DateTime < Utc > ) -> String {
5642 d. to_rfc3339_opts ( chrono:: SecondsFormat :: Micros , true )
5743}
@@ -639,15 +625,11 @@ async fn test_audit_log_coverage(ctx: &ControlPlaneTestContext) {
639625 check_manual (
640626 "POST" ,
641627 "/v1/login/fake-silo/local" ,
642- RequestBuilder :: new (
643- client,
644- Method :: POST ,
645- "/v1/login/fake-silo/local" ,
646- )
647- . body ( Some ( & serde_json:: json!( {
648- "username" : "nonexistent" ,
649- "password" : "doesntmatter"
650- } ) ) ) ,
628+ RequestBuilder :: new ( client, Method :: POST , "/v1/login/fake-silo/local" )
629+ . body ( Some ( & serde_json:: json!( {
630+ "username" : "nonexistent" ,
631+ "password" : "doesntmatter"
632+ } ) ) ) ,
651633 )
652634 . await ;
653635
@@ -688,10 +670,11 @@ async fn test_audit_log_coverage(ctx: &ControlPlaneTestContext) {
688670 check_manual (
689671 "POST" ,
690672 "/device/confirm" ,
691- RequestBuilder :: new ( client, Method :: POST , "/device/confirm" )
692- . body ( Some ( & device:: DeviceAuthVerify {
673+ RequestBuilder :: new ( client, Method :: POST , "/device/confirm" ) . body (
674+ Some ( & device:: DeviceAuthVerify {
693675 user_code : "fake-code" . to_string ( ) ,
694- } ) ) ,
676+ } ) ,
677+ ) ,
695678 )
696679 . await ;
697680
@@ -701,49 +684,47 @@ async fn test_audit_log_coverage(ctx: &ControlPlaneTestContext) {
701684 "/device/token" ,
702685 RequestBuilder :: new ( client, Method :: POST , "/device/token" )
703686 . body_urlencoded ( Some ( & device:: DeviceAccessTokenRequest {
704- grant_type :
705- "urn:ietf:params:oauth:grant-type:device_code"
706- . to_string ( ) ,
687+ grant_type : "urn:ietf:params:oauth:grant-type:device_code"
688+ . to_string ( ) ,
707689 device_code : "fake-code" . to_string ( ) ,
708690 client_id : uuid:: Uuid :: nil ( ) ,
709691 } ) ) ,
710692 )
711693 . await ;
712694
713- let mut output =
714- String :: from ( "Mutating endpoints without audit logging:\n " ) ;
715- for ( op_id, ( method, path) ) in & missing_audit {
716- output. push_str ( & format ! ( "{:44} ({:6} {:?})\n " , op_id, method, path) ) ;
717- }
718-
719- output. push_str (
720- "\n Mutating endpoints not tested (not in VERIFY_ENDPOINTS):\n " ,
721- ) ;
722- for ( op_id, ( method, path) ) in & untested_mutating {
723- output. push_str ( & format ! ( "{:44} ({:6} {:?})\n " , op_id, method, path) ) ;
724- }
725-
726- // Print a helpful message when there are new uncovered endpoints
727- let expected_path = "tests/output/uncovered-audit-log-endpoints.txt" ;
728- let expected = strip_comments (
729- & std:: fs:: read_to_string ( expected_path) . unwrap_or_default ( ) ,
730- ) ;
731- let expected_ops: std:: collections:: HashSet < & str > = expected
732- . lines ( )
733- . skip ( 1 ) // skip the header line
734- . filter_map ( |line| line. split_whitespace ( ) . next ( ) )
735- . collect ( ) ;
736- let unexpected_uncovered: Vec < _ > = missing_audit
695+ // Mutating POST endpoints that are intentionally not audit-logged.
696+ // If you're adding a new endpoint here, include a comment explaining why.
697+ let allowed_unaudited: BTreeMap < & str , ( & str , & str ) > = BTreeMap :: from ( [
698+ // Intermediate steps of the device OAuth flow. The meaningful
699+ // endpoint is device_auth_confirm, which is where authentication
700+ // and token creation happen. device_auth_request has no user
701+ // identity, and device_access_token is just polling (mostly 400s)
702+ // until the token created by confirm is ready.
703+ ( "device_access_token" , ( "post" , "/device/token" ) ) ,
704+ ( "device_auth_request" , ( "post" , "/device/auth" ) ) ,
705+ // Called many times per disk image upload, other related endpoints
706+ // cover it. See
707+ // https://github.com/oxidecomputer/omicron/pull/10046
708+ ( "disk_bulk_write_import" , ( "post" , "/v1/disks/{disk}/bulk-write" ) ) ,
709+ // Needs rework to extract actor identity before we can log it.
710+ // Low priority since sessions expire anyway.
711+ ( "logout" , ( "post" , "/v1/logout" ) ) ,
712+ // Read-only queries that happen to use POST for the request body.
713+ ( "system_timeseries_query" , ( "post" , "/v1/system/timeseries/query" ) ) ,
714+ ( "timeseries_query" , ( "post" , "/v1/timeseries/query" ) ) ,
715+ ] ) ;
716+
717+ let unexpected_unaudited: Vec < _ > = missing_audit
737718 . keys ( )
738- . filter ( |op| !expected_ops . contains ( op. as_str ( ) ) )
719+ . filter ( |op| !allowed_unaudited . contains_key ( op. as_str ( ) ) )
739720 . collect ( ) ;
740- if !unexpected_uncovered . is_empty ( ) {
721+ if !unexpected_unaudited . is_empty ( ) {
741722 eprintln ! ( ) ;
742723 eprintln ! (
743724 "======================================================================="
744725 ) ;
745726 eprintln ! ( "ENDPOINTS MISSING AUDIT LOGGING:" ) ;
746- for op in & unexpected_uncovered {
727+ for op in & unexpected_unaudited {
747728 eprintln ! ( " - {}" , op) ;
748729 }
749730 eprintln ! ( ) ;
@@ -758,42 +739,53 @@ async fn test_audit_log_coverage(ctx: &ControlPlaneTestContext) {
758739 "If the endpoint is read-only despite using POST (like the timeseries"
759740 ) ;
760741 eprintln ! (
761- "query endpoints), add it to uncovered-audit-log-endpoints.txt ."
742+ "query endpoints), add it to the allowed_unaudited list in this test ."
762743 ) ;
763744 eprintln ! (
764745 "======================================================================="
765746 ) ;
766747 eprintln ! ( ) ;
767748 }
768-
769- // NOTE: We intentionally do NOT use expectorate's assert_contents here
770- // because we don't want EXPECTORATE=overwrite to allow people to
771- // accidentally add uncovered endpoints to the allowlist.
772- similar_asserts:: assert_eq!(
773- expected,
774- output,
775- "left: uncovered-audit-log-endpoints.txt, right: actual"
749+ assert ! (
750+ unexpected_unaudited. is_empty( ) ,
751+ "Unexpected unaudited endpoints: {:?}" ,
752+ unexpected_unaudited,
776753 ) ;
777754
778- // Check for GET endpoints that unexpectedly have audit logging
779- let mut get_output = String :: from ( "GET endpoints with audit logging:\n " ) ;
780- for ( op_id, ( method, path) ) in & unexpected_get_audit {
781- get_output
782- . push_str ( & format ! ( "{:44} ({:6} {:?})\n " , op_id, method, path) ) ;
755+ // Check that the allowlist doesn't have stale entries for endpoints
756+ // that have since gained audit logging or been removed.
757+ let actual_unaudited: BTreeMap < & str , ( & str , & str ) > = missing_audit
758+ . iter ( )
759+ . chain ( untested_mutating. iter ( ) )
760+ . map ( |( op, ( m, p) ) | ( op. as_str ( ) , ( m. as_str ( ) , p. as_str ( ) ) ) )
761+ . collect ( ) ;
762+ for ( op, ( method, path) ) in & allowed_unaudited {
763+ match actual_unaudited. get ( op) {
764+ None => {
765+ panic ! (
766+ "Stale allowed_unaudited entry: {} ({} {:?}) — \
767+ endpoint now has audit logging or no longer exists, \
768+ remove it from the list",
769+ op, method, path,
770+ ) ;
771+ }
772+ Some ( ( actual_method, actual_path) ) => {
773+ assert_eq ! (
774+ ( actual_method, actual_path) ,
775+ ( method, path) ,
776+ "allowed_unaudited entry for {} has wrong method/path" ,
777+ op,
778+ ) ;
779+ }
780+ }
783781 }
784782
785- let get_expected_path = "tests/output/audited-get-endpoints.txt" ;
786- let get_expected = strip_comments (
787- & std:: fs:: read_to_string ( get_expected_path) . unwrap_or_default ( ) ,
788- ) ;
789- let get_expected_ops: std:: collections:: HashSet < & str > = get_expected
790- . lines ( )
791- . skip ( 1 ) // skip the header line
792- . filter_map ( |line| line. split_whitespace ( ) . next ( ) )
793- . collect ( ) ;
783+ // GET endpoints that are intentionally audit-logged (should be rare).
784+ let allowed_audited_gets: BTreeMap < & str , ( & str , & str ) > = BTreeMap :: new ( ) ;
785+
794786 let unexpected_audited: Vec < _ > = unexpected_get_audit
795787 . keys ( )
796- . filter ( |op| !get_expected_ops . contains ( op. as_str ( ) ) )
788+ . filter ( |op| !allowed_audited_gets . contains_key ( op. as_str ( ) ) )
797789 . collect ( ) ;
798790 if !unexpected_audited. is_empty ( ) {
799791 eprintln ! ( ) ;
@@ -811,20 +803,16 @@ async fn test_audit_log_coverage(ctx: &ControlPlaneTestContext) {
811803 eprintln ! (
812804 "modify state. If this endpoint was intentionally audited (rare),"
813805 ) ;
814- eprintln ! ( "add it to audited-get-endpoints.txt ." ) ;
806+ eprintln ! ( "add it to the allowed_audited_gets list in this test ." ) ;
815807 eprintln ! (
816808 "======================================================================="
817809 ) ;
818810 eprintln ! ( ) ;
819811 }
820-
821- // NOTE: We intentionally do NOT use expectorate's assert_contents here
822- // because we don't want EXPECTORATE=overwrite to allow people to
823- // accidentally add audited GET endpoints to the list.
824- similar_asserts:: assert_eq!(
825- get_expected,
826- get_output,
827- "left: audited-get-endpoints.txt, right: actual"
812+ assert ! (
813+ unexpected_audited. is_empty( ) ,
814+ "Unexpected audited GET endpoints: {:?}" ,
815+ unexpected_audited,
828816 ) ;
829817}
830818
0 commit comments