Added support for multi tenant authentication (#746)

## Summary of the Pull Request

_What is this about?_

## PR Checklist
* [x] Applies to work item: #562 
* [x] CLA signed. If not, go over [here](https://cla.opensource.microsoft.com/microsoft/onefuzz) and sign the CLI.
* [x] Tests added/passed
* [ ] Requires documentation to be updated
* [x] I've discussed this with core contributors already. If not checked, I'm ready to accept this work might be rejected in favor of a different grand plan. Issue number where discussion took place: #xxx

## Info on Pull Request

The end-to-end changes needed to have onefuzz deployed with multi-tenant authentication.

## Validation Steps Performed

_How does someone test & validate?_
This commit is contained in:
Gurpreet Singh
2021-04-02 07:39:20 -07:00
committed by GitHub
parent 624a7f77e8
commit 7e5cf780a6
8 changed files with 238 additions and 16 deletions

View File

@ -25,6 +25,8 @@ pub struct StaticConfig {
pub onefuzz_url: Url, pub onefuzz_url: Url,
pub multi_tenant_domain: Option<String>,
// TODO: remove the alias once the service has been updated to match // TODO: remove the alias once the service has been updated to match
#[serde(alias = "instrumentation_key")] #[serde(alias = "instrumentation_key")]
pub instance_telemetry_key: Option<InstanceTelemetryKey>, pub instance_telemetry_key: Option<InstanceTelemetryKey>,
@ -47,6 +49,8 @@ struct RawStaticConfig {
pub onefuzz_url: Url, pub onefuzz_url: Url,
pub multi_tenant_domain: Option<String>,
// TODO: remove the alias once the service has been updated to match // TODO: remove the alias once the service has been updated to match
#[serde(alias = "instrumentation_key")] #[serde(alias = "instrumentation_key")]
pub instance_telemetry_key: Option<InstanceTelemetryKey>, pub instance_telemetry_key: Option<InstanceTelemetryKey>,
@ -73,7 +77,8 @@ impl StaticConfig {
.to_string() .to_string()
.trim_end_matches('/') .trim_end_matches('/')
.to_owned(); .to_owned();
let managed = ManagedIdentityCredentials::new(resource); let managed =
ManagedIdentityCredentials::new(resource, config.multi_tenant_domain.clone())?;
managed.into() managed.into()
} }
}; };
@ -81,6 +86,7 @@ impl StaticConfig {
credentials, credentials,
pool_name: config.pool_name, pool_name: config.pool_name,
onefuzz_url: config.onefuzz_url, onefuzz_url: config.onefuzz_url,
multi_tenant_domain: config.multi_tenant_domain,
microsoft_telemetry_key: config.microsoft_telemetry_key, microsoft_telemetry_key: config.microsoft_telemetry_key,
instance_telemetry_key: config.instance_telemetry_key, instance_telemetry_key: config.instance_telemetry_key,
heartbeat_queue: config.heartbeat_queue, heartbeat_queue: config.heartbeat_queue,
@ -102,6 +108,7 @@ impl StaticConfig {
let client_id = Uuid::parse_str(&std::env::var("ONEFUZZ_CLIENT_ID")?)?; let client_id = Uuid::parse_str(&std::env::var("ONEFUZZ_CLIENT_ID")?)?;
let client_secret = std::env::var("ONEFUZZ_CLIENT_SECRET")?; let client_secret = std::env::var("ONEFUZZ_CLIENT_SECRET")?;
let tenant = std::env::var("ONEFUZZ_TENANT")?; let tenant = std::env::var("ONEFUZZ_TENANT")?;
let multi_tenant_domain = std::env::var("ONEFUZZ_MULTI_TENANT_DOMAIN").ok();
let onefuzz_url = Url::parse(&std::env::var("ONEFUZZ_URL")?)?; let onefuzz_url = Url::parse(&std::env::var("ONEFUZZ_URL")?)?;
let pool_name = std::env::var("ONEFUZZ_POOL")?; let pool_name = std::env::var("ONEFUZZ_POOL")?;
@ -125,15 +132,21 @@ impl StaticConfig {
None None
}; };
let credentials = let credentials = ClientCredentials::new(
ClientCredentials::new(client_id, client_secret, onefuzz_url.to_string(), tenant) client_id,
.into(); client_secret,
onefuzz_url.to_string(),
tenant,
multi_tenant_domain.clone(),
)
.into();
Ok(Self { Ok(Self {
instance_id, instance_id,
credentials, credentials,
pool_name, pool_name,
onefuzz_url, onefuzz_url,
multi_tenant_domain,
instance_telemetry_key, instance_telemetry_key,
microsoft_telemetry_key, microsoft_telemetry_key,
heartbeat_queue, heartbeat_queue,

View File

@ -88,10 +88,17 @@ pub struct ClientCredentials {
client_secret: Secret<String>, client_secret: Secret<String>,
resource: String, resource: String,
tenant: String, tenant: String,
multi_tenant_domain: Option<String>,
} }
impl ClientCredentials { impl ClientCredentials {
pub fn new(client_id: Uuid, client_secret: String, resource: String, tenant: String) -> Self { pub fn new(
client_id: Uuid,
client_secret: String,
resource: String,
tenant: String,
multi_tenant_domain: Option<String>,
) -> Self {
let client_secret = client_secret.into(); let client_secret = client_secret.into();
Self { Self {
@ -99,14 +106,30 @@ impl ClientCredentials {
client_secret, client_secret,
resource, resource,
tenant, tenant,
multi_tenant_domain,
} }
} }
pub async fn access_token(&self) -> Result<AccessToken> { pub async fn access_token(&self) -> Result<AccessToken> {
let (authority, resource) = if let Some(domain) = &self.multi_tenant_domain {
let url = Url::parse(&self.resource.clone())?;
let host = url.host_str().ok_or_else(|| {
anyhow::format_err!("resource URL does not have a host string: {}", url)
})?;
let instance: Vec<&str> = host.split('.').collect();
(
String::from("common"),
format!("https://{}/{}/", &domain, instance[0]),
)
} else {
(self.tenant.clone(), self.resource.clone())
};
let mut url = Url::parse("https://login.microsoftonline.com")?; let mut url = Url::parse("https://login.microsoftonline.com")?;
url.path_segments_mut() url.path_segments_mut()
.expect("Authority URL is cannot-be-a-base") .expect("Authority URL is cannot-be-a-base")
.extend(&[&self.tenant, "oauth2", "v2.0", "token"]); .extend(&[&authority.clone(), "oauth2", "v2.0", "token"]);
let response = reqwest::Client::new() let response = reqwest::Client::new()
.post(url) .post(url)
@ -115,8 +138,8 @@ impl ClientCredentials {
("client_id", self.client_id.to_hyphenated().to_string()), ("client_id", self.client_id.to_hyphenated().to_string()),
("client_secret", self.client_secret.expose_ref().to_string()), ("client_secret", self.client_secret.expose_ref().to_string()),
("grant_type", "client_credentials".into()), ("grant_type", "client_credentials".into()),
("tenant", self.tenant.clone()), ("tenant", authority),
("scope", format!("{}.default", self.resource)), ("scope", format!("{}.default", resource)),
]) ])
.send_retry_default() .send_retry_default()
.await? .await?
@ -147,18 +170,34 @@ impl From<ClientAccessTokenBody> for AccessToken {
#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] #[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct ManagedIdentityCredentials { pub struct ManagedIdentityCredentials {
resource: String, resource: String,
multi_tenant_domain: Option<String>,
} }
const MANAGED_IDENTITY_URL: &str = const MANAGED_IDENTITY_URL: &str =
"http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01"; "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01";
impl ManagedIdentityCredentials { impl ManagedIdentityCredentials {
pub fn new(resource: String) -> Self { pub fn new(resource: String, multi_tenant_domain: Option<String>) -> Result<Self> {
Self { resource } let resource = if let Some(domain) = multi_tenant_domain.clone() {
let resource_url = Url::parse(&resource)?;
let host = resource_url.host_str().ok_or_else(|| {
anyhow::format_err!("resource URL does not have a host string: {}", resource_url)
})?;
let instance: Vec<&str> = host.split('.').collect();
format!("https://{}/{}", domain, instance[0])
} else {
resource
};
Ok(Self {
resource,
multi_tenant_domain,
})
} }
fn url(&self) -> Url { fn url(&self) -> Url {
let mut url = Url::parse(MANAGED_IDENTITY_URL).unwrap(); let mut url = Url::parse(MANAGED_IDENTITY_URL).unwrap();
url.query_pairs_mut() url.query_pairs_mut()
.append_pair("resource", &self.resource); .append_pair("resource", &self.resource);
url url
@ -198,3 +237,51 @@ impl From<ManagedIdentityAccessTokenBody> for AccessToken {
AccessToken { secret } AccessToken { secret }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
#[test]
fn test_managed_creds_with_valid_single_tenant() -> Result<()> {
let resource = "https://host-26.azurewebsites.net";
let managed_creds = ManagedIdentityCredentials::new(resource.to_string(), None)?;
assert_eq!(managed_creds.resource, resource);
Ok(())
}
#[test]
fn test_managed_creds_with_valid_multi_tenant_domain() -> Result<()> {
let resource = "https://host-26.azurewebsites.net";
let multi_tenant_domain = "mycloud.contoso.com";
let managed_creds = ManagedIdentityCredentials::new(
resource.to_string(),
Some(multi_tenant_domain.to_string()),
)?;
let expected = "https://mycloud.contoso.com/host-26";
assert_eq!(managed_creds.resource, expected);
Ok(())
}
#[test]
fn test_managed_creds_with_invalid_single_tenant() -> Result<()> {
let resource = "invalid-url";
let multi_tenant_domain = "mycloud.contoso.com";
let managed_creds = ManagedIdentityCredentials::new(
resource.to_string(),
Some(multi_tenant_domain.to_string()),
);
assert!(managed_creds.is_err());
Ok(())
}
}

View File

@ -113,6 +113,10 @@ def build_pool_config(pool: Pool) -> str:
instance_id=get_instance_id(), instance_id=get_instance_id(),
) )
multi_tenant_domain = os.environ.get("MULTI_TENANT_DOMAIN")
if multi_tenant_domain:
config.multi_tenant_domain = multi_tenant_domain
filename = f"{pool.name}/config.json" filename = f"{pool.name}/config.json"
save_blob( save_blob(

View File

@ -40,6 +40,11 @@ def set_config(pool: Pool) -> Pool:
), ),
instance_id=get_instance_id(), instance_id=get_instance_id(),
) )
multi_tenant_domain = os.environ.get("MULTI_TENANT_DOMAIN")
if multi_tenant_domain:
pool.config.multi_tenant_domain = multi_tenant_domain
return pool return pool

View File

@ -17,6 +17,15 @@
"signedExpiry": { "signedExpiry": {
"type": "string" "type": "string"
}, },
"app_func_issuer": {
"type": "string"
},
"app_func_audience": {
"type": "string"
},
"multi_tenant_domain": {
"type": "string"
},
"diagnosticsLogsLevel": { "diagnosticsLogsLevel": {
"type": "string", "type": "string",
"defaultValue": "Verbose", "defaultValue": "Verbose",
@ -202,6 +211,10 @@
"name": "AzureWebJobsStorage", "name": "AzureWebJobsStorage",
"value": "[concat('DefaultEndpointsProtocol=https;AccountName=',variables('storageAccountNameFunc'),';AccountKey=',listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountNameFunc')), '2019-06-01').keys[0].value,';EndpointSuffix=','core.windows.net')]" "value": "[concat('DefaultEndpointsProtocol=https;AccountName=',variables('storageAccountNameFunc'),';AccountKey=',listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountNameFunc')), '2019-06-01').keys[0].value,';EndpointSuffix=','core.windows.net')]"
}, },
{
"name": "MULTI_TENANT_DOMAIN",
"value": "[parameters('multi_tenant_domain')]"
},
{ {
"name": "AzureWebJobsDisableHomepage", "name": "AzureWebJobsDisableHomepage",
"value": "true" "value": "true"
@ -262,7 +275,7 @@
"tokenStoreEnabled": true, "tokenStoreEnabled": true,
"clientId": "[parameters('clientId')]", "clientId": "[parameters('clientId')]",
"clientSecret": "[parameters('clientSecret')]", "clientSecret": "[parameters('clientSecret')]",
"issuer": "[concat('https://sts.windows.net/', subscription().tenantId, '/')]", "issuer": "[parameters('app_func_issuer')]",
"defaultProvider": "AzureActiveDirectory", "defaultProvider": "AzureActiveDirectory",
"allowedAudiences": [ "allowedAudiences": [
"[concat('https://', parameters('name'), '.azurewebsites.net')]" "[concat('https://', parameters('name'), '.azurewebsites.net')]"

View File

@ -68,6 +68,7 @@ from registration import (
assign_scaleset_role, assign_scaleset_role,
authorize_application, authorize_application,
register_application, register_application,
set_app_audience,
update_pool_registration, update_pool_registration,
) )
@ -76,6 +77,7 @@ ONEFUZZ_CLI_APP = "72f1562a-8c0c-41ea-beb9-fa2b71c80134"
ONEFUZZ_CLI_AUTHORITY = ( ONEFUZZ_CLI_AUTHORITY = (
"https://login.microsoftonline.com/72f988bf-86f1-41af-91ab-2d7cd011db47" "https://login.microsoftonline.com/72f988bf-86f1-41af-91ab-2d7cd011db47"
) )
COMMON_AUTHORITY = "https://login.microsoftonline.com/common"
TELEMETRY_NOTICE = ( TELEMETRY_NOTICE = (
"Telemetry collection on stats and OneFuzz failures are sent to Microsoft. " "Telemetry collection on stats and OneFuzz failures are sent to Microsoft. "
"To disable, delete the ONEFUZZ_TELEMETRY application setting in the " "To disable, delete the ONEFUZZ_TELEMETRY application setting in the "
@ -118,6 +120,7 @@ class Client:
migrations: List[str], migrations: List[str],
export_appinsights: bool, export_appinsights: bool,
log_service_principal: bool, log_service_principal: bool,
multi_tenant_domain: str,
upgrade: bool, upgrade: bool,
): ):
self.resource_group = resource_group self.resource_group = resource_group
@ -130,14 +133,19 @@ class Client:
self.instance_specific = instance_specific self.instance_specific = instance_specific
self.third_party = third_party self.third_party = third_party
self.create_registration = create_registration self.create_registration = create_registration
self.multi_tenant_domain = multi_tenant_domain
self.upgrade = upgrade self.upgrade = upgrade
self.results: Dict = { self.results: Dict = {
"client_id": client_id, "client_id": client_id,
"client_secret": client_secret, "client_secret": client_secret,
} }
if self.multi_tenant_domain:
authority = COMMON_AUTHORITY
else:
authority = ONEFUZZ_CLI_AUTHORITY
self.cli_config: Dict[str, Union[str, UUID]] = { self.cli_config: Dict[str, Union[str, UUID]] = {
"client_id": ONEFUZZ_CLI_APP, "client_id": ONEFUZZ_CLI_APP,
"authority": ONEFUZZ_CLI_AUTHORITY, "authority": authority,
} }
self.migrations = migrations self.migrations = migrations
self.export_appinsights = export_appinsights self.export_appinsights = export_appinsights
@ -230,7 +238,6 @@ class Client:
def setup_rbac(self) -> None: def setup_rbac(self) -> None:
""" """
Setup the client application for the OneFuzz instance. Setup the client application for the OneFuzz instance.
By default, Service Principals do not have access to create By default, Service Principals do not have access to create
client applications in AAD. client applications in AAD.
""" """
@ -274,7 +281,14 @@ class Client:
if not existing: if not existing:
logger.info("creating Application registration") logger.info("creating Application registration")
url = "https://%s.azurewebsites.net" % self.application_name
if self.multi_tenant_domain:
url = "https://%s/%s" % (
self.multi_tenant_domain,
self.application_name,
)
else:
url = "https://%s.azurewebsites.net" % self.application_name
params = ApplicationCreateParameters( params = ApplicationCreateParameters(
display_name=self.application_name, display_name=self.application_name,
@ -291,6 +305,7 @@ class Client:
], ],
app_roles=app_roles, app_roles=app_roles,
) )
app = client.applications.create(params) app = client.applications.create(params)
logger.info("creating service principal") logger.info("creating service principal")
@ -349,6 +364,27 @@ class Client:
app.object_id, ApplicationUpdateParameters(app_roles=app_roles) app.object_id, ApplicationUpdateParameters(app_roles=app_roles)
) )
if self.multi_tenant_domain and app.sign_in_audience == "AzureADMyOrg":
url = "https://%s/%s" % (
self.multi_tenant_domain,
self.application_name,
)
client.applications.patch(
app.object_id, ApplicationUpdateParameters(identifier_uris=[url])
)
set_app_audience(app.object_id, "AzureADMultipleOrgs")
elif (
not self.multi_tenant_domain
and app.sign_in_audience == "AzureADMultipleOrgs"
):
set_app_audience(app.object_id, "AzureADMyOrg")
url = "https://%s.azurewebsites.net" % self.application_name
client.applications.patch(
app.object_id, ApplicationUpdateParameters(identifier_uris=[url])
)
else:
logger.debug("No change to App Registration signInAudence setting")
creds = list(client.applications.list_password_credentials(app.object_id)) creds = list(client.applications.list_password_credentials(app.object_id))
client.applications.update_password_credentials(app.object_id, creds) client.applications.update_password_credentials(app.object_id, creds)
@ -366,9 +402,13 @@ class Client:
app_info = register_application( app_info = register_application(
"onefuzz-cli", self.application_name, OnefuzzAppRole.CliClient "onefuzz-cli", self.application_name, OnefuzzAppRole.CliClient
) )
if self.multi_tenant_domain:
authority = COMMON_AUTHORITY
else:
authority = app_info.authority
self.cli_config = { self.cli_config = {
"client_id": app_info.client_id, "client_id": app_info.client_id,
"authority": app_info.authority, "authority": authority,
} }
else: else:
@ -397,12 +437,31 @@ class Client:
expiry = (datetime.now(TZ_UTC) + timedelta(days=365)).strftime( expiry = (datetime.now(TZ_UTC) + timedelta(days=365)).strftime(
"%Y-%m-%dT%H:%M:%SZ" "%Y-%m-%dT%H:%M:%SZ"
) )
if self.multi_tenant_domain:
# clear the value in the Issuer Url field:
# https://docs.microsoft.com/en-us/sharepoint/dev/spfx/use-aadhttpclient-enterpriseapi-multitenant
app_func_audience = "https://%s/%s" % (
self.multi_tenant_domain,
self.application_name,
)
app_func_issuer = ""
multi_tenant_domain = {"value": self.multi_tenant_domain}
else:
app_func_audience = "https://%s.azurewebsites.net" % self.application_name
tenant_oid = str(self.cli_config["authority"]).split("/")[-1]
app_func_issuer = "https://sts.windows.net/%s/" % tenant_oid
multi_tenant_domain = {"value": ""}
params = { params = {
"app_func_audience": {"value": app_func_audience},
"name": {"value": self.application_name}, "name": {"value": self.application_name},
"owner": {"value": self.owner}, "owner": {"value": self.owner},
"clientId": {"value": self.results["client_id"]}, "clientId": {"value": self.results["client_id"]},
"clientSecret": {"value": self.results["client_secret"]}, "clientSecret": {"value": self.results["client_secret"]},
"app_func_issuer": {"value": app_func_issuer},
"signedExpiry": {"value": expiry}, "signedExpiry": {"value": expiry},
"multi_tenant_domain": multi_tenant_domain,
"workbookData": {"value": self.workbook_data}, "workbookData": {"value": self.workbook_data},
} }
deployment = Deployment( deployment = Deployment(
@ -779,13 +838,17 @@ class Client:
if "client_secret" in self.cli_config if "client_secret" in self.cli_config
else "" else ""
) )
multi_tenant_domain = ""
if self.multi_tenant_domain:
multi_tenant_domain = "--tenant_domain %s" % self.multi_tenant_domain
logger.info( logger.info(
"Update your CLI config via: onefuzz config --endpoint " "Update your CLI config via: onefuzz config --endpoint "
"https://%s.azurewebsites.net --authority %s --client_id %s %s", "https://%s.azurewebsites.net --authority %s --client_id %s %s %s",
self.application_name, self.application_name,
self.cli_config["authority"], self.cli_config["authority"],
self.cli_config["client_id"], self.cli_config["client_id"],
client_secret_arg, client_secret_arg,
multi_tenant_domain,
) )
@ -898,6 +961,12 @@ def main() -> None:
action="store_true", action="store_true",
help="display service prinipal with info log level", help="display service prinipal with info log level",
) )
parser.add_argument(
"--multi_tenant_domain",
type=str,
default=None,
help="enable multi-tenant authentication with this tenant domain",
)
args = parser.parse_args() args = parser.parse_args()
if shutil.which("func") is None: if shutil.which("func") is None:
@ -921,6 +990,7 @@ def main() -> None:
migrations=args.apply_migrations, migrations=args.apply_migrations,
export_appinsights=args.export_appinsights, export_appinsights=args.export_appinsights,
log_service_principal=args.log_service_principal, log_service_principal=args.log_service_principal,
multi_tenant_domain=args.multi_tenant_domain,
upgrade=args.upgrade, upgrade=args.upgrade,
) )
if args.verbose: if args.verbose:

View File

@ -516,6 +516,35 @@ def assign_scaleset_role(onefuzz_instance_name: str, scaleset_name: str) -> None
assign_scaleset_role_manually(onefuzz_instance_name, scaleset_name) assign_scaleset_role_manually(onefuzz_instance_name, scaleset_name)
def set_app_audience(objectId: str, audience: str) -> None:
# typical audience values: AzureADMyOrg, AzureADMultipleOrgs
http_body = {"signInAudience": audience}
try:
query_microsoft_graph(
method="PATCH",
resource="applications/%s" % objectId,
body=http_body,
)
except GraphQueryError:
query = (
"az rest --method patch --url "
"https://graph.microsoft.com/v1.0/applications/%s "
"--body '%s' --headers \"Content-Type\"=application/json"
% (objectId, http_body)
)
logger.warning(
"execute the following query in the azure portal bash shell and run deploy.py again : \n%s"
% query
)
err_str = (
"Unable to set signInAudience using Microsoft Graph Query API. \n"
+ "The user must enable single/multi tenancy in the 'Authentication' blade of the "
+ "Application Registration in the AAD web portal, or use the azure bash shell "
+ "using the command given above."
)
raise Exception(err_str)
def main() -> None: def main() -> None:
formatter = argparse.ArgumentDefaultsHelpFormatter formatter = argparse.ArgumentDefaultsHelpFormatter

View File

@ -364,6 +364,7 @@ class AgentConfig(BaseModel):
heartbeat_queue: Optional[str] heartbeat_queue: Optional[str]
instrumentation_key: Optional[str] instrumentation_key: Optional[str]
telemetry_key: Optional[str] telemetry_key: Optional[str]
multi_tenant_domain: Optional[str]
instance_id: UUID instance_id: UUID