Skip to content

Commit 656d3ad

Browse files
davepachecosmklein
andauthored
authz: add built-in roles to the database (#512)
Co-authored-by: Sean Klein <[email protected]>
1 parent 71162c1 commit 656d3ad

File tree

24 files changed

+1442
-194
lines changed

24 files changed

+1442
-194
lines changed

common/src/api/external/mod.rs

Lines changed: 179 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,90 @@ impl Name {
296296
}
297297
}
298298

299+
/**
300+
* Name for a built-in role
301+
*/
302+
#[derive(
303+
Clone,
304+
Debug,
305+
DeserializeFromStr,
306+
Display,
307+
Eq,
308+
FromStr,
309+
Ord,
310+
PartialEq,
311+
PartialOrd,
312+
SerializeDisplay,
313+
)]
314+
#[display("{resource_type}.{role_name}")]
315+
pub struct RoleName {
316+
// "resource_type" is generally the String value of one of the
317+
// `ResourceType` variants. We could store the parsed `ResourceType`
318+
// instead, but it's useful to be able to represent RoleNames for resource
319+
// types that we don't know about. That could happen if we happen to find
320+
// them in the database, for example.
321+
#[from_str(regex = "[a-z-]+")]
322+
resource_type: String,
323+
#[from_str(regex = "[a-z-]+")]
324+
role_name: String,
325+
}
326+
327+
impl RoleName {
328+
pub fn new(resource_type: &str, role_name: &str) -> RoleName {
329+
RoleName {
330+
resource_type: String::from(resource_type),
331+
role_name: String::from(role_name),
332+
}
333+
}
334+
}
335+
336+
/**
337+
* Custom JsonSchema implementation to encode the constraints on Name
338+
*/
339+
/* TODO see TODOs on Name above */
340+
impl JsonSchema for RoleName {
341+
fn schema_name() -> String {
342+
"RoleName".to_string()
343+
}
344+
fn json_schema(
345+
_gen: &mut schemars::gen::SchemaGenerator,
346+
) -> schemars::schema::Schema {
347+
schemars::schema::Schema::Object(schemars::schema::SchemaObject {
348+
metadata: Some(Box::new(schemars::schema::Metadata {
349+
id: None,
350+
title: Some("A name for a built-in role".to_string()),
351+
description: Some(
352+
"Role names consist of two string components \
353+
separated by dot (\".\")."
354+
.to_string(),
355+
),
356+
default: None,
357+
deprecated: false,
358+
read_only: false,
359+
write_only: false,
360+
examples: vec![],
361+
})),
362+
instance_type: Some(schemars::schema::SingleOrVec::Single(
363+
Box::new(schemars::schema::InstanceType::String),
364+
)),
365+
format: None,
366+
enum_values: None,
367+
const_value: None,
368+
subschemas: None,
369+
number: None,
370+
string: Some(Box::new(schemars::schema::StringValidation {
371+
max_length: Some(63),
372+
min_length: None,
373+
pattern: Some("[a-z-]+\\.[a-z-]+".to_string()),
374+
})),
375+
array: None,
376+
object: None,
377+
reference: None,
378+
extensions: BTreeMap::new(),
379+
})
380+
}
381+
}
382+
299383
/**
300384
* A count of bytes, typically used either for memory or storage capacity
301385
*
@@ -465,8 +549,22 @@ impl TryFrom<i64> for Generation {
465549
/**
466550
* Identifies a type of API resource
467551
*/
468-
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
552+
#[derive(
553+
Clone,
554+
Copy,
555+
Debug,
556+
DeserializeFromStr,
557+
Display,
558+
Eq,
559+
FromStr,
560+
Ord,
561+
PartialEq,
562+
PartialOrd,
563+
SerializeDisplay,
564+
)]
565+
#[display(style = "kebab-case")]
469566
pub enum ResourceType {
567+
Fleet,
470568
Organization,
471569
Project,
472570
Dataset,
@@ -483,39 +581,11 @@ pub enum ResourceType {
483581
RouterRoute,
484582
Oximeter,
485583
MetricProducer,
584+
Role,
486585
User,
487586
Zpool,
488587
}
489588

490-
impl Display for ResourceType {
491-
fn fmt(&self, f: &mut Formatter) -> FormatResult {
492-
write!(
493-
f,
494-
"{}",
495-
match self {
496-
ResourceType::Organization => "organization",
497-
ResourceType::Project => "project",
498-
ResourceType::Dataset => "dataset",
499-
ResourceType::Disk => "disk",
500-
ResourceType::Instance => "instance",
501-
ResourceType::NetworkInterface => "network interface",
502-
ResourceType::Rack => "rack",
503-
ResourceType::Sled => "sled",
504-
ResourceType::SagaDbg => "saga_dbg",
505-
ResourceType::Vpc => "vpc",
506-
ResourceType::VpcFirewallRule => "vpc firewall rule",
507-
ResourceType::VpcSubnet => "vpc subnet",
508-
ResourceType::VpcRouter => "vpc router",
509-
ResourceType::RouterRoute => "vpc router route",
510-
ResourceType::Oximeter => "oximeter",
511-
ResourceType::MetricProducer => "metric producer",
512-
ResourceType::User => "user",
513-
ResourceType::Zpool => "zpool",
514-
}
515-
)
516-
}
517-
}
518-
519589
pub async fn to_list<T, U>(object_stream: ObjectStream<T>) -> Vec<U>
520590
where
521591
T: Into<U>,
@@ -1841,13 +1911,14 @@ pub struct NetworkInterface {
18411911
#[cfg(test)]
18421912
mod test {
18431913
use super::{
1844-
ByteCount, L4Port, L4PortRange, Name, NetworkTarget,
1914+
ByteCount, L4Port, L4PortRange, Name, NetworkTarget, RoleName,
18451915
VpcFirewallRuleAction, VpcFirewallRuleDirection, VpcFirewallRuleFilter,
18461916
VpcFirewallRuleHostFilter, VpcFirewallRulePriority,
18471917
VpcFirewallRuleProtocol, VpcFirewallRuleStatus, VpcFirewallRuleTarget,
18481918
VpcFirewallRuleUpdate, VpcFirewallRuleUpdateParams,
18491919
};
18501920
use crate::api::external::Error;
1921+
use crate::api::external::ResourceType;
18511922
use std::convert::TryFrom;
18521923
use std::net::IpAddr;
18531924
use std::net::Ipv4Addr;
@@ -1900,6 +1971,83 @@ mod test {
19001971
}
19011972
}
19021973

1974+
#[test]
1975+
fn test_role_name_parse() {
1976+
// Error cases
1977+
let bad_inputs = vec![
1978+
// empty string is always worth testing
1979+
"",
1980+
// missing dot
1981+
"project",
1982+
// extra dot (or, illegal character in the second component)
1983+
"project.admin.super",
1984+
// missing resource type (or, another bogus resource type)
1985+
".admin",
1986+
// missing role name
1987+
"project.",
1988+
// illegal characters in role name
1989+
"project.not_good",
1990+
];
1991+
1992+
for input in bad_inputs {
1993+
eprintln!("check name {:?} (expecting error)", input);
1994+
let result =
1995+
input.parse::<RoleName>().expect_err("unexpectedly succeeded");
1996+
eprintln!("(expected) error: {:?}", result);
1997+
}
1998+
1999+
eprintln!("check name \"project.admin\" (expecting success)");
2000+
let role_name =
2001+
"project.admin".parse::<RoleName>().expect("failed to parse");
2002+
assert_eq!(role_name.to_string(), "project.admin");
2003+
assert_eq!(role_name.resource_type, "project");
2004+
assert_eq!(role_name.role_name, "admin");
2005+
2006+
eprintln!("check name \"barf.admin\" (expecting success)");
2007+
let role_name =
2008+
"barf.admin".parse::<RoleName>().expect("failed to parse");
2009+
assert_eq!(role_name.to_string(), "barf.admin");
2010+
assert_eq!(role_name.resource_type, "barf");
2011+
assert_eq!(role_name.role_name, "admin");
2012+
2013+
eprintln!("check name \"organization.super-user\" (expecting success)");
2014+
let role_name = "organization.super-user"
2015+
.parse::<RoleName>()
2016+
.expect("failed to parse");
2017+
assert_eq!(role_name.to_string(), "organization.super-user");
2018+
assert_eq!(role_name.resource_type, "organization");
2019+
assert_eq!(role_name.role_name, "super-user");
2020+
}
2021+
2022+
#[test]
2023+
fn test_resource_name_parse() {
2024+
let bad_inputs = vec![
2025+
"bogus",
2026+
"",
2027+
"Project",
2028+
"oRgAnIzAtIoN",
2029+
"organisation",
2030+
"vpc subnet",
2031+
"vpc_subnet",
2032+
];
2033+
for input in bad_inputs {
2034+
eprintln!("check resource type {:?} (expecting error)", input);
2035+
let result = input
2036+
.parse::<ResourceType>()
2037+
.expect_err("unexpectedly succeeded");
2038+
eprintln!("(expected) error: {:?}", result);
2039+
}
2040+
2041+
assert_eq!(
2042+
ResourceType::Project,
2043+
"project".parse::<ResourceType>().unwrap()
2044+
);
2045+
assert_eq!(
2046+
ResourceType::VpcSubnet,
2047+
"vpc-subnet".parse::<ResourceType>().unwrap()
2048+
);
2049+
}
2050+
19032051
#[test]
19042052
fn test_name_parse_from_param() {
19052053
let result = Name::from_param(String::from("my-name"), "the_name");

common/src/sql/dbinit.sql

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,56 @@ INSERT INTO omicron.public.user_builtin (
707707
);
708708

709709

710+
/*
711+
* Roles built into the system
712+
*
713+
* You can think of a built-in role as an opaque token to which we assign a
714+
* hardcoded set of permissions. The role that we call "project.viewer"
715+
* corresponds to the "viewer" role on the "project" resource. A user that has
716+
* this role on a particular Project is granted various read-only permissions on
717+
* that Project. The specific permissions associated with the role are defined
718+
* in Omicron's Polar (Oso) policy file.
719+
*
720+
* A built-in role like "project.viewer" has four parts:
721+
*
722+
* * resource type: "project"
723+
* * role name: "viewer"
724+
* * full name: "project.viewer"
725+
* * description: "Project Viewer"
726+
*
727+
* Internally, we can treat the tuple (resource type, role name) as a composite
728+
* primary key. Externally, we expose this as the full name. This is
729+
* consistent with RFD 43 and other IAM systems.
730+
*
731+
* These fields look awfully close to the identity metadata that we use for most
732+
* other tables. But they're just different enough that we can't use most of
733+
* the same abstractions:
734+
*
735+
* * "id": We have no need for a uuid because the (resource_type, role_name) is
736+
* already unique and immutable.
737+
* * "name": What we call "full name" above could instead be called "name",
738+
* which would be consistent with other identity metadata. But it's not a
739+
* legal "name" because of the period, and it would be confusing to have
740+
* "resource type", "role name", and "name".
741+
* * "time_created": not that useful because it's whenever the system was
742+
* initialized, and we have plenty of other timestamps for that
743+
* * "time_modified": does not apply because the role cannot be changed
744+
* * "time_deleted" does not apply because the role cannot be deleted
745+
*
746+
* If the set of roles and their permissions are fixed, why store them in the
747+
* database at all? Because what's dynamic is the assignment of roles to users.
748+
* We [will] have a separate table that says "user U has role ROLE on resource
749+
* RESOURCE". How do we represent the ROLE part of this association? We use a
750+
* foreign key into this "role_builtin" table.
751+
*/
752+
CREATE TABLE omicron.public.role_builtin (
753+
resource_type STRING(63),
754+
role_name STRING(63),
755+
description STRING(512),
756+
757+
PRIMARY KEY(resource_type, role_name)
758+
);
759+
710760
/*******************************************************************/
711761

712762
/*

0 commit comments

Comments
 (0)