Skip to content

Commit d59444b

Browse files
committed
move exception list into rust and use regular comments
1 parent 9c42800 commit d59444b

3 files changed

Lines changed: 77 additions & 108 deletions

File tree

nexus/tests/integration_tests/audit_log.rs

Lines changed: 77 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -38,20 +38,6 @@ use std::str::FromStr;
3838
type 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-
5541
fn 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-
"\nMutating 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

nexus/tests/output/audited-get-endpoints.txt

Lines changed: 0 additions & 1 deletion
This file was deleted.

nexus/tests/output/uncovered-audit-log-endpoints.txt

Lines changed: 0 additions & 18 deletions
This file was deleted.

0 commit comments

Comments
 (0)