Skip to content

Conversation

@charliepark
Copy link
Contributor

@charliepark charliepark commented Nov 12, 2025

This PR allows silo users with the limited-collaborator role to promote project images to silo images and demote silo images to project images. In PR #9299, we established the limited-collaborator role at both the project and silo scope, and created Polar rules that allow them to create/modify/delete compute resources. After that PR they were not able to promote project images to be silo images, or to demote silo images to project images. This change enables silo.limited-collaborators to manage images across project and silo scopes. This is done via a synthetic SiloImageList resource (similar to VpcList) to grant image promotion, without giving them the broader create_child permission on Silo (which would allow creating projects, users, groups, identity providers, etc.).

What a silo.limited-collaborator can now do (beyond what they got in PR 9299):

  • Promote project images to silo images
  • Demote silo images to project images
  • Modify and delete silo images

What remains restricted (only collaborators have permissions for …):

Note on project-scoped limited-collaborators:
Only users with silo.limited-collaborator (or higher) can promote/demote images. A project.limited-collaborator can list, read, and modify Project Images within their project, but lacks the silo-level role needed to promote project images to silo scope or demote silo images.

Added tests:

  • test_silo_collaborator_can_promote_demote_images — Verifies silo collaborators can promote project images to silo images and demote them back
  • test_silo_limited_collaborator_can_promote_demote_images — Verifies silo limited-collaborators can promote project images to silo images and demote them back
  • test_silo_viewer_cannot_promote_demote_images — Verifies silo viewers cannot promote or demote images (403 Forbidden)
  • test_project_collaborator_cannot_promote_demote_images — Verifies project-level collaborators without silo roles cannot promote or demote images (404 Not Found)
  • test_project_limited_collaborator_cannot_promote_demote_images — Verifies project-level limited-collaborators without silo roles cannot promote or demote images (404 Not Found)

Closes #9338

@charliepark
Copy link
Contributor Author

CI failures all look to be unrelated reconfigurator-cli::test-scripts

}

#[nexus_test]
async fn test_silo_viewer_cannot_promote_demote_images(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised there is not already a test for this. I guess maybe it would be testing the totally unprivileged user.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is this one, but it's more about testing silo deletion permissions.

async fn test_image_deletion_permissions(cptestctx: &ControlPlaneTestContext) {
let client = &cptestctx.external_client;
DiskTest::new(&cptestctx).await;
// Create a project
create_project(client, PROJECT_NAME).await;
// Grant the unprivileged user viewer on the silo and admin on that project
let silo_url = format!("/v1/system/silos/{}", DEFAULT_SILO.id());
grant_iam(
client,
&silo_url,
SiloRole::Viewer,
USER_TEST_UNPRIVILEGED.id(),
AuthnMode::PrivilegedUser,
)
.await;
let project_url = format!("/v1/projects/{}", PROJECT_NAME);
grant_iam(
client,
&project_url,
ProjectRole::Admin,
USER_TEST_UNPRIVILEGED.id(),
AuthnMode::PrivilegedUser,
)
.await;
// Create an image in the default silo using the privileged user
let silo_images_url = "/v1/images";
let images_url = get_project_images_url(PROJECT_NAME);
let image_create_params = get_image_create(
params::ImageSource::YouCanBootAnythingAsLongAsItsAlpine,
);
let image =
NexusRequest::objects_post(client, &images_url, &image_create_params)
.authn_as(AuthnMode::PrivilegedUser)
.execute_and_parse_unwrap::<views::Image>()
.await;
let image_id = image.identity.id;
// promote the image to the silo
let promote_url = format!("/v1/images/{}/promote", image_id);
NexusRequest::new(
RequestBuilder::new(client, http::Method::POST, &promote_url)
.expect_status(Some(http::StatusCode::ACCEPTED)),
)
.authn_as(AuthnMode::PrivilegedUser)
.execute_and_parse_unwrap::<views::Image>()
.await;
let silo_images = NexusRequest::object_get(client, &silo_images_url)
.authn_as(AuthnMode::PrivilegedUser)
.execute_and_parse_unwrap::<ResultsPage<views::Image>>()
.await
.items;
assert_eq!(silo_images.len(), 1);
assert_eq!(silo_images[0].identity.name, "alpine-edge");
// the unprivileged user should not be able to delete that image
let image_url = format!("/v1/images/{}", image_id);
NexusRequest::new(
RequestBuilder::new(client, http::Method::DELETE, &image_url)
.expect_status(Some(http::StatusCode::FORBIDDEN)),
)
.authn_as(AuthnMode::UnprivilegedUser)
.execute()
.await
.expect("should not be able to delete silo image as unpriv user!");
// Demote that image
let demote_url =
format!("/v1/images/{}/demote?project={}", image_id, PROJECT_NAME);
NexusRequest::new(
RequestBuilder::new(client, http::Method::POST, &demote_url)
.expect_status(Some(http::StatusCode::ACCEPTED)),
)
.authn_as(AuthnMode::PrivilegedUser)
.execute_and_parse_unwrap::<views::Image>()
.await;
// now the unpriviledged user should be able to delete that image
let image_url = format!("/v1/images/{}", image_id);
NexusRequest::new(
RequestBuilder::new(client, http::Method::DELETE, &image_url)
.expect_status(Some(http::StatusCode::NO_CONTENT)),
)
.authn_as(AuthnMode::UnprivilegedUser)
.execute()
.await
.expect("should be able to delete project image as unpriv user!");
}

// This allows limited-collaborators to demote images.
let (_, authz_project) = project_lookup
.lookup_for(authz::Action::CreateChild)
.await?;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Guess this was wrong before.

@david-crespo
Copy link
Contributor

I was curious about how we prevent you from demoting to a project in another silo, so I had Claude write a test confirming that you can't. But this is probably not necessary to add since all of our project lookup infrastructure is built around nesting under silos.

test_cannot_demote_silo_image_to_project_in_other_silo
diff --git a/nexus/tests/integration_tests/images.rs b/nexus/tests/integration_tests/images.rs
index e89bcffd3e..ab8ad129c3 100644
--- a/nexus/tests/integration_tests/images.rs
+++ b/nexus/tests/integration_tests/images.rs
@@ -13,9 +13,13 @@
 use nexus_test_utils::http_testing::NexusRequest;
 use nexus_test_utils::http_testing::RequestBuilder;
 use nexus_test_utils::resource_helpers::DiskTest;
+use nexus_test_utils::resource_helpers::create_local_user;
 use nexus_test_utils::resource_helpers::create_project;
+use nexus_test_utils::resource_helpers::create_silo;
 use nexus_test_utils::resource_helpers::grant_iam;
+use nexus_test_utils::resource_helpers::test_params;
 use nexus_test_utils_macros::nexus_test;
+use nexus_types::external_api::shared;
 use nexus_types::external_api::shared::ProjectRole;
 use nexus_types::external_api::shared::SiloRole;
 use nexus_types::external_api::{params, views};
@@ -966,3 +970,149 @@
     .await
     .expect("expected project limited-collaborator to be blocked from demoting image");
 }
+
+#[nexus_test]
+async fn test_cannot_demote_silo_image_to_project_in_other_silo(
+    cptestctx: &ControlPlaneTestContext,
+) {
+    let client = &cptestctx.external_client;
+    DiskTest::new(&cptestctx).await;
+
+    // Create two silos
+    let silo1 = create_silo(
+        &client,
+        "silo1",
+        true,
+        shared::SiloIdentityMode::LocalOnly,
+    )
+    .await;
+    let silo2 = create_silo(
+        &client,
+        "silo2",
+        true,
+        shared::SiloIdentityMode::LocalOnly,
+    )
+    .await;
+
+    // Create a user in silo1 with collaborator role
+    let silo1_user = create_local_user(
+        client,
+        &silo1,
+        &"silo1-user".parse().unwrap(),
+        test_params::UserPassword::LoginDisallowed,
+    )
+    .await;
+    let silo1_user_id = silo1_user.id;
+
+    let silo1_url = format!("/v1/system/silos/{}", silo1.identity.id);
+    grant_iam(
+        client,
+        &silo1_url,
+        SiloRole::Collaborator,
+        silo1_user_id,
+        AuthnMode::PrivilegedUser,
+    )
+    .await;
+
+    // Create a user in silo2
+    let silo2_user = create_local_user(
+        client,
+        &silo2,
+        &"silo2-user".parse().unwrap(),
+        test_params::UserPassword::LoginDisallowed,
+    )
+    .await;
+    let silo2_user_id = silo2_user.id;
+
+    let silo2_url = format!("/v1/system/silos/{}", silo2.identity.id);
+    grant_iam(
+        client,
+        &silo2_url,
+        SiloRole::Collaborator,
+        silo2_user_id,
+        AuthnMode::PrivilegedUser,
+    )
+    .await;
+
+    // Create a project in silo1
+    let project1_name = "proj1";
+    let _project1 = NexusRequest::objects_post(
+        client,
+        &format!("/v1/projects?silo={}", silo1.identity.name),
+        &params::ProjectCreate {
+            identity: IdentityMetadataCreateParams {
+                name: project1_name.parse().unwrap(),
+                description: "project 1".to_string(),
+            },
+        },
+    )
+    .authn_as(AuthnMode::SiloUser(silo1_user_id))
+    .execute_and_parse_unwrap::<views::Project>()
+    .await;
+
+    // Create a project in silo2
+    let project2_name = "proj2";
+    let project2 = NexusRequest::objects_post(
+        client,
+        &format!("/v1/projects?silo={}", silo2.identity.name),
+        &params::ProjectCreate {
+            identity: IdentityMetadataCreateParams {
+                name: project2_name.parse().unwrap(),
+                description: "project 2".to_string(),
+            },
+        },
+    )
+    .authn_as(AuthnMode::SiloUser(silo2_user_id))
+    .execute_and_parse_unwrap::<views::Project>()
+    .await;
+
+    // Create a project image in silo1's project as silo1 user
+    let image_create_params = get_image_create(
+        params::ImageSource::YouCanBootAnythingAsLongAsItsAlpine,
+    );
+    let images_url = format!(
+        "/v1/images?project={}&silo={}",
+        project1_name, silo1.identity.name
+    );
+
+    let image =
+        NexusRequest::objects_post(client, &images_url, &image_create_params)
+            .authn_as(AuthnMode::SiloUser(silo1_user_id))
+            .execute_and_parse_unwrap::<views::Image>()
+            .await;
+
+    let image_id = image.identity.id;
+
+    // Promote the image to silo1's silo level
+    let promote_url =
+        format!("/v1/images/{}/promote?silo={}", image_id, silo1.identity.name);
+    NexusRequest::new(
+        RequestBuilder::new(client, http::Method::POST, &promote_url)
+            .expect_status(Some(http::StatusCode::ACCEPTED)),
+    )
+    .authn_as(AuthnMode::SiloUser(silo1_user_id))
+    .execute_and_parse_unwrap::<views::Image>()
+    .await;
+
+    // Attempt to demote the silo1 image to silo2's project as silo1 user
+    // This should fail because when authenticated as silo1 user, project lookup
+    // is scoped to silo1, so the silo2 project ID won't be found
+    let demote_url = format!(
+        "/v1/images/{}/demote?project={}&silo={}",
+        image_id, project2.identity.id, silo1.identity.name
+    );
+    let error = NexusRequest::expect_failure(
+        client,
+        StatusCode::NOT_FOUND,
+        Method::POST,
+        &demote_url,
+    )
+    .authn_as(AuthnMode::SiloUser(silo1_user_id))
+    .execute_and_parse_unwrap::<dropshot::HttpErrorResponseBody>()
+    .await;
+
+    assert_eq!(
+        error.message,
+        format!("not found: project with id \"{}\"", project2.identity.id)
+    );
+}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow limited-collaborator role users to promote/demote images

3 participants