Skip to content

authz: add built-in roles to the database #512

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

Merged
merged 32 commits into from
Dec 17, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0b68b13
populate built-in roles into database
davepacheco Dec 7, 2021
b003327
Merge branch 'main' into authz-roles
davepacheco Dec 10, 2021
22ce267
lots of rework
davepacheco Dec 10, 2021
9aa6369
more work
davepacheco Dec 10, 2021
0635bb3
fix pagination condition
davepacheco Dec 10, 2021
a006cd4
give up on diesel
davepacheco Dec 13, 2021
28c2a21
remove cruft
davepacheco Dec 13, 2021
d1f8019
starting test
davepacheco Dec 13, 2021
b481442
fix broken tests
davepacheco Dec 13, 2021
abf9e26
flesh out test
davepacheco Dec 13, 2021
a163fb9
merge fix
davepacheco Dec 14, 2021
7983cd9
fix up roles test
davepacheco Dec 14, 2021
5d53966
fix up iter impl
davepacheco Dec 14, 2021
0841cf3
remove duplication in populate()
davepacheco Dec 14, 2021
47196c5
add API to fetch one built-in role
davepacheco Dec 14, 2021
eb48065
remove vestigial oso patch
davepacheco Dec 14, 2021
3591dca
remove spurious delta
davepacheco Dec 14, 2021
d69d119
fix style
davepacheco Dec 14, 2021
f410fd6
Add multi-column pagination support
smklein Dec 16, 2021
ee21650
Add unused attr, fix comment typo
smklein Dec 16, 2021
f177556
review feedback
davepacheco Dec 16, 2021
7799728
add newtype for role names
davepacheco Dec 16, 2021
a4524ba
don't parse role name resource type
davepacheco Dec 17, 2021
bd843dd
Add tests
smklein Dec 17, 2021
aa8837b
Remove errant println
smklein Dec 17, 2021
1207b13
Merge branch 'main' into pag_multicolumn
smklein Dec 17, 2021
38fccbf
review feedback: underscores to hyphens, extra comment
davepacheco Dec 17, 2021
600c9fa
Merge remote-tracking branch 'origin/main' into authz-roles
davepacheco Dec 17, 2021
59e6bfd
fix mismerge
davepacheco Dec 17, 2021
346666f
Merge remote-tracking branch 'origin/pag_multicolumn' into authz-roles
davepacheco Dec 17, 2021
7c8f57b
use new paginated_multicolumn
davepacheco Dec 17, 2021
0f7ee09
remove vestigial comment
davepacheco Dec 17, 2021
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
210 changes: 179 additions & 31 deletions common/src/api/external/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,90 @@ impl Name {
}
}

/**
* Name for a built-in role
*/
#[derive(
Clone,
Debug,
DeserializeFromStr,
Display,
Eq,
FromStr,
Ord,
PartialEq,
PartialOrd,
SerializeDisplay,
)]
#[display("{resource_type}.{role_name}")]
pub struct RoleName {
// "resource_type" is generally the String value of one of the
// `ResourceType` variants. We could store the parsed `ResourceType`
// instead, but it's useful to be able to represent RoleNames for resource
// types that we don't know about. That could happen if we happen to find
// them in the database, for example.
#[from_str(regex = "[a-z-]+")]
resource_type: String,
#[from_str(regex = "[a-z-]+")]
role_name: String,
}

impl RoleName {
pub fn new(resource_type: &str, role_name: &str) -> RoleName {
RoleName {
resource_type: String::from(resource_type),
role_name: String::from(role_name),
}
}
}

/**
* Custom JsonSchema implementation to encode the constraints on Name
*/
/* TODO see TODOs on Name above */
impl JsonSchema for RoleName {
fn schema_name() -> String {
"RoleName".to_string()
}
fn json_schema(
_gen: &mut schemars::gen::SchemaGenerator,
) -> schemars::schema::Schema {
schemars::schema::Schema::Object(schemars::schema::SchemaObject {
metadata: Some(Box::new(schemars::schema::Metadata {
id: None,
title: Some("A name for a built-in role".to_string()),
description: Some(
"Role names consist of two string components \
separated by dot (\".\")."
.to_string(),
),
default: None,
deprecated: false,
read_only: false,
write_only: false,
examples: vec![],
})),
instance_type: Some(schemars::schema::SingleOrVec::Single(
Box::new(schemars::schema::InstanceType::String),
)),
format: None,
enum_values: None,
const_value: None,
subschemas: None,
number: None,
string: Some(Box::new(schemars::schema::StringValidation {
max_length: Some(63),
min_length: None,
pattern: Some("[a-z-]+\\.[a-z-]+".to_string()),
})),
array: None,
object: None,
reference: None,
extensions: BTreeMap::new(),
})
}
}

/**
* A count of bytes, typically used either for memory or storage capacity
*
Expand Down Expand Up @@ -465,8 +549,22 @@ impl TryFrom<i64> for Generation {
/**
* Identifies a type of API resource
*/
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[derive(
Clone,
Copy,
Debug,
DeserializeFromStr,
Display,
Eq,
FromStr,
Ord,
PartialEq,
PartialOrd,
SerializeDisplay,
)]
#[display(style = "kebab-case")]
pub enum ResourceType {
Fleet,
Organization,
Project,
Dataset,
Expand All @@ -483,39 +581,11 @@ pub enum ResourceType {
RouterRoute,
Oximeter,
MetricProducer,
Role,
User,
Zpool,
}

impl Display for ResourceType {
fn fmt(&self, f: &mut Formatter) -> FormatResult {
write!(
f,
"{}",
match self {
ResourceType::Organization => "organization",
ResourceType::Project => "project",
ResourceType::Dataset => "dataset",
ResourceType::Disk => "disk",
ResourceType::Instance => "instance",
ResourceType::NetworkInterface => "network interface",
ResourceType::Rack => "rack",
ResourceType::Sled => "sled",
ResourceType::SagaDbg => "saga_dbg",
ResourceType::Vpc => "vpc",
ResourceType::VpcFirewallRule => "vpc firewall rule",
ResourceType::VpcSubnet => "vpc subnet",
ResourceType::VpcRouter => "vpc router",
ResourceType::RouterRoute => "vpc router route",
ResourceType::Oximeter => "oximeter",
ResourceType::MetricProducer => "metric producer",
ResourceType::User => "user",
ResourceType::Zpool => "zpool",
}
)
}
}

pub async fn to_list<T, U>(object_stream: ObjectStream<T>) -> Vec<U>
where
T: Into<U>,
Expand Down Expand Up @@ -1841,13 +1911,14 @@ pub struct NetworkInterface {
#[cfg(test)]
mod test {
use super::{
ByteCount, L4Port, L4PortRange, Name, NetworkTarget,
ByteCount, L4Port, L4PortRange, Name, NetworkTarget, RoleName,
VpcFirewallRuleAction, VpcFirewallRuleDirection, VpcFirewallRuleFilter,
VpcFirewallRuleHostFilter, VpcFirewallRulePriority,
VpcFirewallRuleProtocol, VpcFirewallRuleStatus, VpcFirewallRuleTarget,
VpcFirewallRuleUpdate, VpcFirewallRuleUpdateParams,
};
use crate::api::external::Error;
use crate::api::external::ResourceType;
use std::convert::TryFrom;
use std::net::IpAddr;
use std::net::Ipv4Addr;
Expand Down Expand Up @@ -1900,6 +1971,83 @@ mod test {
}
}

#[test]
fn test_role_name_parse() {
// Error cases
let bad_inputs = vec![
// empty string is always worth testing
"",
// missing dot
"project",
// extra dot (or, illegal character in the second component)
"project.admin.super",
// missing resource type (or, another bogus resource type)
".admin",
// missing role name
"project.",
// illegal characters in role name
"project.not_good",
];

for input in bad_inputs {
eprintln!("check name {:?} (expecting error)", input);
let result =
input.parse::<RoleName>().expect_err("unexpectedly succeeded");
eprintln!("(expected) error: {:?}", result);
}

eprintln!("check name \"project.admin\" (expecting success)");
let role_name =
"project.admin".parse::<RoleName>().expect("failed to parse");
assert_eq!(role_name.to_string(), "project.admin");
assert_eq!(role_name.resource_type, "project");
assert_eq!(role_name.role_name, "admin");

eprintln!("check name \"barf.admin\" (expecting success)");
let role_name =
"barf.admin".parse::<RoleName>().expect("failed to parse");
assert_eq!(role_name.to_string(), "barf.admin");
assert_eq!(role_name.resource_type, "barf");
assert_eq!(role_name.role_name, "admin");

eprintln!("check name \"organization.super-user\" (expecting success)");
let role_name = "organization.super-user"
.parse::<RoleName>()
.expect("failed to parse");
assert_eq!(role_name.to_string(), "organization.super-user");
assert_eq!(role_name.resource_type, "organization");
assert_eq!(role_name.role_name, "super-user");
}

#[test]
fn test_resource_name_parse() {
let bad_inputs = vec![
"bogus",
"",
"Project",
"oRgAnIzAtIoN",
"organisation",
"vpc subnet",
"vpc_subnet",
];
for input in bad_inputs {
eprintln!("check resource type {:?} (expecting error)", input);
let result = input
.parse::<ResourceType>()
.expect_err("unexpectedly succeeded");
eprintln!("(expected) error: {:?}", result);
}

assert_eq!(
ResourceType::Project,
"project".parse::<ResourceType>().unwrap()
);
assert_eq!(
ResourceType::VpcSubnet,
"vpc-subnet".parse::<ResourceType>().unwrap()
);
}

#[test]
fn test_name_parse_from_param() {
let result = Name::from_param(String::from("my-name"), "the_name");
Expand Down
50 changes: 50 additions & 0 deletions common/src/sql/dbinit.sql
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,56 @@ INSERT INTO omicron.public.user_builtin (
);


/*
* Roles built into the system
*
* You can think of a built-in role as an opaque token to which we assign a
* hardcoded set of permissions. The role that we call "project.viewer"
* corresponds to the "viewer" role on the "project" resource. A user that has
* this role on a particular Project is granted various read-only permissions on
* that Project. The specific permissions associated with the role are defined
* in Omicron's Polar (Oso) policy file.
*
* A built-in role like "project.viewer" has four parts:
*
* * resource type: "project"
* * role name: "viewer"
* * full name: "project.viewer"
* * description: "Project Viewer"
*
* Internally, we can treat the tuple (resource type, role name) as a composite
* primary key. Externally, we expose this as the full name. This is
* consistent with RFD 43 and other IAM systems.
*
* These fields look awfully close to the identity metadata that we use for most
* other tables. But they're just different enough that we can't use most of
* the same abstractions:
*
* * "id": We have no need for a uuid because the (resource_type, role_name) is
* already unique and immutable.
* * "name": What we call "full name" above could instead be called "name",
* which would be consistent with other identity metadata. But it's not a
* legal "name" because of the period, and it would be confusing to have
* "resource type", "role name", and "name".
* * "time_created": not that useful because it's whenever the system was
* initialized, and we have plenty of other timestamps for that
* * "time_modified": does not apply because the role cannot be changed
* * "time_deleted" does not apply because the role cannot be deleted
*
* If the set of roles and their permissions are fixed, why store them in the
* database at all? Because what's dynamic is the assignment of roles to users.
* We [will] have a separate table that says "user U has role ROLE on resource
* RESOURCE". How do we represent the ROLE part of this association? We use a
* foreign key into this "role_builtin" table.
*/
CREATE TABLE omicron.public.role_builtin (
resource_type STRING(63),
role_name STRING(63),
description STRING(512),

PRIMARY KEY(resource_type, role_name)
);

/*******************************************************************/

/*
Expand Down
Loading