Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

authz: fix several bugs #1110

Merged
merged 3 commits into from
May 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions common/src/sql/dbinit.sql
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ CREATE TABLE omicron.public.organization (
);

CREATE UNIQUE INDEX ON omicron.public.organization (
silo_id,
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Fixes #1096. There's a test for this now in tests/integration_tests/silos.rs.

name
) WHERE
time_deleted IS NULL;
Expand Down
4 changes: 2 additions & 2 deletions nexus/src/app/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ impl super::Nexus {
) -> ListResultVec<db::model::Project> {
let (.., authz_org) = LookupPath::new(opctx, &self.db_datastore)
.organization_name(organization_name)
.lookup_for(authz::Action::CreateChild)
.lookup_for(authz::Action::ListChildren)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Here and at L114, a copy/paste mistake meant we were checking whether you can create a Project when all you were trying to do was list them.

Copy link
Contributor

Choose a reason for hiding this comment

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

Interesting. I wonder if there's a way to make the type system catch such errors in the future?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It's a good question. We've talked about it a little bit (well, we've talked about whether there's a way to encode in some type what checks you've done already so that we can have functions that accept arguments that must have already been authorized), but not gotten very far.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, seems like it might be worth some investigation. I might play around with it a bit.

.await?;
self.db_datastore
.projects_list_by_name(opctx, &authz_org, pagparams)
Expand All @@ -111,7 +111,7 @@ impl super::Nexus {
) -> ListResultVec<db::model::Project> {
let (.., authz_org) = LookupPath::new(opctx, &self.db_datastore)
.organization_name(organization_name)
.lookup_for(authz::Action::CreateChild)
.lookup_for(authz::Action::ListChildren)
.await?;
self.db_datastore
.projects_list_by_id(opctx, &authz_org, pagparams)
Expand Down
38 changes: 30 additions & 8 deletions nexus/src/authz/omicron.polar
Original file line number Diff line number Diff line change
Expand Up @@ -137,23 +137,41 @@ resource Silo {
];
roles = [ "admin", "collaborator", "viewer" ];

# permissions granted by this resource's roles
"list_children" if "viewer";
"read" if "viewer";
"create_child" if "collaborator";
plotnick marked this conversation as resolved.
Show resolved Hide resolved
"modify" if "admin";

# roles implied by other roles
"viewer" if "collaborator";
"create_child" if "collaborator";
"collaborator" if "admin";
"modify" if "admin";

# roles implied by relationships with the parent fleet
relations = { parent_fleet: Fleet };
"admin" if "admin" on "parent_fleet";
"collaborator" if "collaborator" on "parent_fleet";
"admin" if "collaborator" on "parent_fleet";
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Previously, you got the "admin" role on a Silo if you were an "admin" on the Fleet. That seems a bit too restrictive. If you're "collaborator" on the Fleet, you're supposed to be able to create Silos. Why wouldn't you be able to delete them? I concluded that you should get "admin" on Silos if you already have "collaborator" on the parent Fleet.

I think it'd be good to better articulate what's intended by the various roles (and the names that are reused, like "collaborator"). I didn't want to block this on that, but if folks are uncomfortable with this we can defer it. The concrete problem with not doing this is that a Fleet Collaborator can create a Silo that they then cannot delete. (There are other ways to address this too but this seemed cleanest to me.)

Copy link
Contributor

Choose a reason for hiding this comment

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

I agree that this makes sense, but I will note here for the record that I find "collaborator" to be a very unclear role name generally.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm not attached to it. Do you think the role makes sense and you'd just pick a different name? Do you have one in mind?

Copy link
Contributor

Choose a reason for hiding this comment

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

🤔... maybe writer? author? owner?

Looking at the role itself, I'm not sure that I understand the reason for grouping list_children with create_child. It seems to me like list_children should go with read, and create_child should go with some variant of write. My intuitions on this are probably from Unix directories, which might not match our model exactly. But looking at the Polar file, I see that (contrary to the comment at the top), we have list_children if "viewer" for (nearly) everything except Organization.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

(sorry for the incredible delay on responding here)

Your intuition makes sense to me, and I think they match the comment as well? The comment says that "viewer" generally gets "read" and "list_children". "collaborator" gets these plus "create_child".

"viewer" if "viewer" on "parent_fleet";
}
has_relation(fleet: Fleet, "parent_fleet", silo: Silo)
if silo.fleet = fleet;
has_role(actor: AuthenticatedActor, "viewer", silo: Silo)

# All authenticated users can read their own Silo. That's not quite the same as
# having the "viewer" role. For example, they cannot list Organizations in the
# Silo.
#
# One reason this is necessary is because if an unprivileged user tries to
# create an Organization using "POST /organizations", they should get back a 403
# (which implies they're able to see /organizations, which is essentially seeing
# the Silo itself) rather than a 404. This behavior isn't a hard constraint
# (i.e., you could reasonably get a 404 for an API you're not allowed to call).
# Nor is the implementation (i.e., we could special-case this endpoint somehow).
# But granting this permission is the simplest way to keep this endpoint's
# behavior consistent with the rest of the API.
#
# It's unclear what else would break if users couldn't see their own Silo.
has_permission(actor: AuthenticatedActor, "read", silo: Silo)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Previously, every authenticated user got the "viewer" role on their Silo. This meant they could list all Organizations. They really only need the ability to "read" their Silo. So I've narrowed this rule to only provide that.

# TODO-security TODO-coverage We should have a test that exercises this
# case.
# syntax.
if silo in actor.silo;

resource Organization {
Expand All @@ -180,7 +198,9 @@ resource Organization {
"modify" if "admin";

relations = { parent_silo: Silo };
"admin" if "admin" on "parent_silo";
"admin" if "collaborator" on "parent_silo";
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Same argument as I made above -- without this, if you're a Silo Collaborator, you can create Organizations but not delete them. That seemed wrong.

"read" if "viewer" on "parent_silo";
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I found that Silo Viewers couldn't actually see inside an Organization.

We probably want to break down and add an "Organization Viewer" role. It wasn't obviously necessary when I was writing out the initial roles. But at this point, it's weirder not to have it. Still, that's a bigger change so for now I'm just granting these two permissions directly.

"list_children" if "viewer" on "parent_silo";
}
has_relation(silo: Silo, "parent_silo", organization: Organization)
if organization.silo = silo;
Expand Down Expand Up @@ -212,7 +232,9 @@ resource Project {
"modify" if "admin";

relations = { parent_organization: Organization };
"admin" if "admin" on "parent_organization";
"admin" if "collaborator" on "parent_organization";
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Similar to the above two cases: without this, if you're an Organization Collaborator, you could create Projects that you couldn't delete.


"viewer" if "list_children" on "parent_organization";
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Similar to the other case above, without this, a Fleet or Silo Viewer couldn't see into the Project.

}
has_relation(organization: Organization, "parent_organization", project: Project)
if project.organization = organization;
Expand Down
2 changes: 1 addition & 1 deletion nexus/src/db/datastore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,7 @@ impl DataStore {
pagparams: &DataPageParams<'_, Name>,
) -> ListResultVec<Organization> {
let authz_silo = opctx.authn.silo_required()?;
opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?;
opctx.authorize(authz::Action::ListChildren, &authz_silo).await?;
plotnick marked this conversation as resolved.
Show resolved Hide resolved

use db::schema::organization::dsl;
paginated(dsl::organization, dsl::name, pagparams)
Expand Down
47 changes: 47 additions & 0 deletions nexus/test-utils/src/resource_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ use omicron_common::api::external::Instance;
use omicron_common::api::external::InstanceCpuCount;
use omicron_nexus::crucible_agent_client::types::State as RegionState;
use omicron_nexus::external_api::params;
use omicron_nexus::external_api::shared;
use omicron_nexus::external_api::shared::IdentityType;
use omicron_nexus::external_api::views::{
Organization, Project, Silo, Vpc, VpcRouter,
};
Expand Down Expand Up @@ -280,6 +282,51 @@ pub async fn create_router(
.unwrap()
}

/// Grant a role on a resource to a user
///
/// * `grant_resource_url`: URL of the resource we're granting the role on
/// * `grant_role`: the role we're granting
/// * `grant_user`: the uuid of the user we're granting the role to
/// * `run_as`: the user _doing_ the granting
pub async fn grant_iam<T>(
client: &ClientTestContext,
grant_resource_url: &str,
grant_role: T,
grant_user: Uuid,
run_as: AuthnMode,
) where
T: serde::Serialize + serde::de::DeserializeOwned,
{
let policy_url = format!("{}/policy", grant_resource_url);
let existing_policy: shared::Policy<T> =
NexusRequest::object_get(client, &policy_url)
.authn_as(run_as.clone())
.execute()
.await
.expect("failed to fetch policy")
.parsed_body()
.expect("failed to parse policy");
let new_role_assignment = shared::RoleAssignment {
identity_type: IdentityType::SiloUser,
identity_id: grant_user,
role_name: grant_role,
};
let new_role_assignments = existing_policy
.role_assignments
.into_iter()
.chain(std::iter::once(new_role_assignment))
.collect();

let new_policy = shared::Policy { role_assignments: new_role_assignments };

// TODO-correctness use etag when we have it
NexusRequest::object_put(client, &policy_url, Some(&new_policy))
.authn_as(run_as)
.execute()
.await
.expect("failed to update policy");
}

pub async fn project_get(
client: &ClientTestContext,
project_url: &str,
Expand Down
57 changes: 54 additions & 3 deletions nexus/tests/integration_tests/silos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,20 @@
// file, You can obtain one at https://mozilla.org/MPL/2.0/.

use nexus_test_utils::http_testing::{AuthnMode, NexusRequest};
use omicron_nexus::external_api::views::{Organization, Silo};
use omicron_common::api::external::{IdentityMetadataCreateParams, Name};
use omicron_nexus::external_api::views::{self, Organization, Silo};
use omicron_nexus::TestInterfaces as _;

use http::method::Method;
use http::StatusCode;
use nexus_test_utils::resource_helpers::{
create_organization, create_silo, objects_list_page_authz,
create_organization, create_silo, grant_iam, objects_list_page_authz,
};

use nexus_test_utils::ControlPlaneTestContext;
use nexus_test_utils_macros::nexus_test;
use omicron_nexus::authz::SiloRoles;
use omicron_nexus::external_api::params;

#[nexus_test]
async fn test_silos(cptestctx: &ControlPlaneTestContext) {
Expand Down Expand Up @@ -74,14 +77,62 @@ async fn test_silos(cptestctx: &ControlPlaneTestContext) {
.await
.unwrap();

// Grant the user "admin" privileges on that Silo.
grant_iam(
client,
"/silos/discoverable",
SiloRoles::Admin,
new_silo_user_id,
AuthnMode::PrivilegedUser,
)
.await;

// TODO-coverage, TODO-security: Add test for Silo-local session
// when we can use users in another Silo.

let authn_opctx = nexus.opctx_external_authn();

// Create organization with built-in user auth
// Note: this currently goes to the built-in silo!
create_organization(&client, "someorg").await;
let org_name: Name = "someorg".parse().unwrap();
let new_org_in_default_silo =
create_organization(&client, org_name.as_str()).await;

// Create an Organization of the same name in a different Silo to verify
// that's possible.
let new_org_in_our_silo = NexusRequest::objects_post(
client,
"/organizations",
&params::OrganizationCreate {
identity: IdentityMetadataCreateParams {
name: org_name.clone(),
description: String::new(),
},
},
)
.authn_as(AuthnMode::SiloUser(new_silo_user_id))
.execute()
.await
.expect("failed to create same-named Organization in a different Silo")
.parsed_body::<views::Organization>()
.expect("failed to parse new Organization");
assert_eq!(
new_org_in_default_silo.identity.name,
new_org_in_our_silo.identity.name
);
assert_ne!(
new_org_in_default_silo.identity.id,
new_org_in_our_silo.identity.id
);
// Delete it so that we can delete the Silo later.
NexusRequest::object_delete(
client,
&format!("/organizations/{}", org_name),
)
.authn_as(AuthnMode::SiloUser(new_silo_user_id))
.execute()
.await
.expect("failed to delete test Organization");

// Verify GET /organizations works with built-in user auth
let organizations =
Expand Down