Skip to content

Commit dea2d6f

Browse files
authored
[SCIM 2/4]: Add SCIM user, group, and IDP (#9162)
Build on #8981 and add the SCIM user provision type to users and groups. Users must have a user name, and optionally have an external id field and/or an active field. Groups must have a display name and optionally have an external id field. Also add a new SiloIdentityMode named SamlScim. A few functions have to change to account for these new variants, but not many. Silos using the SamlScim identity mode will use all the same SAML code that exists today.
1 parent 7d5d83c commit dea2d6f

File tree

30 files changed

+877
-121
lines changed

30 files changed

+877
-121
lines changed

nexus/db-fixed-data/src/silo_user.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use std::sync::LazyLock;
1616
// not automatically at Nexus startup. See omicron#2305.
1717
pub static USER_TEST_PRIVILEGED: LazyLock<model::SiloUser> =
1818
LazyLock::new(|| {
19-
model::SiloUser::new_api_only_user(
19+
model::SiloUser::new_api_only(
2020
DEFAULT_SILO_ID,
2121
// "4007" looks a bit like "root".
2222
"001de000-05e4-4000-8000-000000004007".parse().unwrap(),
@@ -51,7 +51,7 @@ pub static ROLE_ASSIGNMENTS_PRIVILEGED: LazyLock<Vec<model::RoleAssignment>> =
5151
// not automatically at Nexus startup. See omicron#2305.
5252
pub static USER_TEST_UNPRIVILEGED: LazyLock<model::SiloUser> =
5353
LazyLock::new(|| {
54-
model::SiloUser::new_api_only_user(
54+
model::SiloUser::new_api_only(
5555
DEFAULT_SILO_ID,
5656
// 60001 is the decimal uid for "nobody" on Helios.
5757
"001de000-05e4-4000-8000-000000060001".parse().unwrap(),

nexus/db-model/src/schema_versions.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use std::{collections::BTreeMap, sync::LazyLock};
1616
///
1717
/// This must be updated when you change the database schema. Refer to
1818
/// schema/crdb/README.adoc in the root of this repository for details.
19-
pub const SCHEMA_VERSION: Version = Version::new(196, 0, 0);
19+
pub const SCHEMA_VERSION: Version = Version::new(197, 0, 0);
2020

2121
/// List of all past database schema versions, in *reverse* order
2222
///
@@ -28,6 +28,7 @@ static KNOWN_VERSIONS: LazyLock<Vec<KnownVersion>> = LazyLock::new(|| {
2828
// | leaving the first copy as an example for the next person.
2929
// v
3030
// KnownVersion::new(next_int, "unique-dirname-with-the-sql-files"),
31+
KnownVersion::new(197, "scim-users-and-groups"),
3132
KnownVersion::new(196, "user-provision-type-for-silo-user-and-group"),
3233
KnownVersion::new(195, "tuf-pruned-index"),
3334
KnownVersion::new(194, "tuf-pruned"),

nexus/db-model/src/silo.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,15 @@ impl_enum_type!(
5656
// Enum values
5757
ApiOnly => b"api_only"
5858
Jit => b"jit"
59+
Scim => b"scim"
5960
);
6061

6162
impl From<shared::UserProvisionType> for UserProvisionType {
6263
fn from(params: shared::UserProvisionType) -> Self {
6364
match params {
6465
shared::UserProvisionType::ApiOnly => UserProvisionType::ApiOnly,
6566
shared::UserProvisionType::Jit => UserProvisionType::Jit,
67+
shared::UserProvisionType::Scim => UserProvisionType::Scim,
6668
}
6769
}
6870
}
@@ -72,6 +74,7 @@ impl From<UserProvisionType> for shared::UserProvisionType {
7274
match model {
7375
UserProvisionType::ApiOnly => Self::ApiOnly,
7476
UserProvisionType::Jit => Self::Jit,
77+
UserProvisionType::Scim => Self::Scim,
7578
}
7679
}
7780
}
@@ -221,11 +224,16 @@ impl TryFrom<Silo> for views::Silo {
221224
(AuthenticationMode::Saml, UserProvisionType::Jit) => {
222225
Some(SiloIdentityMode::SamlJit)
223226
}
224-
(AuthenticationMode::Saml, UserProvisionType::ApiOnly) => None,
227+
225228
(AuthenticationMode::Local, UserProvisionType::ApiOnly) => {
226229
Some(SiloIdentityMode::LocalOnly)
227230
}
228-
(AuthenticationMode::Local, UserProvisionType::Jit) => None,
231+
232+
(AuthenticationMode::Saml, UserProvisionType::Scim) => {
233+
Some(SiloIdentityMode::SamlScim)
234+
}
235+
236+
_ => None,
229237
}
230238
.ok_or_else(|| {
231239
Error::internal_error(&format!(

nexus/db-model/src/silo_group.rs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,23 @@ pub struct SiloGroup {
2626

2727
/// If the user provision type is ApiOnly or JIT, then the external id is
2828
/// the identity provider's ID for this group. There is a database
29-
/// constraint (`lookup_silo_group_by_silo`) that ensures this field must be
29+
/// constraint (`external_id_consistency`) that ensures this field must be
3030
/// non-null for those provision types.
3131
///
3232
/// For SCIM, this may be null, which would trigger the uniqueness
3333
/// constraint if that wasn't limited to specific provision types.
3434
pub external_id: Option<String>,
3535

3636
pub user_provision_type: UserProvisionType,
37+
38+
/// For SCIM groups, display name must be Some. There is a database
39+
/// constraint (`display_name_consistency`) that ensures this field is
40+
/// non-null for that provision type.
41+
pub display_name: Option<String>,
3742
}
3843

3944
impl SiloGroup {
40-
pub fn new_api_only_group(
45+
pub fn new_api_only(
4146
id: SiloGroupUuid,
4247
silo_id: Uuid,
4348
external_id: String,
@@ -48,10 +53,11 @@ impl SiloGroup {
4853
silo_id,
4954
user_provision_type: UserProvisionType::ApiOnly,
5055
external_id: Some(external_id),
56+
display_name: None,
5157
}
5258
}
5359

54-
pub fn new_jit_group(
60+
pub fn new_jit(
5561
id: SiloGroupUuid,
5662
silo_id: Uuid,
5763
external_id: String,
@@ -62,6 +68,23 @@ impl SiloGroup {
6268
silo_id,
6369
user_provision_type: UserProvisionType::Jit,
6470
external_id: Some(external_id),
71+
display_name: None,
72+
}
73+
}
74+
75+
pub fn new_scim(
76+
id: SiloGroupUuid,
77+
silo_id: Uuid,
78+
display_name: String,
79+
external_id: Option<String>,
80+
) -> Self {
81+
Self {
82+
identity: SiloGroupIdentity::new(id),
83+
time_deleted: None,
84+
silo_id,
85+
user_provision_type: UserProvisionType::Scim,
86+
external_id,
87+
display_name: Some(display_name),
6588
}
6689
}
6790
}

nexus/db-model/src/silo_user.rs

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,31 @@ pub struct SiloUser {
2121

2222
/// If the user provision type is ApiOnly or JIT, then the external id is
2323
/// the identity provider's ID for this user. There is a database constraint
24-
/// (`lookup_silo_user_by_silo`) that ensures this field must be non-null
25-
/// for those provision types.
24+
/// (`external_id_consistency`) that ensures this field must be non-null for
25+
/// those provision types.
2626
///
2727
/// For SCIM, this may be null, which would trigger the uniqueness
2828
/// constraint if that wasn't limited to specific provision types.
2929
pub external_id: Option<String>,
3030

3131
pub user_provision_type: UserProvisionType,
32+
33+
/// For SCIM users, user name must be Some. There is a database constraint
34+
/// (`user_name_consistency`) that ensures this field is non-null for that
35+
/// provision type.
36+
pub user_name: Option<String>,
37+
38+
/// For SCIM users, active describes whether or not the user is allowed to
39+
/// have active sessions.
40+
///
41+
/// Note this field isn't mandatory for SCIM provisioning clients to
42+
/// support. Using an option here lets us determine if the client sent us
43+
/// nothing, or of they sent us a value.
44+
pub active: Option<bool>,
3245
}
3346

3447
impl SiloUser {
35-
pub fn new_api_only_user(
48+
pub fn new_api_only(
3649
silo_id: Uuid,
3750
user_id: SiloUserUuid,
3851
external_id: String,
@@ -43,10 +56,12 @@ impl SiloUser {
4356
silo_id,
4457
external_id: Some(external_id),
4558
user_provision_type: UserProvisionType::ApiOnly,
59+
user_name: None,
60+
active: None,
4661
}
4762
}
4863

49-
pub fn new_jit_user(
64+
pub fn new_jit(
5065
silo_id: Uuid,
5166
user_id: SiloUserUuid,
5267
external_id: String,
@@ -57,6 +72,26 @@ impl SiloUser {
5772
silo_id,
5873
external_id: Some(external_id),
5974
user_provision_type: UserProvisionType::Jit,
75+
user_name: None,
76+
active: None,
77+
}
78+
}
79+
80+
pub fn new_scim(
81+
silo_id: Uuid,
82+
user_id: SiloUserUuid,
83+
user_name: String,
84+
external_id: Option<String>,
85+
active: Option<bool>,
86+
) -> Self {
87+
Self {
88+
identity: SiloUserIdentity::new(user_id),
89+
time_deleted: None,
90+
silo_id,
91+
external_id,
92+
user_provision_type: UserProvisionType::Scim,
93+
user_name: Some(user_name),
94+
active,
6095
}
6196
}
6297
}

nexus/db-queries/src/db/datastore/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ pub use silo_user::SiloUser;
152152
pub use silo_user::SiloUserApiOnly;
153153
pub use silo_user::SiloUserJit;
154154
pub use silo_user::SiloUserLookup;
155+
pub use silo_user::SiloUserScim;
155156
pub use sled::SledTransition;
156157
pub use sled::TransitionError;
157158
pub use support_bundle::SupportBundleExpungementReport;

nexus/db-queries/src/db/datastore/rack.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -472,13 +472,13 @@ impl DataStore {
472472
let silo_user_id = SiloUserUuid::new_v4();
473473

474474
let silo_user = match &db_silo.user_provision_type {
475-
UserProvisionType::ApiOnly => SiloUser::new_api_only_user(
475+
UserProvisionType::ApiOnly => SiloUser::new_api_only(
476476
db_silo.id(),
477477
silo_user_id,
478478
recovery_user_id.as_ref().to_owned(),
479479
),
480480

481-
UserProvisionType::Jit => {
481+
UserProvisionType::Jit | UserProvisionType::Scim => {
482482
unreachable!("match at start of function should prevent this");
483483
}
484484
};
@@ -1266,16 +1266,21 @@ mod test {
12661266
)
12671267
.await
12681268
.expect("failed to list users");
1269+
12691270
assert_eq!(silo_users.len(), 1);
1270-
assert_eq!(
1271-
silo_users[0].external_id(),
1272-
rack_init.recovery_user_id.as_ref()
1273-
);
1271+
1272+
let db::datastore::SiloUser::ApiOnly(silo_user) = &silo_users[0] else {
1273+
panic!("wrong user type {:?}", silo_users[0].user_provision_type());
1274+
};
1275+
1276+
assert_eq!(silo_user.external_id, rack_init.recovery_user_id.as_ref());
1277+
12741278
let authz_silo_user = authz::SiloUser::new(
12751279
authz_silo,
12761280
silo_users[0].id(),
12771281
LookupType::by_id(silo_users[0].id()),
12781282
);
1283+
12791284
let hash = datastore
12801285
.silo_user_password_hash_fetch(&opctx, &authz_silo_user)
12811286
.await

nexus/db-queries/src/db/datastore/silo.rs

Lines changed: 46 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -192,62 +192,73 @@ impl DataStore {
192192
let silo_admin_group_ensure_query = if let Some(ref admin_group_name) =
193193
new_silo_params.admin_group_name
194194
{
195-
let silo_admin_group =
195+
let maybe_silo_admin_group =
196196
match new_silo_params.identity_mode.user_provision_type() {
197197
shared::UserProvisionType::ApiOnly => {
198-
db::model::SiloGroup::new_api_only_group(
198+
Some(db::model::SiloGroup::new_api_only(
199199
silo_group_id,
200200
silo_id,
201201
admin_group_name.clone(),
202-
)
202+
))
203203
}
204204

205205
shared::UserProvisionType::Jit => {
206-
db::model::SiloGroup::new_jit_group(
206+
Some(db::model::SiloGroup::new_jit(
207207
silo_group_id,
208208
silo_id,
209209
admin_group_name.clone(),
210-
)
210+
))
211+
}
212+
213+
shared::UserProvisionType::Scim => {
214+
// Do not create any group automatically, the SCIM
215+
// provisioning client is responsible for all user and
216+
// group CRUD.
217+
None
211218
}
212219
};
213220

214-
let silo_admin_group_ensure_query =
215-
DataStore::silo_group_ensure_query(
216-
&nexus_opctx,
217-
&authz_silo,
218-
silo_admin_group,
219-
)
220-
.await?;
221+
if let Some(silo_admin_group) = maybe_silo_admin_group {
222+
let silo_admin_group_ensure_query =
223+
DataStore::silo_group_ensure_query(
224+
&nexus_opctx,
225+
&authz_silo,
226+
silo_admin_group,
227+
)
228+
.await?;
221229

222-
Some(silo_admin_group_ensure_query)
230+
Some(silo_admin_group_ensure_query)
231+
} else {
232+
None
233+
}
223234
} else {
224235
None
225236
};
226237

227-
let silo_admin_group_role_assignment_queries = if new_silo_params
228-
.admin_group_name
229-
.is_some()
230-
{
231-
// Grant silo admin role for members of the admin group.
232-
let policy = shared::Policy {
233-
role_assignments: vec![shared::RoleAssignment::for_silo_group(
234-
silo_group_id,
235-
SiloRole::Admin,
236-
)],
237-
};
238+
let silo_admin_group_role_assignment_queries =
239+
if silo_admin_group_ensure_query.is_some() {
240+
// Grant silo admin role for members of the admin group.
241+
let policy = shared::Policy {
242+
role_assignments: vec![
243+
shared::RoleAssignment::for_silo_group(
244+
silo_group_id,
245+
SiloRole::Admin,
246+
),
247+
],
248+
};
238249

239-
let silo_admin_group_role_assignment_queries =
240-
DataStore::role_assignment_replace_visible_queries(
241-
opctx,
242-
&authz_silo,
243-
&policy.role_assignments,
244-
)
245-
.await?;
250+
let silo_admin_group_role_assignment_queries =
251+
DataStore::role_assignment_replace_visible_queries(
252+
opctx,
253+
&authz_silo,
254+
&policy.role_assignments,
255+
)
256+
.await?;
246257

247-
Some(silo_admin_group_role_assignment_queries)
248-
} else {
249-
None
250-
};
258+
Some(silo_admin_group_role_assignment_queries)
259+
} else {
260+
None
261+
};
251262

252263
// This method uses nested transactions, which are not supported
253264
// with retryable transactions.

0 commit comments

Comments
 (0)