Skip to content

Commit 6d81122

Browse files
committed
Gate BMF payload versions per project
Each project now declares the highest BMF payload version it accepts. The field is `bmf_version` on the project, it defaults to 0 everywhere, and it is visible to everyone that can see the project. Only a server admin can move it: a PATCH that carries the field from anyone else is forbidden, and a PATCH that does not carry it is the patch it has always been. The gate is a plain setting, so an admin can lower it again just as easily. The gate is a maximum, not an exact match. A project at version 1 still ingests a payload that declares version 0 and a payload that declares nothing at all, so raising the gate refuses nothing that ingested before it moved. Ingest checks the gate twice, once for each way a payload states its version, and both refusals are the same class and name both versions, the payload's and the project's. The `bmf_version` key a payload declares is checked before anything is created for the report, because later layers hang payload shapes off the declared version and none of them should reach a project that does not accept them. The version the results actually parsed as is checked after parsing, because a payload can reach a v1 leaf without declaring anything: the `json_v1` adapter names the leaf outright, and the `magic` and `json` nodes fall back to it. Without the second check the gate would be decorative. That second check is a deliberate behavior change for those two undeclared paths on a project still at version 0. They are not a documented way to send v1 results, and a gate a payload can walk around is not a gate. Report ingest, `/v0/run`, and a job based run share one check rather than three copies of it. A job declares nothing, since its results are the runner's own output rather than a submitted payload, but the project gate still applies to what those results parse as.
1 parent 64658c0 commit 6d81122

28 files changed

Lines changed: 828 additions & 56 deletions

File tree

lib/api_projects/src/projects.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use bencher_endpoint::{
33
};
44
use bencher_json::{
55
JsonDirection, JsonPagination, JsonProject, JsonProjects, ProjectResourceId, ResourceName,
6-
Search,
6+
Sanitize as _, Search,
77
project::{JsonUpdateProject, Visibility},
88
};
99
use bencher_rbac::project::Permission;
@@ -277,6 +277,7 @@ pub async fn get_one_inner(
277277
///
278278
/// Update a project.
279279
/// The user must have `edit` permissions for the project.
280+
/// Setting the `bmf_version` field requires a server admin.
280281
#[endpoint {
281282
method = PATCH,
282283
path = "/v0/projects/{project}",
@@ -321,6 +322,16 @@ async fn patch_inner(
321322
Permission::Edit,
322323
)?;
323324

325+
// Only a server admin can move the project's BMF payload version gate.
326+
// Every other field of the patch is unaffected by this check.
327+
if json_project.bmf_version().is_some() && !auth_user.is_admin(&context.rbac) {
328+
let mut auth_user = auth_user.clone();
329+
auth_user.sanitize();
330+
return Err(forbidden_error(format!(
331+
"Only admins can update the `bmf_version` field for a project. User is not an admin: {auth_user:?}",
332+
)));
333+
}
334+
324335
// Check project visibility
325336
if let Some(visibility) = json_project.visibility() {
326337
#[cfg(not(feature = "plus"))]

lib/api_projects/tests/bmf_version.rs

Lines changed: 294 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
//! before is refused now and every payload lands on the same leaf either way.
1515
1616
use bencher_api_tests::{TestServer, TestUser};
17+
use bencher_json::{BmfVersion, ProjectSlug};
1718
use http::StatusCode;
1819

1920
/// A BMF v0 payload: a benchmark maps straight to its measures.
@@ -42,8 +43,11 @@ fn v1_results() -> String {
4243
}
4344

4445
/// A signed up user with an organization and a project to report into.
46+
///
47+
/// The user is the server's first signup, which makes them its admin, so this
48+
/// fixture is also what moves the project's BMF version gate.
4549
struct Fixture {
46-
project_slug: String,
50+
project_slug: ProjectSlug,
4751
user: TestUser,
4852
}
4953

@@ -56,11 +60,22 @@ async fn fixture(server: &TestServer, label: &str) -> Fixture {
5660
.create_project(&user, &org, &format!("Bmf Project {label}"))
5761
.await;
5862
Fixture {
59-
project_slug: project.slug.to_string(),
63+
project_slug: project.slug,
6064
user,
6165
}
6266
}
6367

68+
/// The same fixture with the project's gate raised to BMF version 1.
69+
///
70+
/// Every payload that states version 1, by declaring the key or by parsing as v1,
71+
/// needs this: a project accepts version 0 and nothing above it until an admin
72+
/// says otherwise.
73+
async fn gated_fixture(server: &TestServer, label: &str) -> Fixture {
74+
let fixture = fixture(server, label).await;
75+
server.set_bmf_version(&fixture.project_slug, BmfVersion::V1);
76+
fixture
77+
}
78+
6479
/// Post one report and return its status and body, whatever they are.
6580
///
6681
/// Every report a comparison uses carries the same `hash`, so they all resolve to
@@ -196,7 +211,7 @@ async fn report_absent_bmf_version_is_version_0() {
196211
#[tokio::test]
197212
async fn report_bmf_version_1_ingests_a_v1_payload_identically_to_version_0() {
198213
let server = TestServer::new().await;
199-
let fixture = fixture(&server, "v1").await;
214+
let fixture = gated_fixture(&server, "v1").await;
200215

201216
let one = report(
202217
&server,
@@ -236,7 +251,7 @@ async fn report_bmf_version_1_ingests_a_v1_payload_identically_to_version_0() {
236251
#[tokio::test]
237252
async fn report_bmf_version_1_still_ingests_a_v0_payload() {
238253
let server = TestServer::new().await;
239-
let fixture = fixture(&server, "fallback").await;
254+
let fixture = gated_fixture(&server, "fallback").await;
240255

241256
let one = report(
242257
&server,
@@ -305,7 +320,7 @@ async fn report_unknown_bmf_version_is_rejected() {
305320
#[tokio::test]
306321
async fn report_explicit_leaf_wins_over_bmf_version() {
307322
let server = TestServer::new().await;
308-
let fixture = fixture(&server, "leaf").await;
323+
let fixture = gated_fixture(&server, "leaf").await;
309324

310325
let (status, body) = try_report(
311326
&server,
@@ -341,3 +356,277 @@ async fn report_explicit_leaf_wins_over_bmf_version() {
341356

342357
server.close().await;
343358
}
359+
360+
// --- The project gate ---
361+
//
362+
// A project declares the highest BMF payload version it accepts. It defaults to 0
363+
// everywhere, only a server admin can raise it, and it is visible to everyone.
364+
365+
/// GET one project as `user`.
366+
async fn get_project(
367+
server: &TestServer,
368+
user: &TestUser,
369+
project: &ProjectSlug,
370+
) -> serde_json::Value {
371+
let resp = server
372+
.client
373+
.get(server.api_url(&format!("/v0/projects/{project}")))
374+
.header(
375+
bencher_json::AUTHORIZATION,
376+
bencher_json::bearer_header(&user.token),
377+
)
378+
.send()
379+
.await
380+
.expect("Request failed");
381+
let status = resp.status();
382+
assert_eq!(status, StatusCode::OK, "GET project: {status}");
383+
resp.json().await.expect("Failed to parse the project")
384+
}
385+
386+
/// PATCH one project with whatever body, and return its status and body.
387+
async fn try_patch(
388+
server: &TestServer,
389+
user: &TestUser,
390+
project: &ProjectSlug,
391+
body: &serde_json::Value,
392+
) -> (StatusCode, String) {
393+
let resp = server
394+
.client
395+
.patch(server.api_url(&format!("/v0/projects/{project}")))
396+
.header(
397+
bencher_json::AUTHORIZATION,
398+
bencher_json::bearer_header(&user.token),
399+
)
400+
.json(body)
401+
.send()
402+
.await
403+
.expect("Request failed");
404+
let status = resp.status();
405+
let body = resp.text().await.expect("Failed to read the response");
406+
(status, body)
407+
}
408+
409+
/// What an error response said, without the request id the server mints for it.
410+
fn message(body: &str) -> String {
411+
let error: serde_json::Value = serde_json::from_str(body).expect("Failed to parse the error");
412+
error["message"]
413+
.as_str()
414+
.expect("the error carries a message")
415+
.to_owned()
416+
}
417+
418+
/// The refusal has to name the payload's version and the project's, so the message
419+
/// says both what was sent and what the project would take.
420+
fn assert_gate_refusal(status: StatusCode, body: &str, payload: u8, project: u8) {
421+
assert_eq!(status, StatusCode::LOCKED, "{body}");
422+
assert!(
423+
body.contains(&payload.to_string()) && body.contains(&project.to_string()),
424+
"expected the refusal to name BMF version {payload} and {project}: {body}"
425+
);
426+
}
427+
428+
/// A new project accepts BMF version 0 and says so.
429+
#[tokio::test]
430+
async fn project_bmf_version_defaults_to_0() {
431+
let server = TestServer::new().await;
432+
let fixture = fixture(&server, "default").await;
433+
434+
let project = get_project(&server, &fixture.user, &fixture.project_slug).await;
435+
assert_eq!(project["bmf_version"], serde_json::json!(0));
436+
437+
server.close().await;
438+
}
439+
440+
/// A payload that declares a version above the gate is refused, and the same
441+
/// project still takes a version 0 payload.
442+
#[tokio::test]
443+
async fn report_declared_version_above_the_gate_is_refused() {
444+
let server = TestServer::new().await;
445+
let fixture = fixture(&server, "declared").await;
446+
447+
let (status, body) = try_report(
448+
&server,
449+
&fixture,
450+
&v0_results(),
451+
Some(serde_json::json!(1)),
452+
None,
453+
)
454+
.await;
455+
assert_gate_refusal(status, &body, 1, 0);
456+
457+
// The gate is about the version, not about the project: version 0 still ingests.
458+
report(
459+
&server,
460+
&fixture,
461+
&v0_results(),
462+
Some(serde_json::json!(0)),
463+
None,
464+
)
465+
.await;
466+
467+
server.close().await;
468+
}
469+
470+
/// Results that parse as v1 are refused even when no version was declared.
471+
///
472+
/// These are the two side doors the declared key does not cover: the `json_v1`
473+
/// leaf named outright, and the default `magic` adapter falling back to that leaf.
474+
/// Both are refused, and refused the same way, because both are the same statement
475+
/// about the payload.
476+
#[tokio::test]
477+
async fn report_parsed_v1_above_the_gate_is_refused() {
478+
let server = TestServer::new().await;
479+
let fixture = fixture(&server, "parsed").await;
480+
481+
let (magic_status, magic_body) = try_report(&server, &fixture, &v1_results(), None, None).await;
482+
assert_gate_refusal(magic_status, &magic_body, 1, 0);
483+
484+
let (leaf_status, leaf_body) =
485+
try_report(&server, &fixture, &v1_results(), None, Some("json_v1")).await;
486+
assert_gate_refusal(leaf_status, &leaf_body, 1, 0);
487+
488+
// The same refusal, not just the same class. Only the request id differs,
489+
// since the server mints a fresh one per request.
490+
assert_eq!(message(&magic_body), message(&leaf_body));
491+
492+
server.close().await;
493+
}
494+
495+
/// An admin raises the gate, and every payload the gate refused ingests.
496+
#[tokio::test]
497+
async fn admin_raising_the_gate_admits_the_refused_payloads() {
498+
let server = TestServer::new().await;
499+
let fixture = fixture(&server, "raise").await;
500+
501+
for (results, version, adapter) in [
502+
(v0_results(), Some(serde_json::json!(1)), None),
503+
(v1_results(), None, None),
504+
(v1_results(), None, Some("json_v1")),
505+
] {
506+
let (status, body) =
507+
try_report(&server, &fixture, &results, version.clone(), adapter).await;
508+
assert_gate_refusal(status, &body, 1, 0);
509+
}
510+
511+
let (status, body) = try_patch(
512+
&server,
513+
&fixture.user,
514+
&fixture.project_slug,
515+
&serde_json::json!({ "bmf_version": 1 }),
516+
)
517+
.await;
518+
assert_eq!(status, StatusCode::OK, "{body}");
519+
let project = get_project(&server, &fixture.user, &fixture.project_slug).await;
520+
assert_eq!(project["bmf_version"], serde_json::json!(1));
521+
522+
for (results, version, adapter) in [
523+
(v0_results(), Some(serde_json::json!(1)), None),
524+
(v1_results(), None, None),
525+
(v1_results(), None, Some("json_v1")),
526+
] {
527+
report(&server, &fixture, &results, version.clone(), adapter).await;
528+
}
529+
530+
server.close().await;
531+
}
532+
533+
/// The gate is a maximum, so a raised project takes every lower version unchanged.
534+
#[tokio::test]
535+
async fn a_raised_gate_still_accepts_version_0_payloads() {
536+
let server = TestServer::new().await;
537+
let fixture = gated_fixture(&server, "maximum").await;
538+
539+
let absent = report(&server, &fixture, &v0_results(), None, None).await;
540+
let zero = report(
541+
&server,
542+
&fixture,
543+
&v0_results(),
544+
Some(serde_json::json!(0)),
545+
None,
546+
)
547+
.await;
548+
assert_eq!(absent, zero);
549+
550+
server.close().await;
551+
}
552+
553+
/// Nothing ratchets: the gate is a plain setting an admin can put back.
554+
#[tokio::test]
555+
async fn admin_can_lower_the_gate() {
556+
let server = TestServer::new().await;
557+
let fixture = gated_fixture(&server, "lower").await;
558+
559+
report(
560+
&server,
561+
&fixture,
562+
&v1_results(),
563+
Some(serde_json::json!(1)),
564+
None,
565+
)
566+
.await;
567+
568+
let (status, body) = try_patch(
569+
&server,
570+
&fixture.user,
571+
&fixture.project_slug,
572+
&serde_json::json!({ "bmf_version": 0 }),
573+
)
574+
.await;
575+
assert_eq!(status, StatusCode::OK, "{body}");
576+
let project = get_project(&server, &fixture.user, &fixture.project_slug).await;
577+
assert_eq!(project["bmf_version"], serde_json::json!(0));
578+
579+
let (status, body) = try_report(
580+
&server,
581+
&fixture,
582+
&v1_results(),
583+
Some(serde_json::json!(1)),
584+
None,
585+
)
586+
.await;
587+
assert_gate_refusal(status, &body, 1, 0);
588+
589+
server.close().await;
590+
}
591+
592+
/// Only a server admin can move the gate, and the rest of the patch is unaffected.
593+
///
594+
/// The second user owns their own organization and project, so they are allowed to
595+
/// edit it. The only thing standing between them and the field is the admin check.
596+
#[tokio::test]
597+
async fn non_admin_cannot_move_the_gate() {
598+
let server = TestServer::new().await;
599+
// The first signup is the server admin, so the second one is not.
600+
let _admin = fixture(&server, "owner").await;
601+
let user = server.signup("Other User", "bmfother@example.com").await;
602+
let org = server.create_org(&user, "Bmf Other Org").await;
603+
let project = server
604+
.create_project(&user, &org, "Bmf Other Project")
605+
.await;
606+
607+
let (status, body) = try_patch(
608+
&server,
609+
&user,
610+
&project.slug,
611+
&serde_json::json!({ "bmf_version": 1 }),
612+
)
613+
.await;
614+
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
615+
assert!(body.contains("bmf_version"), "{body}");
616+
617+
// The same patch without the field is the patch it has always been.
618+
let (status, body) = try_patch(
619+
&server,
620+
&user,
621+
&project.slug,
622+
&serde_json::json!({ "name": "Bmf Renamed Project" }),
623+
)
624+
.await;
625+
assert_eq!(status, StatusCode::OK, "{body}");
626+
627+
let project = get_project(&server, &user, &project.slug).await;
628+
assert_eq!(project["name"], serde_json::json!("Bmf Renamed Project"));
629+
assert_eq!(project["bmf_version"], serde_json::json!(0));
630+
631+
server.close().await;
632+
}

0 commit comments

Comments
 (0)