Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
scim::{SCIM_DOMAIN, ScimTest},
|
||||
utils::containers,
|
||||
};
|
||||
use ahash::AHashMap;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||
use serde_json::Value;
|
||||
|
||||
const DRIVER: &str = include_str!("../../docker/scim/driver.py");
|
||||
|
||||
const URL: &str = "https://host.docker.internal:8899/scim/v2";
|
||||
const NON_MAILBOX_USER_NAME: &str = "is not a valid email address";
|
||||
const STRICT_TAGS: [&str; 5] = [
|
||||
"discovery",
|
||||
"service-provider-config",
|
||||
"resource-types",
|
||||
"schemas",
|
||||
"misc",
|
||||
];
|
||||
|
||||
pub fn is_enabled() -> bool {
|
||||
std::env::var("SCIM_CONFORMANCE").is_ok_and(|value| value == "1")
|
||||
}
|
||||
|
||||
pub async fn test(scim: &ScimTest) {
|
||||
println!("Running SCIM third party conformance tests...");
|
||||
containers::ensure_scim_tester().await;
|
||||
|
||||
the_lifecycle_survives_a_third_party_client(scim).await;
|
||||
real_client_payloads_are_accepted(scim).await;
|
||||
the_conformance_checker_reports_no_failures(scim).await;
|
||||
}
|
||||
|
||||
async fn real_client_payloads_are_accepted(scim: &ScimTest) {
|
||||
let report = run(scim, "clients").await;
|
||||
let steps = report["steps"]
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("Missing steps in {report}"));
|
||||
|
||||
assert!(!steps.is_empty(), "The driver ran no steps: {report}");
|
||||
for step in steps {
|
||||
if step["ok"] != Value::Bool(true) {
|
||||
panic!(
|
||||
"The {} payload was refused:\n{}",
|
||||
step["step"].as_str().unwrap_or_default(),
|
||||
step["detail"].as_str().unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!(" replayed {} real client payloads", steps.len());
|
||||
}
|
||||
|
||||
async fn the_lifecycle_survives_a_third_party_client(scim: &ScimTest) {
|
||||
let report = run(scim, "lifecycle").await;
|
||||
let steps = report["steps"]
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("Missing steps in {report}"));
|
||||
|
||||
assert!(!steps.is_empty(), "The driver ran no steps: {report}");
|
||||
for step in steps {
|
||||
if step["ok"] != Value::Bool(true) {
|
||||
panic!(
|
||||
"scim2-client step '{}' failed:\n{}",
|
||||
step["step"].as_str().unwrap_or_default(),
|
||||
step["detail"].as_str().unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!(" scim2-client completed {} lifecycle steps", steps.len());
|
||||
}
|
||||
|
||||
async fn the_conformance_checker_reports_no_failures(scim: &ScimTest) {
|
||||
let report = run(scim, "conformance").await;
|
||||
let checks = report["checks"]
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("Missing checks in {report}"));
|
||||
|
||||
assert!(!checks.is_empty(), "The checker ran no checks: {report}");
|
||||
|
||||
let mut totals: AHashMap<String, usize> = AHashMap::new();
|
||||
let mut expected = 0;
|
||||
let mut failures = Vec::new();
|
||||
let mut strict_checks = 0;
|
||||
|
||||
for check in checks {
|
||||
let status = check["status"].as_str().unwrap_or_default();
|
||||
let reason = check["reason"].as_str().unwrap_or_default();
|
||||
let title = check["title"].as_str().unwrap_or_default();
|
||||
let tags = check["tags"]
|
||||
.as_array()
|
||||
.map(|tags| {
|
||||
tags.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
*totals.entry(status.to_string()).or_default() += 1;
|
||||
if tags.iter().any(|tag| STRICT_TAGS.contains(&tag.as_str())) {
|
||||
strict_checks += 1;
|
||||
}
|
||||
|
||||
if !matches!(status, "ERROR" | "CRITICAL" | "DEVIATION") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if status == "ERROR" && reason.contains(NON_MAILBOX_USER_NAME) {
|
||||
expected += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
failures.push(format!(
|
||||
" [{status}] {title} ({}): {reason}",
|
||||
tags.join(",")
|
||||
));
|
||||
}
|
||||
|
||||
let mut summary = totals.into_iter().collect::<Vec<_>>();
|
||||
summary.sort();
|
||||
println!(
|
||||
" scim2-tester ran {} checks ({strict_checks} of them on the discovery endpoints): {}",
|
||||
checks.len(),
|
||||
summary
|
||||
.iter()
|
||||
.map(|(status, count)| format!("{status}={count}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
);
|
||||
println!(" {expected} checks failed because the generated userName is not a mailbox address");
|
||||
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"scim2-tester reported unexplained failures:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
assert!(
|
||||
strict_checks > 0,
|
||||
"The checker ran no discovery checks: {report}"
|
||||
);
|
||||
}
|
||||
|
||||
async fn run(scim: &ScimTest, mode: &str) -> Value {
|
||||
let command = format!(
|
||||
"echo '{}' | base64 -d > /tmp/driver.py && exec python /tmp/driver.py \
|
||||
--url {URL} --token {} --domain {SCIM_DOMAIN} --mode {mode}",
|
||||
STANDARD.encode(DRIVER.as_bytes()),
|
||||
scim.token,
|
||||
);
|
||||
let (stdout, stderr) = containers::scim_tester_exec(&["sh", "-c", &command]).await;
|
||||
|
||||
let report = stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|line| line.starts_with('{'))
|
||||
.unwrap_or_else(|| {
|
||||
panic!("The SCIM driver produced no report.\nstdout:\n{stdout}\nstderr:\n{stderr}")
|
||||
});
|
||||
|
||||
let report = serde_json::from_str::<Value>(report).unwrap_or_else(|err| {
|
||||
panic!("The SCIM driver report is not valid JSON: {err}\n{stdout}\n{stderr}")
|
||||
});
|
||||
|
||||
if let Some(error) = report.get("error").and_then(Value::as_str) {
|
||||
panic!("The SCIM driver failed: {error}");
|
||||
}
|
||||
|
||||
report
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
scim::{ScimTest, patch_body, query},
|
||||
utils::{containers, server::TestServer},
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{self, Action, OidcDirectory, UserAccount},
|
||||
},
|
||||
types::map::Map,
|
||||
};
|
||||
use scim_proto::SCHEMA_USER;
|
||||
use serde_json::json;
|
||||
use std::str::FromStr;
|
||||
use types::id::Id;
|
||||
|
||||
pub const OIDC_DOMAIN: &str = "example.org";
|
||||
const KEYCLOAK_PASSWORD: &str = "this is an OIDC password";
|
||||
const JOHN: &str = "[email protected]";
|
||||
const BILL: &str = "[email protected]";
|
||||
const SCIM_DISPLAY_NAME: &str = "Provisioned By SCIM";
|
||||
const SCIM_GROUP: &str = "Provisioned Group";
|
||||
|
||||
pub async fn test(test: &TestServer, scim: &ScimTest) {
|
||||
println!("Running SCIM OIDC interaction tests...");
|
||||
containers::ensure_keycloak().await;
|
||||
|
||||
let domain_id = bind_directory(test).await;
|
||||
|
||||
just_in_time_account_creation_is_disabled(test, scim).await;
|
||||
scim_managed_attributes_survive_a_login(test, scim).await;
|
||||
clearing_the_flag_restores_just_in_time_provisioning(test, scim, domain_id).await;
|
||||
|
||||
cleanup(test, scim, domain_id).await;
|
||||
}
|
||||
|
||||
async fn bind_directory(test: &TestServer) -> Id {
|
||||
let admin = test.account("admin");
|
||||
let directory_id = admin
|
||||
.registry_create_object(structs::Directory::Oidc(OidcDirectory {
|
||||
description: "SCIM test OIDC directory".to_string(),
|
||||
issuer_url: "http://localhost:9080/realms/stalwart".to_string(),
|
||||
claim_username: "email".to_string(),
|
||||
claim_name: Some("name".to_string()),
|
||||
claim_groups: Some("groups".to_string()),
|
||||
username_domain: None,
|
||||
require_audience: Some("stalwart".to_string()),
|
||||
require_scopes: Map::new(vec![
|
||||
"email".to_string(),
|
||||
"profile".to_string(),
|
||||
"openid".to_string(),
|
||||
]),
|
||||
member_tenant_id: None,
|
||||
}))
|
||||
.await;
|
||||
|
||||
let domain_id = admin.find_or_create_domain(OIDC_DOMAIN).await;
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Domain,
|
||||
domain_id,
|
||||
json!({
|
||||
Property::DirectoryId: directory_id.to_string(),
|
||||
Property::AllowScimProvisioning: true,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
admin.reload_settings().await;
|
||||
admin.registry_create_object(Action::InvalidateCaches).await;
|
||||
|
||||
domain_id
|
||||
}
|
||||
|
||||
async fn just_in_time_account_creation_is_disabled(test: &TestServer, scim: &ScimTest) {
|
||||
let token = keycloak_token(BILL).await;
|
||||
|
||||
assert_eq!(
|
||||
bearer_session_status(&token).await,
|
||||
401,
|
||||
"A domain provisioned through SCIM must not create accounts on login"
|
||||
);
|
||||
|
||||
let response = scim
|
||||
.client
|
||||
.get(&query("/Users", &format!("userName eq \"{BILL}\"")))
|
||||
.await;
|
||||
response.assert_status(200);
|
||||
assert_eq!(
|
||||
response.total_results(),
|
||||
0,
|
||||
"The login created an account: {}",
|
||||
response.body
|
||||
);
|
||||
assert!(!account_exists(test, BILL).await);
|
||||
assert!(!account_exists(test, "[email protected]").await);
|
||||
}
|
||||
|
||||
async fn scim_managed_attributes_survive_a_login(test: &TestServer, scim: &ScimTest) {
|
||||
let user_id = scim
|
||||
.client
|
||||
.post(
|
||||
"/Users",
|
||||
json!({
|
||||
"schemas": [SCHEMA_USER],
|
||||
"userName": JOHN,
|
||||
"displayName": SCIM_DISPLAY_NAME,
|
||||
"externalId": "OIDC-1",
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.assert_status(201)
|
||||
.id();
|
||||
let group_id = scim.create_group(SCIM_GROUP).await;
|
||||
|
||||
scim.client
|
||||
.patch(
|
||||
&format!("/Groups/{group_id}"),
|
||||
patch_body(json!([{"op": "add", "path": "members", "value": [{"value": user_id}]}])),
|
||||
)
|
||||
.await
|
||||
.assert_status(200);
|
||||
|
||||
let before = scim.client.get(&format!("/Users/{user_id}")).await;
|
||||
before.assert_status(200);
|
||||
|
||||
let token = keycloak_token(JOHN).await;
|
||||
assert_eq!(
|
||||
bearer_session_status(&token).await,
|
||||
200,
|
||||
"A SCIM provisioned account must still authenticate over OIDC"
|
||||
);
|
||||
|
||||
let after = scim.client.get(&format!("/Users/{user_id}")).await;
|
||||
after.assert_status(200);
|
||||
assert_eq!(
|
||||
after.json["displayName"],
|
||||
json!(SCIM_DISPLAY_NAME),
|
||||
"The OIDC name claim overwrote the SCIM displayName"
|
||||
);
|
||||
assert_eq!(
|
||||
after.json["groups"], before.json["groups"],
|
||||
"The OIDC groups claim overwrote the SCIM membership"
|
||||
);
|
||||
assert_eq!(after.json["groups"][0]["display"], json!(SCIM_GROUP));
|
||||
assert_eq!(after.etag(), before.etag());
|
||||
|
||||
assert!(
|
||||
!account_exists(test, "[email protected]").await,
|
||||
"The OIDC groups claim created a group account"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
bearer_session_status(&keycloak_token(JOHN).await).await,
|
||||
200,
|
||||
"A second login must be equally inert"
|
||||
);
|
||||
let repeated = scim.client.get(&format!("/Users/{user_id}")).await;
|
||||
assert_eq!(repeated.etag(), before.etag());
|
||||
}
|
||||
|
||||
async fn clearing_the_flag_restores_just_in_time_provisioning(
|
||||
test: &TestServer,
|
||||
scim: &ScimTest,
|
||||
domain_id: Id,
|
||||
) {
|
||||
let john_id = scim
|
||||
.client
|
||||
.get(&query("/Users", &format!("userName eq \"{JOHN}\"")))
|
||||
.await
|
||||
.resource_ids()
|
||||
.remove(0);
|
||||
|
||||
let admin = test.account("admin");
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Domain,
|
||||
domain_id,
|
||||
json!({ Property::AllowScimProvisioning: false }),
|
||||
)
|
||||
.await;
|
||||
admin.registry_create_object(Action::InvalidateCaches).await;
|
||||
|
||||
assert_eq!(
|
||||
bearer_session_status(&keycloak_token(BILL).await).await,
|
||||
200,
|
||||
"Just-in-time provisioning must be unchanged when the flag is off"
|
||||
);
|
||||
|
||||
let bill = user_account(
|
||||
test,
|
||||
account_id(test, BILL).await.expect("Bill was not created"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(bill.description.as_deref(), Some("Bill Foobar"));
|
||||
let corporate = account_id(test, "[email protected]")
|
||||
.await
|
||||
.expect("The groups claim did not create a group account");
|
||||
assert!(bill.member_group_ids.contains(&corporate));
|
||||
|
||||
assert_eq!(
|
||||
bearer_session_status(&keycloak_token(JOHN).await).await,
|
||||
200
|
||||
);
|
||||
|
||||
let john = user_account(test, Id::from_str(&john_id).unwrap()).await;
|
||||
assert_eq!(
|
||||
john.description.as_deref(),
|
||||
Some("John Doe"),
|
||||
"Without the flag the name claim must win, which is what the flag exists to prevent"
|
||||
);
|
||||
let sales = account_id(test, "[email protected]")
|
||||
.await
|
||||
.expect("The groups claim did not create a group account");
|
||||
assert_eq!(
|
||||
john.member_group_ids.iter().copied().collect::<Vec<_>>(),
|
||||
vec![sales],
|
||||
"Without the flag the groups claim must replace the SCIM membership"
|
||||
);
|
||||
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Domain,
|
||||
domain_id,
|
||||
json!({ Property::AllowScimProvisioning: true }),
|
||||
)
|
||||
.await;
|
||||
admin.registry_create_object(Action::InvalidateCaches).await;
|
||||
}
|
||||
|
||||
async fn user_account(test: &TestServer, id: Id) -> UserAccount {
|
||||
match test
|
||||
.server
|
||||
.registry()
|
||||
.object::<structs::Account>(id)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("The account no longer exists")
|
||||
{
|
||||
structs::Account::User(account) => account,
|
||||
other => panic!("Expected a user account but got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup(test: &TestServer, scim: &ScimTest, domain_id: Id) {
|
||||
let admin = test.account("admin");
|
||||
|
||||
for user_name in [JOHN, BILL] {
|
||||
let ids = scim
|
||||
.client
|
||||
.get(&query("/Users", &format!("userName eq \"{user_name}\"")))
|
||||
.await
|
||||
.resource_ids();
|
||||
for id in ids {
|
||||
scim.client.delete(&format!("/Users/{id}")).await;
|
||||
}
|
||||
}
|
||||
|
||||
let group_ids = scim
|
||||
.client
|
||||
.get(&query(
|
||||
"/Groups",
|
||||
&format!("displayName eq \"{SCIM_GROUP}\""),
|
||||
))
|
||||
.await
|
||||
.resource_ids();
|
||||
for id in group_ids {
|
||||
scim.client.delete(&format!("/Groups/{id}")).await;
|
||||
}
|
||||
|
||||
for address in ["[email protected]", "[email protected]"] {
|
||||
if let Some(id) = account_id(test, address).await {
|
||||
admin.registry_destroy(ObjectType::Account, [id]).await;
|
||||
}
|
||||
}
|
||||
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Domain,
|
||||
domain_id,
|
||||
json!({
|
||||
Property::DirectoryId: Option::<String>::None,
|
||||
Property::AllowScimProvisioning: false,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
admin.reload_settings().await;
|
||||
admin.registry_create_object(Action::InvalidateCaches).await;
|
||||
}
|
||||
|
||||
async fn account_exists(test: &TestServer, address: &str) -> bool {
|
||||
account_id(test, address).await.is_some()
|
||||
}
|
||||
|
||||
async fn account_id(test: &TestServer, address: &str) -> Option<Id> {
|
||||
test.server
|
||||
.account_id_from_email(address, false)
|
||||
.await
|
||||
.unwrap_or(None)
|
||||
.map(Id::from)
|
||||
}
|
||||
|
||||
async fn keycloak_token(username: &str) -> String {
|
||||
let response = reqwest::Client::new()
|
||||
.post("http://localhost:9080/realms/stalwart/protocol/openid-connect/token")
|
||||
.form(&[
|
||||
("grant_type", "password"),
|
||||
("client_id", "stalwart"),
|
||||
("client_secret", "stalwart-secret"),
|
||||
("username", username),
|
||||
("password", KEYCLOAK_PASSWORD),
|
||||
("scope", "openid email profile"),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to request a Keycloak token");
|
||||
let body = response.text().await.expect("Failed to read the token");
|
||||
|
||||
serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|json| json["access_token"].as_str().map(str::to_string))
|
||||
.unwrap_or_else(|| panic!("No access_token in the Keycloak response: {body}"))
|
||||
}
|
||||
|
||||
async fn bearer_session_status(token: &str) -> u16 {
|
||||
crate::scim::jmap_session_status(&format!("Bearer {token}")).await
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
scim::{SCIM_DOMAIN, ScimClient, ScimTest, api_key, group_body, patch_body, query, user_body},
|
||||
utils::{account::Account, server::TestServer},
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::{Permission, StorageQuota},
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
self, Action, CertificateManagement, DkimManagement, DnsManagement, Domain,
|
||||
PasswordCredential, Permissions, PermissionsList, Tenant, UserAccount,
|
||||
},
|
||||
},
|
||||
types::{EnumImpl, list::List, map::Map},
|
||||
};
|
||||
use scim_proto::{MESSAGE_BULK_REQUEST, SCHEMA_USER};
|
||||
use serde_json::json;
|
||||
use types::id::Id;
|
||||
|
||||
const TENANT_DOMAIN: &str = "acme.example.com";
|
||||
const TENANT_PRINCIPAL: &str = "[email protected]";
|
||||
const TENANT_SECRET: &str = "these_pretzels_are_making_me_thirsty";
|
||||
|
||||
pub async fn test(test: &TestServer, scim: &ScimTest) {
|
||||
println!("Running SCIM tenant isolation tests...");
|
||||
|
||||
let fixture = TenantFixture::new(test).await;
|
||||
|
||||
a_tenant_client_only_sees_its_own_tenant(&fixture, scim).await;
|
||||
a_tenant_client_cannot_reach_another_tenant(&fixture, scim).await;
|
||||
a_tenant_client_cannot_provision_outside_its_domains(&fixture).await;
|
||||
a_tenant_client_provisions_inside_its_own_domain(&fixture).await;
|
||||
|
||||
fixture.cleanup(test).await;
|
||||
}
|
||||
|
||||
struct TenantFixture {
|
||||
client: ScimClient,
|
||||
tenant_id: Id,
|
||||
domain_id: Id,
|
||||
principal_id: Id,
|
||||
resident_id: Id,
|
||||
}
|
||||
|
||||
impl TenantFixture {
|
||||
async fn new(test: &TestServer) -> Self {
|
||||
let admin = test.account("admin");
|
||||
let tenant_id = admin
|
||||
.registry_create_object(Tenant {
|
||||
name: "acme".to_string(),
|
||||
permissions: Permissions::Merge(PermissionsList {
|
||||
disabled_permissions: Default::default(),
|
||||
enabled_permissions: Map::new(vec![
|
||||
Permission::ScimAccess,
|
||||
Permission::UnlimitedRequests,
|
||||
]),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
|
||||
let domain_id = admin
|
||||
.registry_create_object(Domain {
|
||||
is_enabled: true,
|
||||
name: TENANT_DOMAIN.to_string(),
|
||||
certificate_management: CertificateManagement::Manual,
|
||||
dns_management: DnsManagement::Manual,
|
||||
dkim_management: DkimManagement::Manual,
|
||||
member_tenant_id: Some(tenant_id),
|
||||
allow_scim_provisioning: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
|
||||
let principal_id = create_account(
|
||||
admin,
|
||||
"scim-svc",
|
||||
domain_id,
|
||||
tenant_id,
|
||||
"Tenant SCIM Service Principal",
|
||||
vec![
|
||||
Permission::ScimAccess,
|
||||
Permission::SysAccountGet,
|
||||
Permission::SysAccountCreate,
|
||||
Permission::SysAccountUpdate,
|
||||
Permission::SysAccountDestroy,
|
||||
Permission::UnlimitedRequests,
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let resident_id = create_account(
|
||||
admin,
|
||||
"resident",
|
||||
domain_id,
|
||||
tenant_id,
|
||||
"Tenant Resident",
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::Account,
|
||||
principal_id,
|
||||
json!({ Property::Quotas: { StorageQuota::MaxApiKeys.as_str(): 20 } }),
|
||||
)
|
||||
.await;
|
||||
admin.registry_create_object(Action::InvalidateCaches).await;
|
||||
|
||||
let mut principal = Account::new(TENANT_PRINCIPAL, TENANT_SECRET, &[], "", principal_id);
|
||||
principal.http_listener_port = crate::scim::HTTP_PORT;
|
||||
let token = api_key(admin, &principal, json!({ "@type": "Inherit" })).await;
|
||||
|
||||
TenantFixture {
|
||||
client: ScimClient::bearer(&token),
|
||||
tenant_id,
|
||||
domain_id,
|
||||
principal_id,
|
||||
resident_id,
|
||||
}
|
||||
}
|
||||
|
||||
async fn cleanup(&self, test: &TestServer) {
|
||||
let admin = test.account("admin");
|
||||
for id in [self.resident_id, self.principal_id] {
|
||||
admin.registry_destroy(ObjectType::Account, [id]).await;
|
||||
}
|
||||
admin
|
||||
.registry_destroy(ObjectType::Domain, [self.domain_id])
|
||||
.await;
|
||||
admin
|
||||
.registry_destroy(ObjectType::Tenant, [self.tenant_id])
|
||||
.await;
|
||||
admin.registry_create_object(Action::InvalidateCaches).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn a_tenant_client_only_sees_its_own_tenant(fixture: &TenantFixture, scim: &ScimTest) {
|
||||
let outsider = scim.create_user("[email protected]").await;
|
||||
|
||||
let listing = fixture.client.get("/Users?count=200").await;
|
||||
listing.assert_status(200);
|
||||
listing.assert_contains_id(&fixture.resident_id.to_string());
|
||||
listing.assert_contains_id(&fixture.principal_id.to_string());
|
||||
listing.assert_lacks_id(&outsider);
|
||||
assert_eq!(
|
||||
listing.total_results(),
|
||||
2,
|
||||
"A tenant client must see only its own accounts: {}",
|
||||
listing.body
|
||||
);
|
||||
|
||||
for filter in [
|
||||
"userName eq \"[email protected]\"",
|
||||
&format!("id eq \"{outsider}\""),
|
||||
"emails eq \"[email protected]\"",
|
||||
] {
|
||||
let response = fixture.client.get(&query("/Users", filter)).await;
|
||||
response.assert_status(200);
|
||||
assert_eq!(response.total_results(), 0, "{filter}: {}", response.body);
|
||||
}
|
||||
|
||||
let searched = fixture
|
||||
.client
|
||||
.post(
|
||||
"/.search",
|
||||
json!({
|
||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:SearchRequest"],
|
||||
"count": 200,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
searched.assert_status(200);
|
||||
searched.assert_lacks_id(&outsider);
|
||||
|
||||
scim.destroy(&format!("/Users/{outsider}")).await;
|
||||
}
|
||||
|
||||
async fn a_tenant_client_cannot_reach_another_tenant(fixture: &TenantFixture, scim: &ScimTest) {
|
||||
let outsider = scim.create_user("[email protected]").await;
|
||||
let outsider_group = scim.create_group("Untenanted Team").await;
|
||||
let path = format!("/Users/{outsider}");
|
||||
|
||||
fixture.client.get(&path).await.assert_error(404, None);
|
||||
fixture
|
||||
.client
|
||||
.patch(
|
||||
&path,
|
||||
patch_body(json!([{"op": "replace", "path": "displayName", "value": "Crossed"}])),
|
||||
)
|
||||
.await
|
||||
.assert_error(404, None);
|
||||
fixture
|
||||
.client
|
||||
.put(&path, user_body("[email protected]"))
|
||||
.await
|
||||
.assert_error(404, None);
|
||||
fixture.client.delete(&path).await.assert_error(404, None);
|
||||
|
||||
fixture
|
||||
.client
|
||||
.get(&format!("/Groups/{outsider_group}"))
|
||||
.await
|
||||
.assert_error(404, None);
|
||||
fixture
|
||||
.client
|
||||
.delete(&format!("/Groups/{outsider_group}"))
|
||||
.await
|
||||
.assert_error(404, None);
|
||||
|
||||
let bulk = fixture
|
||||
.client
|
||||
.post(
|
||||
"/Bulk",
|
||||
json!({
|
||||
"schemas": [MESSAGE_BULK_REQUEST],
|
||||
"Operations": [
|
||||
{"method": "DELETE", "path": path},
|
||||
{"method": "PATCH", "path": path, "data": patch_body(
|
||||
json!([{"op": "replace", "path": "active", "value": false}])
|
||||
)},
|
||||
],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
bulk.assert_status(200);
|
||||
for operation in bulk.json["Operations"].as_array().unwrap() {
|
||||
assert_eq!(operation["status"], json!("404"), "{operation}");
|
||||
}
|
||||
|
||||
let group = fixture
|
||||
.client
|
||||
.post("/Groups", group_body("Tenant Team"))
|
||||
.await
|
||||
.assert_status(201)
|
||||
.id();
|
||||
fixture
|
||||
.client
|
||||
.patch(
|
||||
&format!("/Groups/{group}"),
|
||||
patch_body(json!([{"op": "add", "path": "members", "value": [{"value": outsider}]}])),
|
||||
)
|
||||
.await
|
||||
.assert_error(400, Some("invalidValue"));
|
||||
fixture.client.delete(&format!("/Groups/{group}")).await;
|
||||
|
||||
let survivor = scim.client.get(&path).await;
|
||||
survivor.assert_status(200);
|
||||
assert!(
|
||||
survivor.json.get("displayName").is_none(),
|
||||
"The cross tenant patch was applied: {}",
|
||||
survivor.body
|
||||
);
|
||||
|
||||
scim.destroy(&format!("/Groups/{outsider_group}")).await;
|
||||
scim.destroy(&path).await;
|
||||
}
|
||||
|
||||
async fn a_tenant_client_cannot_provision_outside_its_domains(fixture: &TenantFixture) {
|
||||
let response = fixture
|
||||
.client
|
||||
.post("/Users", user_body(&format!("intruder@{SCIM_DOMAIN}")))
|
||||
.await;
|
||||
response.assert_error(404, None);
|
||||
response.assert_detail_contains(SCIM_DOMAIN);
|
||||
|
||||
let response = fixture
|
||||
.client
|
||||
.post(
|
||||
"/Users",
|
||||
json!({
|
||||
"schemas": [SCHEMA_USER],
|
||||
"userName": format!("aliased@{TENANT_DOMAIN}"),
|
||||
"emails": [{"value": format!("intruder@{SCIM_DOMAIN}")}],
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
response.assert_error(404, None);
|
||||
response.assert_detail_contains(SCIM_DOMAIN);
|
||||
}
|
||||
|
||||
async fn a_tenant_client_provisions_inside_its_own_domain(fixture: &TenantFixture) {
|
||||
let response = fixture
|
||||
.client
|
||||
.post("/Users", user_body(&format!("newcomer@{TENANT_DOMAIN}")))
|
||||
.await;
|
||||
response.assert_status(201);
|
||||
let id = response.id();
|
||||
|
||||
fixture
|
||||
.client
|
||||
.get(&format!("/Users/{id}"))
|
||||
.await
|
||||
.assert_status(200);
|
||||
|
||||
let group = fixture
|
||||
.client
|
||||
.post("/Groups", group_body("Acme Team"))
|
||||
.await
|
||||
.assert_status(201)
|
||||
.id();
|
||||
fixture
|
||||
.client
|
||||
.patch(
|
||||
&format!("/Groups/{group}"),
|
||||
patch_body(json!([{"op": "add", "path": "members", "value": [{"value": id}]}])),
|
||||
)
|
||||
.await
|
||||
.assert_status(200);
|
||||
fixture
|
||||
.client
|
||||
.patch(
|
||||
&format!("/Groups/{group}"),
|
||||
patch_body(json!([{"op": "remove", "path": "members"}])),
|
||||
)
|
||||
.await
|
||||
.assert_status(200);
|
||||
|
||||
fixture
|
||||
.client
|
||||
.delete(&format!("/Groups/{group}"))
|
||||
.await
|
||||
.assert_status(204);
|
||||
fixture
|
||||
.client
|
||||
.delete(&format!("/Users/{id}"))
|
||||
.await
|
||||
.assert_status(204);
|
||||
}
|
||||
|
||||
async fn create_account(
|
||||
admin: &Account,
|
||||
name: &str,
|
||||
domain_id: Id,
|
||||
tenant_id: Id,
|
||||
description: &str,
|
||||
permissions: Vec<Permission>,
|
||||
) -> Id {
|
||||
admin
|
||||
.registry_create_object(structs::Account::User(UserAccount {
|
||||
name: name.to_string(),
|
||||
domain_id,
|
||||
member_tenant_id: Some(tenant_id),
|
||||
description: Some(description.to_string()),
|
||||
credentials: List::from_iter([structs::Credential::Password(PasswordCredential {
|
||||
secret: TENANT_SECRET.to_string(),
|
||||
..Default::default()
|
||||
})]),
|
||||
permissions: Permissions::Merge(PermissionsList {
|
||||
disabled_permissions: Default::default(),
|
||||
enabled_permissions: Map::new(permissions),
|
||||
}),
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
}
|
||||
Reference in New Issue
Block a user