migrate to msgraph (#966)

* migrate to msgraph

* add subscription id to query_microsoft_graph

* migrating remaingin references

* formatting

* adding missing dependencies

* flake fix

* fix get_tenant_id

* cleanup

* formatting

* migrate application creation in deploy.py

* foramt

* mypy fix

* isort

* isort

* format

* bug fixes

* specify the correct signInAudience

* fix backing service principal creation
fix preauthorized application

* remove remaining references to graphrbac

* fix ms graph authentication

* formatting

* fix typo

* format

* deployment fix

* set implicitGrantSettings in the deployment

* format

* fix deployment

* fix graph authentication on the server

* use the current cli logged in account to retrive the backend token cache

* assign the the msgraph app role permissions to the web app during the deployment

* formatting

* fix build

* build fix

* fix bandit issue

* mypy fix

* isort

* deploy fixes

* formatting

* remove assign_app_permissions

* mypy fix

* build fix

* mypy fix

* format

* formatting

* flake fix

* remove webapp identity permission assignment

* remove unused reference to assign_app_role

* remove manual registration message

* fixing name and logging

* address PR coments

* address PR comments

* build fix

* lint

* lint

* mypy fix

* mypy fix

* formatting

* address PR comments

* linting

* lint

* remove ONEFUZZ_AAD_GROUP_ID check

* regenerate webhook_events.md

* change return type of query_microsoft_graph_list

* fix tenant_id

Co-authored-by: Marc Greisen <marc@greisen.org>
Co-authored-by: Stas <stishkin@live.com>
This commit is contained in:
Cheick Keita
2021-10-22 11:59:05 -07:00
committed by GitHub
parent c97395a37f
commit 98cd7c9c56
9 changed files with 586 additions and 474 deletions

View File

@ -1004,7 +1004,8 @@ Each event will be submitted via HTTP POST to the user provided URL.
469, 469,
470, 470,
471, 471,
472 472,
473
], ],
"title": "ErrorCode" "title": "ErrorCode"
}, },
@ -1615,7 +1616,8 @@ Each event will be submitted via HTTP POST to the user provided URL.
469, 469,
470, 470,
471, 471,
472 472,
473
], ],
"title": "ErrorCode" "title": "ErrorCode"
} }
@ -2501,7 +2503,8 @@ Each event will be submitted via HTTP POST to the user provided URL.
469, 469,
470, 470,
471, 471,
472 472,
473
], ],
"title": "ErrorCode" "title": "ErrorCode"
} }
@ -3190,7 +3193,8 @@ Each event will be submitted via HTTP POST to the user provided URL.
469, 469,
470, 470,
471, 471,
472 472,
473
], ],
"title": "ErrorCode" "title": "ErrorCode"
}, },
@ -5123,7 +5127,8 @@ Each event will be submitted via HTTP POST to the user provided URL.
469, 469,
470, 470,
471, 471,
472 472,
473
], ],
"title": "ErrorCode" "title": "ErrorCode"
}, },

View File

@ -6,12 +6,12 @@
import functools import functools
import logging import logging
import os import os
from typing import Any, Callable, List, TypeVar, cast import urllib.parse
from typing import Any, Callable, Dict, List, Optional, TypeVar, cast
from uuid import UUID from uuid import UUID
import requests
from azure.core.exceptions import ClientAuthenticationError from azure.core.exceptions import ClientAuthenticationError
from azure.graphrbac import GraphRbacManagementClient
from azure.graphrbac.models import CheckGroupMembershipParameters
from azure.identity import DefaultAzureCredential from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient from azure.keyvault.secrets import SecretClient
from azure.mgmt.resource import ResourceManagementClient from azure.mgmt.resource import ResourceManagementClient
@ -23,6 +23,10 @@ from onefuzztypes.primitives import Container, Region
from .monkeypatch import allow_more_workers, reduce_logging from .monkeypatch import allow_more_workers, reduce_logging
# https://docs.microsoft.com/en-us/graph/api/overview?view=graph-rest-1.0
GRAPH_RESOURCE = "https://graph.microsoft.com"
GRAPH_RESOURCE_ENDPOINT = "https://graph.microsoft.com/v1.0"
@cached @cached
def get_msi() -> MSIAuthentication: def get_msi() -> MSIAuthentication:
@ -99,18 +103,77 @@ def get_regions() -> List[Region]:
return sorted([Region(x.name) for x in locations]) return sorted([Region(x.name) for x in locations])
@cached class GraphQueryError(Exception):
def get_graph_client() -> GraphRbacManagementClient: def __init__(self, message: str, status_code: Optional[int]) -> None:
return GraphRbacManagementClient(get_msi(), get_subscription()) super(GraphQueryError, self).__init__(message)
self.message = message
self.status_code = status_code
def query_microsoft_graph(
method: str,
resource: str,
params: Optional[Dict] = None,
body: Optional[Dict] = None,
) -> Dict:
cred = get_identity()
access_token = cred.get_token(f"{GRAPH_RESOURCE}/.default")
url = urllib.parse.urljoin(f"{GRAPH_RESOURCE_ENDPOINT}/", resource)
headers = {
"Authorization": "Bearer %s" % access_token.token,
"Content-Type": "application/json",
}
response = requests.request(
method=method, url=url, headers=headers, params=params, json=body
)
if 200 <= response.status_code < 300:
if response.content and response.content.strip():
json = response.json()
if isinstance(json, Dict):
return json
else:
raise GraphQueryError(
"invalid data expected a json object: HTTP"
f" {response.status_code} - {json}",
response.status_code,
)
else:
return {}
else:
error_text = str(response.content, encoding="utf-8", errors="backslashreplace")
raise GraphQueryError(
f"request did not succeed: HTTP {response.status_code} - {error_text}",
response.status_code,
)
def query_microsoft_graph_list(
method: str,
resource: str,
params: Optional[Dict] = None,
body: Optional[Dict] = None,
) -> List[Any]:
result = query_microsoft_graph(
method,
resource,
params,
body,
)
value = result.get("value")
if isinstance(value, list):
return value
else:
raise GraphQueryError("Expected data containing a list of values", None)
def is_member_of(group_id: str, member_id: str) -> bool: def is_member_of(group_id: str, member_id: str) -> bool:
client = get_graph_client() body = {"groupIds": [group_id]}
return bool( response = query_microsoft_graph_list(
client.groups.is_member_of( method="POST", resource=f"users/{member_id}/checkMemberGroups", body=body
CheckGroupMembershipParameters(group_id=group_id, member_id=member_id)
).value
) )
return group_id in response
@cached @cached

View File

@ -10,13 +10,11 @@ from typing import TYPE_CHECKING, Optional, Sequence, Type, TypeVar, Union
from uuid import UUID from uuid import UUID
from azure.functions import HttpRequest, HttpResponse from azure.functions import HttpRequest, HttpResponse
from azure.graphrbac.models import GraphErrorException
from onefuzztypes.enums import ErrorCode from onefuzztypes.enums import ErrorCode
from onefuzztypes.models import Error from onefuzztypes.models import Error
from onefuzztypes.responses import BaseResponse from onefuzztypes.responses import BaseResponse
from pydantic import ValidationError from pydantic import ValidationError
from .azure.creds import is_member_of
from .orm import ModelMixin from .orm import ModelMixin
# We don't actually use these types at runtime at this time. Rather, # We don't actually use these types at runtime at this time. Rather,
@ -28,26 +26,16 @@ if TYPE_CHECKING:
def check_access(req: HttpRequest) -> Optional[Error]: def check_access(req: HttpRequest) -> Optional[Error]:
if "ONEFUZZ_AAD_GROUP_ID" not in os.environ: if "ONEFUZZ_AAD_GROUP_ID" in os.environ:
message = "ONEFUZZ_AAD_GROUP_ID configuration not supported"
logging.error(message)
return Error(
code=ErrorCode.INVALID_CONFIGURATION,
errors=[message],
)
else:
return None return None
group_id = os.environ["ONEFUZZ_AAD_GROUP_ID"]
member_id = req.headers["x-ms-client-principal-id"]
try:
result = is_member_of(group_id, member_id)
except GraphErrorException:
return Error(
code=ErrorCode.UNAUTHORIZED, errors=["unable to interact with graph"]
)
if not result:
logging.error("unauthorized access: %s is not in %s", member_id, group_id)
return Error(
code=ErrorCode.UNAUTHORIZED,
errors=["not approved to use this instance of onefuzz"],
)
return None
def ok( def ok(
data: Union[BaseResponse, Sequence[BaseResponse], ModelMixin, Sequence[ModelMixin]] data: Union[BaseResponse, Sequence[BaseResponse], ModelMixin, Sequence[ModelMixin]]

View File

@ -4,7 +4,6 @@ azure-cosmosdb-nspkg==2.0.2
azure-cosmosdb-table==1.0.6 azure-cosmosdb-table==1.0.6
azure-devops==6.0.0b4 azure-devops==6.0.0b4
azure-functions==1.7.2 azure-functions==1.7.2
azure-graphrbac~=0.61.1
azure-identity==1.6.1 azure-identity==1.6.1
azure-keyvault-secrets~=4.3.0 azure-keyvault-secrets~=4.3.0
azure-mgmt-compute==22.0 azure-mgmt-compute==22.0

View File

@ -23,4 +23,4 @@ cryptography<3.4,>=3.2
# PyJWT needs to be pinned to the version used by azure-cli-core # PyJWT needs to be pinned to the version used by azure-cli-core
PyJWT>=2.1.0 PyJWT>=2.1.0
# onefuzztypes version is set during build # onefuzztypes version is set during build
onefuzztypes==0.0.0 onefuzztypes==0.0.0

View File

@ -899,6 +899,6 @@
"tenant_id": { "tenant_id": {
"type": "string", "type": "string",
"value": "[subscription().tenantId]" "value": "[subscription().tenantId]"
} }
} }
} }

View File

@ -22,18 +22,6 @@ from uuid import UUID
from azure.common.client_factory import get_client_from_cli_profile from azure.common.client_factory import get_client_from_cli_profile
from azure.common.credentials import get_cli_profile from azure.common.credentials import get_cli_profile
from azure.cosmosdb.table.tableservice import TableService from azure.cosmosdb.table.tableservice import TableService
from azure.graphrbac import GraphRbacManagementClient
from azure.graphrbac.models import (
Application,
ApplicationCreateParameters,
ApplicationUpdateParameters,
AppRole,
GraphErrorException,
OptionalClaims,
RequiredResourceAccess,
ResourceAccess,
ServicePrincipalCreateParameters,
)
from azure.mgmt.applicationinsights import ApplicationInsightsManagementClient from azure.mgmt.applicationinsights import ApplicationInsightsManagementClient
from azure.mgmt.applicationinsights.models import ( from azure.mgmt.applicationinsights.models import (
ApplicationInsightsComponentExportRequest, ApplicationInsightsComponentExportRequest,
@ -61,11 +49,14 @@ from msrest.serialization import TZ_UTC
from data_migration import migrate from data_migration import migrate
from registration import ( from registration import (
GraphQueryError,
OnefuzzAppRole, OnefuzzAppRole,
add_application_password, add_application_password,
assign_app_role, assign_instance_app_role,
authorize_application, authorize_application,
get_graph_client, get_application,
get_tenant_id,
query_microsoft_graph,
register_application, register_application,
set_app_audience, set_app_audience,
update_pool_registration, update_pool_registration,
@ -249,7 +240,9 @@ class Client:
sys.exit(1) sys.exit(1)
def create_password(self, object_id: UUID) -> Tuple[str, str]: def create_password(self, object_id: UUID) -> Tuple[str, str]:
return add_application_password(object_id, self.get_subscription_id()) return add_application_password(
"cli_password", object_id, self.get_subscription_id()
)
def get_instance_url(self) -> str: def get_instance_url(self) -> str:
# The url to access the instance # The url to access the instance
@ -277,7 +270,14 @@ class Client:
else: else:
return "api://%s.azurewebsites.net" % self.application_name return "api://%s.azurewebsites.net" % self.application_name
def setup_rbac(self) -> None: # noqa: C901 def get_signin_audience(self) -> str:
# https://docs.microsoft.com/en-us/azure/active-directory/develop/supported-accounts-validation
if self.multi_tenant_domain:
return "AzureADMultipleOrgs"
else:
return "AzureADMyOrg"
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
@ -287,78 +287,99 @@ class Client:
logger.info("using existing client application") logger.info("using existing client application")
return return
client = get_client_from_cli_profile( app = get_application(
GraphRbacManagementClient, subscription_id=self.get_subscription_id() display_name=self.application_name,
subscription_id=self.get_subscription_id(),
) )
logger.info("checking if RBAC already exists")
try:
existing = list(
client.applications.list(
filter="displayName eq '%s'" % self.application_name
)
)
except GraphErrorException:
logger.error("unable to query RBAC. Provide client_id and client_secret")
sys.exit(1)
app_roles = [ app_roles = [
AppRole( {
allowed_member_types=["Application"], "allowedMemberTypes": ["Application"],
display_name=OnefuzzAppRole.CliClient.value, "description": "Allows access from the CLI.",
id=str(uuid.uuid4()), "displayName": OnefuzzAppRole.CliClient.value,
is_enabled=True, "id": str(uuid.uuid4()),
description="Allows access from the CLI.", "isEnabled": True,
value=OnefuzzAppRole.CliClient.value, "value": OnefuzzAppRole.CliClient.value,
), },
AppRole( {
allowed_member_types=["Application"], "allowedMemberTypes": ["Application"],
display_name=OnefuzzAppRole.ManagedNode.value, "description": "Allow access from a lab machine.",
id=str(uuid.uuid4()), "displayName": OnefuzzAppRole.ManagedNode.value,
is_enabled=True, "id": str(uuid.uuid4()),
description="Allow access from a lab machine.", "isEnabled": True,
value=OnefuzzAppRole.ManagedNode.value, "value": OnefuzzAppRole.ManagedNode.value,
), },
] ]
app: Optional[Application] = None if not app:
if not existing:
logger.info("creating Application registration") logger.info("creating Application registration")
params = ApplicationCreateParameters( params = {
display_name=self.application_name, "displayName": self.application_name,
identifier_uris=[self.get_identifier_url()], "identifierUris": [self.get_identifier_url()],
reply_urls=[self.get_instance_url() + "/.auth/login/aad/callback"], "signInAudience": self.get_signin_audience(),
optional_claims=OptionalClaims(id_token=[], access_token=[]), "appRoles": app_roles,
required_resource_access=[ "api": {
RequiredResourceAccess( "oauth2PermissionScopes": [
resource_access=[ {
ResourceAccess(id=USER_READ_PERMISSION, type="Scope") "adminConsentDescription": f"Allow the application to access {self.application_name} on behalf of the signed-in user.",
"adminConsentDisplayName": f"Access {self.application_name}",
"id": str(uuid.uuid4()),
"isEnabled": True,
"type": "User",
"userConsentDescription": f"Allow the application to access {self.application_name} on your behalf.",
"userConsentDisplayName": f"Access {self.application_name}",
"value": "user_impersonation",
}
]
},
"web": {
"implicitGrantSettings": {
"enableAccessTokenIssuance": False,
"enableIdTokenIssuance": True,
},
"redirectUris": [
f"{self.get_instance_url()}/.auth/login/aad/callback"
],
},
"requiredResourceAccess": [
{
"resourceAccess": [
{"id": USER_READ_PERMISSION, "type": "Scope"}
], ],
resource_app_id=MICROSOFT_GRAPH_APP_ID, "resourceAppId": MICROSOFT_GRAPH_APP_ID,
) }
], ],
app_roles=app_roles, }
)
app = client.applications.create(params) app = query_microsoft_graph(
method="POST",
resource="applications",
body=params,
subscription=self.get_subscription_id(),
)
logger.info("creating service principal") logger.info("creating service principal")
service_principal_params = ServicePrincipalCreateParameters(
account_enabled=True, service_principal_params = {
app_role_assignment_required=False, "accountEnabled": True,
service_principal_type="Application", "appRoleAssignmentRequired": False,
app_id=app.app_id, "servicePrincipalType": "Application",
) "appId": app["appId"],
}
def try_sp_create() -> None: def try_sp_create() -> None:
error: Optional[Exception] = None error: Optional[Exception] = None
for _ in range(10): for _ in range(10):
try: try:
client.service_principals.create(service_principal_params) query_microsoft_graph(
method="POST",
resource="servicePrincipals",
body=service_principal_params,
subscription=self.get_subscription_id(),
)
return return
except GraphErrorException as err: except GraphQueryError as err:
# work around timing issue when creating service principal # work around timing issue when creating service principal
# https://github.com/Azure/azure-cli/issues/14767 # https://github.com/Azure/azure-cli/issues/14767
if ( if (
@ -379,56 +400,70 @@ class Client:
try_sp_create() try_sp_create()
else: else:
app = existing[0] existing_role_values = [app_role["value"] for app_role in app["appRoles"]]
api_id = self.get_identifier_url() api_id = self.get_identifier_url()
if api_id not in app.identifier_uris:
identifier_uris = app.identifier_uris if api_id not in app["identifierUris"]:
identifier_uris = app["identifierUris"]
identifier_uris.append(api_id) identifier_uris.append(api_id)
client.applications.patch( query_microsoft_graph(
app.object_id, method="PATCH",
ApplicationUpdateParameters(identifier_uris=identifier_uris), resource=f"applications/{app['id']}",
body={"identifierUris": identifier_uris},
subscription=self.get_subscription_id(),
) )
existing_role_values = [app_role.value for app_role in app.app_roles]
has_missing_roles = any( has_missing_roles = any(
[role.value not in existing_role_values for role in app_roles] [role["value"] not in existing_role_values for role in app_roles]
) )
if has_missing_roles: if has_missing_roles:
# disabling the existing app role first to allow the update # disabling the existing app role first to allow the update
# this is a requirement to update the application roles # this is a requirement to update the application roles
for role in app.app_roles: for role in app["appRoles"]:
role.is_enabled = False role["isEnabled"] = False
client.applications.patch( query_microsoft_graph(
app.object_id, ApplicationUpdateParameters(app_roles=app.app_roles) method="PATCH",
resource=f"applications/{app['id']}",
body={"appRoles": app["AppRoles"]},
subscription=self.get_subscription_id(),
) )
# overriding the list of app roles # overriding the list of app roles
client.applications.patch( query_microsoft_graph(
app.object_id, ApplicationUpdateParameters(app_roles=app_roles) method="PATCH",
resource=f"applications/{app['id']}",
body={"appRoles": app_roles},
subscription=self.get_subscription_id(),
) )
if self.multi_tenant_domain and app.sign_in_audience == "AzureADMyOrg": if self.multi_tenant_domain and app["signInAudience"] == "AzureADMyOrg":
set_app_audience(app.object_id, "AzureADMultipleOrgs") set_app_audience(
app["id"],
"AzureADMultipleOrgs",
subscription_id=self.get_subscription_id(),
)
elif ( elif (
not self.multi_tenant_domain not self.multi_tenant_domain
and app.sign_in_audience == "AzureADMultipleOrgs" and app["signInAudience"] == "AzureADMultipleOrgs"
): ):
set_app_audience(app.object_id, "AzureADMyOrg") set_app_audience(
app["id"],
"AzureADMyOrg",
subscription_id=self.get_subscription_id(),
)
else: else:
logger.debug("No change to App Registration signInAudence setting") logger.debug("No change to App Registration signInAudence setting")
creds = list(client.applications.list_password_credentials(app.object_id)) (password_id, password) = self.create_password(app["id"])
client.applications.update_password_credentials(app.object_id, creds)
(password_id, password) = self.create_password(app.object_id) cli_app = get_application(
app_id=uuid.UUID(ONEFUZZ_CLI_APP),
cli_app = list( subscription_id=self.get_subscription_id(),
client.applications.list(filter="appId eq '%s'" % ONEFUZZ_CLI_APP)
) )
if len(cli_app) == 0: if not cli_app:
logger.info( logger.info(
"Could not find the default CLI application under the current " "Could not find the default CLI application under the current "
"subscription, creating a new one" "subscription, creating a new one"
@ -449,22 +484,20 @@ class Client:
} }
else: else:
onefuzz_cli_app = cli_app[0] onefuzz_cli_app = cli_app
authorize_application(uuid.UUID(onefuzz_cli_app.app_id), app.app_id) authorize_application(uuid.UUID(onefuzz_cli_app["appId"]), app["appId"])
if self.multi_tenant_domain: if self.multi_tenant_domain:
authority = COMMON_AUTHORITY authority = COMMON_AUTHORITY
else: else:
onefuzz_client = get_graph_client(self.get_subscription_id())
authority = ( tenant_id = get_tenant_id(self.get_subscription_id())
"https://login.microsoftonline.com/%s" authority = "https://login.microsoftonline.com/%s" % tenant_id
% onefuzz_client.config.tenant_id
)
self.cli_config = { self.cli_config = {
"client_id": onefuzz_cli_app.app_id, "client_id": onefuzz_cli_app["appId"],
"authority": authority, "authority": authority,
} }
self.results["client_id"] = app.app_id self.results["client_id"] = app["appId"]
self.results["client_secret"] = password self.results["client_secret"] = password
def deploy_template(self) -> None: def deploy_template(self) -> None:
@ -554,7 +587,7 @@ class Client:
logger.info("Upgrading: skipping assignment of the managed identity role") logger.info("Upgrading: skipping assignment of the managed identity role")
return return
logger.info("assigning the user managed identity role") logger.info("assigning the user managed identity role")
assign_app_role( assign_instance_app_role(
self.application_name, self.application_name,
self.results["deploy"]["scaleset-identity"]["value"], self.results["deploy"]["scaleset-identity"]["value"],
self.get_subscription_id(), self.get_subscription_id(),

View File

@ -11,53 +11,40 @@ import urllib.parse
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum from enum import Enum
from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, TypeVar from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, TypeVar
from uuid import UUID, uuid4 from uuid import UUID
import requests import requests
from azure.cli.core.azclierror import AuthenticationError
from azure.common.client_factory import get_client_from_cli_profile
from azure.common.credentials import get_cli_profile from azure.common.credentials import get_cli_profile
from azure.graphrbac import GraphRbacManagementClient
from azure.graphrbac.models import (
Application,
ApplicationCreateParameters,
ApplicationUpdateParameters,
AppRole,
PasswordCredential,
RequiredResourceAccess,
ResourceAccess,
ServicePrincipal,
ServicePrincipalCreateParameters,
)
from functional import seq from functional import seq
from msrest.serialization import TZ_UTC from msrest.serialization import TZ_UTC
FIX_URL = (
"https://ms.portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/"
"ApplicationMenuBlade/ProtectAnAPI/appId/%s/isMSAApp/"
)
logger = logging.getLogger("deploy") logger = logging.getLogger("deploy")
## https://docs.microsoft.com/en-us/graph/api/overview?view=graph-rest-1.0
GRAPH_RESOURCE = "https://graph.microsoft.com"
GRAPH_RESOURCE_ENDPOINT = "https://graph.microsoft.com/v1.0"
class GraphQueryError(Exception): class GraphQueryError(Exception):
def __init__(self, message: str, status_code: int) -> None: def __init__(self, message: str, status_code: Optional[int]) -> None:
super(GraphQueryError, self).__init__(message) super(GraphQueryError, self).__init__(message)
self.message = message self.message = message
self.status_code = status_code self.status_code = status_code
## Queries microsoft graph api and return
def query_microsoft_graph( def query_microsoft_graph(
method: str, method: str,
resource: str, resource: str,
params: Optional[Dict] = None, params: Optional[Dict] = None,
body: Optional[Dict] = None, body: Optional[Dict] = None,
) -> Any: subscription: Optional[str] = None,
) -> Dict:
profile = get_cli_profile() profile = get_cli_profile()
(token_type, access_token, _), _, _ = profile.get_raw_token( (token_type, access_token, _), _, _ = profile.get_raw_token(
resource="https://graph.microsoft.com" resource=GRAPH_RESOURCE, subscription=subscription
) )
url = urllib.parse.urljoin("https://graph.microsoft.com/v1.0/", resource) url = urllib.parse.urljoin(f"{GRAPH_RESOURCE_ENDPOINT}/", resource)
headers = { headers = {
"Authorization": "%s %s" % (token_type, access_token), "Authorization": "%s %s" % (token_type, access_token),
"Content-Type": "application/json", "Content-Type": "application/json",
@ -65,23 +52,60 @@ def query_microsoft_graph(
response = requests.request( response = requests.request(
method=method, url=url, headers=headers, params=params, json=body method=method, url=url, headers=headers, params=params, json=body
) )
response.status_code
if 200 <= response.status_code < 300: if 200 <= response.status_code < 300:
try: if response.content and response.content.strip():
return response.json() json = response.json()
except ValueError: if isinstance(json, Dict):
return None return json
else:
raise GraphQueryError(
f"invalid data received expected a json object: HTTP {response.status_code} - {json}",
response.status_code,
)
else:
return {}
else: else:
error_text = str(response.content, encoding="utf-8", errors="backslashreplace") error_text = str(response.content, encoding="utf-8", errors="backslashreplace")
raise GraphQueryError( raise GraphQueryError(
"request did not succeed: HTTP %s - %s" f"request did not succeed: HTTP {response.status_code} - {error_text}",
% (response.status_code, error_text),
response.status_code, response.status_code,
) )
def query_microsoft_graph_list(
method: str,
resource: str,
params: Optional[Dict] = None,
body: Optional[Dict] = None,
subscription: Optional[str] = None,
) -> List[Any]:
result = query_microsoft_graph(
method,
resource,
params,
body,
subscription,
)
value = result.get("value")
if isinstance(value, list):
return value
else:
raise GraphQueryError("Expected data containing a list of values", None)
def get_tenant_id(subscription_id: Optional[str] = None) -> str:
profile = get_cli_profile()
_, _, tenant_id = profile.get_raw_token(
resource=GRAPH_RESOURCE, subscription=subscription_id
)
if isinstance(tenant_id, str):
return tenant_id
else:
raise Exception(
f"unable to retrive tenant_id for subscription {subscription_id}"
)
OperationResult = TypeVar("OperationResult") OperationResult = TypeVar("OperationResult")
@ -121,14 +145,6 @@ def retry(
time.sleep(wait_duration) time.sleep(wait_duration)
def get_graph_client(subscription_id: str) -> GraphRbacManagementClient:
client: GraphRbacManagementClient = get_client_from_cli_profile(
GraphRbacManagementClient,
subscription_id=subscription_id,
)
return client
class ApplicationInfo(NamedTuple): class ApplicationInfo(NamedTuple):
client_id: UUID client_id: UUID
client_secret: str client_secret: str
@ -147,46 +163,41 @@ def register_application(
subscription_id: str, subscription_id: str,
) -> ApplicationInfo: ) -> ApplicationInfo:
logger.info("retrieving the application registration %s" % registration_name) logger.info("retrieving the application registration %s" % registration_name)
client = get_graph_client(subscription_id)
apps: List[Application] = list( app = get_application(
client.applications.list(filter="displayName eq '%s'" % registration_name) display_name=registration_name, subscription_id=subscription_id
) )
if len(apps) == 0: if not app:
logger.info("No existing registration found. creating a new one") logger.info("No existing registration found. creating a new one")
app = create_application_registration( app = create_application_registration(
onefuzz_instance_name, registration_name, approle, subscription_id onefuzz_instance_name, registration_name, approle, subscription_id
) )
else: else:
app = apps[0]
logger.info( logger.info(
"Found existing application objectId '%s' - appid '%s'" "Found existing application objectId '%s' - appid '%s'"
% (app.object_id, app.app_id) % (app["id"], app["appId"])
) )
onefuzz_apps: List[Application] = list( onefuzz_app = get_application(
client.applications.list(filter="displayName eq '%s'" % onefuzz_instance_name) display_name=onefuzz_instance_name, subscription_id=subscription_id
) )
if len(onefuzz_apps) == 0: if not (onefuzz_app):
raise Exception("onefuzz app not found") raise Exception("onefuzz app not found")
onefuzz_app = onefuzz_apps[0] pre_authorized_applications = onefuzz_app["api"]["preAuthorizedApplications"]
pre_authorized_applications = (
onefuzz_app.pre_authorized_applications
if onefuzz_app.pre_authorized_applications is not None
else []
)
if app.app_id not in [app.app_id for app in pre_authorized_applications]: if app["appId"] not in [app["appId"] for app in pre_authorized_applications]:
authorize_application(UUID(app.app_id), UUID(onefuzz_app.app_id)) authorize_application(UUID(app["appId"]), UUID(onefuzz_app["appId"]))
password = create_application_credential(registration_name, subscription_id) password = create_application_credential(registration_name, subscription_id)
tenant_id = get_tenant_id(subscription_id=subscription_id)
return ApplicationInfo( return ApplicationInfo(
client_id=app.app_id, client_id=app["appId"],
client_secret=password, client_secret=password,
authority=("https://login.microsoftonline.com/%s" % client.config.tenant_id), authority=("https://login.microsoftonline.com/%s" % tenant_id),
) )
@ -194,93 +205,93 @@ def create_application_credential(application_name: str, subscription_id: str) -
"""Add a new password to the application registration""" """Add a new password to the application registration"""
logger.info("creating application credential for '%s'" % application_name) logger.info("creating application credential for '%s'" % application_name)
client = get_graph_client(subscription_id) app = get_application(display_name=application_name)
apps: List[Application] = list(
client.applications.list(filter="displayName eq '%s'" % application_name) if not app:
raise Exception("app not found")
(_, password) = add_application_password(
f"{application_name}_password", app["id"], subscription_id
) )
app: Application = apps[0]
(_, password) = add_application_password(app.object_id, subscription_id)
return str(password) return str(password)
def create_application_registration( def create_application_registration(
onefuzz_instance_name: str, name: str, approle: OnefuzzAppRole, subscription_id: str onefuzz_instance_name: str, name: str, approle: OnefuzzAppRole, subscription_id: str
) -> Application: ) -> Any:
"""Create an application registration""" """Create an application registration"""
client = get_graph_client(subscription_id) app = get_application(
apps: List[Application] = list( display_name=onefuzz_instance_name, subscription_id=subscription_id
client.applications.list(filter="displayName eq '%s'" % onefuzz_instance_name)
) )
app = apps[0] if not app:
raise Exception("onefuzz app registration not found")
resource_access = [ resource_access = [
ResourceAccess(id=role.id, type="Role") {"id": role["id"], "type": "Scope"}
for role in app.app_roles for role in app["appRoles"]
if role.value == approle.value if role["value"] == approle.value
] ]
params = ApplicationCreateParameters( params = {
is_device_only_auth_supported=True, "isDeviceOnlyAuthSupported": True,
display_name=name, "displayName": name,
identifier_uris=[], "publicClient": {
password_credentials=[], "redirectUris": ["https://%s.azurewebsites.net" % onefuzz_instance_name]
required_resource_access=( },
"isFallbackPublicClient": True,
"requiredResourceAccess": (
[ [
RequiredResourceAccess( {
resource_access=resource_access, "resourceAccess": resource_access,
resource_app_id=app.app_id, "resourceAppId": app["appId"],
) }
] ]
if len(resource_access) > 0 if len(resource_access) > 0
else [] else []
), ),
) }
registered_app: Application = client.applications.create(params) registered_app = query_microsoft_graph(
method="POST",
resource="applications",
body=params,
subscription=subscription_id,
)
logger.info("creating service principal") logger.info("creating service principal")
service_principal_params = ServicePrincipalCreateParameters(
account_enabled=True, service_principal_params = {
app_role_assignment_required=False, "accountEnabled": True,
service_principal_type="Application", "appRoleAssignmentRequired": False,
app_id=registered_app.app_id, "servicePrincipalType": "Application",
"appId": registered_app["appId"],
}
query_microsoft_graph(
method="POST",
resource="servicePrincipals",
body=service_principal_params,
subscription=subscription_id,
) )
client.service_principals.create(service_principal_params) authorize_application(
UUID(registered_app["appId"]),
atttempts = 5 UUID(app["appId"]),
while True: subscription_id=subscription_id,
if atttempts < 0: )
raise Exception( assign_instance_app_role(onefuzz_instance_name, name, subscription_id, approle)
"Unable to create application registration, Please try again"
)
atttempts = atttempts - 1
try:
time.sleep(5)
update_param = ApplicationUpdateParameters(
reply_urls=["https://%s.azurewebsites.net" % onefuzz_instance_name]
)
client.applications.patch(registered_app.object_id, update_param)
break
except Exception:
continue
authorize_application(UUID(registered_app.app_id), UUID(app.app_id))
assign_app_role(onefuzz_instance_name, name, subscription_id, approle)
return registered_app return registered_app
def add_application_password( def add_application_password(
app_object_id: UUID, subscription_id: str password_name: str, app_object_id: UUID, subscription_id: str
) -> Tuple[str, str]: ) -> Tuple[str, str]:
def create_password(data: Any) -> Tuple[str, str]: def create_password(data: Any) -> Tuple[str, str]:
password = add_application_password_impl(app_object_id, subscription_id) password = add_application_password_impl(
password_name, app_object_id, subscription_id
)
logger.info("app password created") logger.info("app password created")
return password return password
@ -289,35 +300,32 @@ def add_application_password(
return retry(create_password, "create password") return retry(create_password, "create password")
def add_application_password_legacy(
app_object_id: UUID, subscription_id: str
) -> Tuple[str, str]:
key = str(uuid4())
password = str(uuid4())
client = get_graph_client(subscription_id)
password_cred = [
PasswordCredential(
start_date="%s" % datetime.now(TZ_UTC).strftime("%Y-%m-%dT%H:%M.%fZ"),
end_date="%s"
% (datetime.now(TZ_UTC) + timedelta(days=365)).strftime(
"%Y-%m-%dT%H:%M.%fZ"
),
key_id=key,
value=password,
)
]
client.applications.update_password_credentials(app_object_id, password_cred)
return (key, password)
def add_application_password_impl( def add_application_password_impl(
app_object_id: UUID, subscription_id: str password_name: str, app_object_id: UUID, subscription_id: str
) -> Tuple[str, str]: ) -> Tuple[str, str]:
key = uuid4()
app = query_microsoft_graph(
method="GET",
resource="applications/%s" % app_object_id,
subscription=subscription_id,
)
passwords = [
x for x in app["passwordCredentials"] if x["displayName"] == password_name
]
if len(passwords) > 0:
key_id = passwords[0]["keyId"]
query_microsoft_graph(
method="POST",
resource="applications/%s/removePassword" % app_object_id,
body={"keyId": key_id},
subscription=subscription_id,
)
password_request = { password_request = {
"passwordCredential": { "passwordCredential": {
"displayName": "%s" % key, "displayName": "%s" % password_name,
"startDateTime": "%s" % datetime.now(TZ_UTC).strftime("%Y-%m-%dT%H:%M.%fZ"), "startDateTime": "%s" % datetime.now(TZ_UTC).strftime("%Y-%m-%dT%H:%M.%fZ"),
"endDateTime": "%s" "endDateTime": "%s"
% (datetime.now(TZ_UTC) + timedelta(days=365)).strftime( % (datetime.now(TZ_UTC) + timedelta(days=365)).strftime(
@ -326,98 +334,116 @@ def add_application_password_impl(
} }
} }
try: password: Dict = query_microsoft_graph(
password: Dict = query_microsoft_graph( method="POST",
method="POST", resource="applications/%s/addPassword" % app_object_id,
resource="applications/%s/addPassword" % app_object_id, body=password_request,
body=password_request, subscription=subscription_id,
) )
return (str(key), password["secretText"]) return (password_name, password["secretText"])
except AuthenticationError:
return add_application_password_legacy(app_object_id, subscription_id)
def get_application(app_id: UUID) -> Optional[Any]: def get_application(
apps: Dict = query_microsoft_graph( app_id: Optional[UUID] = None,
display_name: Optional[str] = None,
subscription_id: Optional[str] = None,
) -> Optional[Any]:
filters = []
if app_id:
filters.append("appId eq '%s'" % app_id)
if display_name:
filters.append("displayName eq '%s'" % display_name)
filter_str = " and ".join(filters)
apps = query_microsoft_graph(
method="GET", method="GET",
resource="applications", resource="applications",
params={"$filter": "appId eq '%s'" % app_id}, params={
"$filter": filter_str,
},
subscription=subscription_id,
) )
if len(apps["value"]) == 0: number_of_apps = len(apps["value"])
if number_of_apps == 0:
return None return None
elif number_of_apps == 1:
return apps["value"][0] return apps["value"][0]
else:
raise Exception(
f"Found {number_of_apps} application matching filter: '{filter_str}'"
)
def authorize_application( def authorize_application(
registration_app_id: UUID, registration_app_id: UUID,
onefuzz_app_id: UUID, onefuzz_app_id: UUID,
permissions: List[str] = ["user_impersonation"], permissions: List[str] = ["user_impersonation"],
subscription_id: Optional[str] = None,
) -> None: ) -> None:
try: onefuzz_app = get_application(
onefuzz_app = get_application(onefuzz_app_id) app_id=onefuzz_app_id, subscription_id=subscription_id
if onefuzz_app is None: )
logger.error("Application '%s' not found", onefuzz_app_id) if onefuzz_app is None:
return logger.error("Application '%s' not found", onefuzz_app_id)
return
scopes = seq(onefuzz_app["api"]["oauth2PermissionScopes"]).filter( scopes = seq(onefuzz_app["api"]["oauth2PermissionScopes"]).filter(
lambda scope: scope["value"] in permissions lambda scope: scope["value"] in permissions
) )
existing_preAuthorizedApplications = ( existing_preAuthorizedApplications = (
seq(onefuzz_app["api"]["preAuthorizedApplications"]) seq(onefuzz_app["api"]["preAuthorizedApplications"])
.map( .map(
lambda paa: seq(paa["delegatedPermissionIds"]).map( lambda paa: seq(paa["delegatedPermissionIds"]).map(
lambda permission_id: (paa["appId"], permission_id) lambda permission_id: (paa["appId"], permission_id)
)
) )
.flatten()
) )
.flatten()
)
preAuthorizedApplications = ( preAuthorizedApplications = (
scopes.map(lambda s: (str(registration_app_id), s["id"])) scopes.map(lambda s: (str(registration_app_id), s["id"]))
.union(existing_preAuthorizedApplications) .union(existing_preAuthorizedApplications)
.distinct() .distinct()
.group_by_key() .group_by_key()
.map(lambda data: {"appId": data[0], "delegatedPermissionIds": data[1]}) .map(lambda data: {"appId": data[0], "delegatedPermissionIds": data[1]})
) )
onefuzz_app_id = onefuzz_app["id"] onefuzz_app_id = onefuzz_app["id"]
def add_preauthorized_app(app_list: List[Dict]) -> None: def add_preauthorized_app(app_list: List[Dict]) -> None:
try: try:
query_microsoft_graph( query_microsoft_graph(
method="PATCH", method="PATCH",
resource="applications/%s" % onefuzz_app_id, resource="applications/%s" % onefuzz_app_id,
body={"api": {"preAuthorizedApplications": app_list}}, body={"api": {"preAuthorizedApplications": app_list}},
) subscription=subscription_id,
except GraphQueryError as e: )
m = re.search( except GraphQueryError as e:
"Property PreAuthorizedApplication references " m = re.search(
"applications (.*) that cannot be found.", "Property PreAuthorizedApplication references "
e.message, "applications (.*) that cannot be found.",
) e.message,
if m: )
invalid_app_id = m.group(1) if m:
if invalid_app_id: invalid_app_id = m.group(1)
for app in app_list: if invalid_app_id:
if app["appId"] == invalid_app_id: for app in app_list:
logger.warning( if app["appId"] == invalid_app_id:
f"removing invalid id {invalid_app_id} " logger.warning(
"for the next request" f"removing invalid id {invalid_app_id} "
) "for the next request"
app_list.remove(app) )
app_list.remove(app)
raise e raise e
retry( retry(
add_preauthorized_app, add_preauthorized_app,
"authorize application", "authorize application",
data=preAuthorizedApplications.to_list(), data=preAuthorizedApplications.to_list(),
) )
except AuthenticationError:
logger.warning("*** Browse to: %s", FIX_URL % onefuzz_app_id)
logger.warning("*** Then add the client application %s", registration_app_id)
def create_and_display_registration( def create_and_display_registration(
@ -451,70 +477,63 @@ def update_pool_registration(onefuzz_instance_name: str, subscription_id: str) -
) )
def assign_app_role_manually( def assign_app_role(
onefuzz_instance_name: str, principal_id: str,
application_name: str, application_id: str,
role_names: List[str],
subscription_id: str, subscription_id: str,
app_role: OnefuzzAppRole,
) -> None: ) -> None:
application_registrations = query_microsoft_graph_list(
method="GET",
resource="servicePrincipals",
params={
"$filter": f"appId eq '{application_id}'",
},
subscription=subscription_id,
)
if len(application_registrations) == 0:
raise Exception(f"appid '{application_id}' was not found:")
app = application_registrations[0]
client = get_graph_client(subscription_id) roles = (
apps: List[Application] = list( seq(app["appRoles"]).filter(lambda role: role["value"] in role_names).to_list()
client.applications.list(filter="displayName eq '%s'" % onefuzz_instance_name)
) )
if not apps: if len(roles) < len(role_names):
raise Exception("onefuzz app registration not found") existing_roles = [role["value"] for role in roles]
missing_roles = [
app = apps[0] role_name for role_name in role_names if role_name not in existing_roles
appId = app.app_id ]
onefuzz_service_principals: List[ServicePrincipal] = list(
client.service_principals.list(filter="appId eq '%s'" % appId)
)
if not onefuzz_service_principals:
raise Exception("onefuzz app service principal not found")
onefuzz_service_principal = onefuzz_service_principals[0]
scaleset_service_principals: List[ServicePrincipal] = list(
client.service_principals.list(filter="displayName eq '%s'" % application_name)
)
if not scaleset_service_principals:
raise Exception("scaleset service principal not found")
scaleset_service_principal = scaleset_service_principals[0]
scaleset_service_principal.app_roles
app_roles: List[AppRole] = [
role for role in app.app_roles if role.value == app_role.value
]
if not app_roles:
raise Exception( raise Exception(
"ManagedNode role not found in the OneFuzz application " f"The following roles could not be found in appId '{application_id}': {missing_roles}"
"registration. Please redeploy the instance"
) )
body = '{ "principalId": "%s", "resourceId": "%s", "appRoleId": "%s"}' % ( expected_role_ids = [role["id"] for role in roles]
scaleset_service_principal.object_id, assignments = query_microsoft_graph_list(
onefuzz_service_principal.object_id, method="GET",
app_roles[0].id, resource=f"servicePrincipals/{principal_id}/appRoleAssignments",
subscription=subscription_id,
) )
assigned_role_ids = [assignment["appRoleId"] for assignment in assignments]
missing_assignments = [
id for id in expected_role_ids if id not in assigned_role_ids
]
query = ( if missing_assignments:
"az rest --method post --url " for app_role_id in missing_assignments:
"https://graph.microsoft.com/v1.0/servicePrincipals/%s/appRoleAssignedTo " query_microsoft_graph(
"--body '%s' --headers \"Content-Type\"=application/json" method="POST",
% (scaleset_service_principal.object_id, body) resource=f"servicePrincipals/{principal_id}/appRoleAssignedTo",
) body={
"principalId": principal_id,
logger.warning( "resourceId": app["id"],
"execute the following query in the azure portal bash shell : \n%s" % query "appRoleId": app_role_id,
) },
subscription=subscription_id,
)
def assign_app_role( def assign_instance_app_role(
onefuzz_instance_name: str, onefuzz_instance_name: str,
application_name: str, application_name: str,
subscription_id: str, subscription_id: str,
@ -524,74 +543,77 @@ def assign_app_role(
Allows the application to access the service by assigning Allows the application to access the service by assigning
their managed identity to the provided App Role their managed identity to the provided App Role
""" """
try:
onefuzz_service_appId = query_microsoft_graph( onefuzz_service_appIds = query_microsoft_graph_list(
method="GET", method="GET",
resource="applications", resource="applications",
params={ params={
"$filter": "displayName eq '%s'" % onefuzz_instance_name, "$filter": "displayName eq '%s'" % onefuzz_instance_name,
"$select": "appId", "$select": "appId",
},
subscription=subscription_id,
)
if len(onefuzz_service_appIds) == 0:
raise Exception("onefuzz app registration not found")
appId = onefuzz_service_appIds[0]["appId"]
onefuzz_service_principals = query_microsoft_graph_list(
method="GET",
resource="servicePrincipals",
params={"$filter": "appId eq '%s'" % appId},
subscription=subscription_id,
)
if len(onefuzz_service_principals) == 0:
raise Exception("onefuzz app service principal not found")
onefuzz_service_principal = onefuzz_service_principals[0]
application_service_principals = query_microsoft_graph_list(
method="GET",
resource="servicePrincipals",
params={"$filter": "displayName eq '%s'" % application_name},
subscription=subscription_id,
)
if len(application_service_principals) == 0:
raise Exception(f"application '{application_name}' service principal not found")
application_service_principal = application_service_principals[0]
managed_node_role = (
seq(onefuzz_service_principal["appRoles"])
.filter(lambda x: x["value"] == app_role.value)
.head_option()
)
if not managed_node_role:
raise Exception(
f"{app_role.value} role not found in the OneFuzz application "
"registration. Please redeploy the instance"
)
assignments = query_microsoft_graph_list(
method="GET",
resource="servicePrincipals/%s/appRoleAssignments"
% application_service_principal["id"],
subscription=subscription_id,
)
# check if the role is already assigned
role_assigned = seq(assignments).find(
lambda assignment: assignment["appRoleId"] == managed_node_role["id"]
)
if not role_assigned:
query_microsoft_graph(
method="POST",
resource="servicePrincipals/%s/appRoleAssignedTo"
% application_service_principal["id"],
body={
"principalId": application_service_principal["id"],
"resourceId": onefuzz_service_principal["id"],
"appRoleId": managed_node_role["id"],
}, },
) subscription=subscription_id,
if len(onefuzz_service_appId["value"]) == 0:
raise Exception("onefuzz app registration not found")
appId = onefuzz_service_appId["value"][0]["appId"]
onefuzz_service_principals = query_microsoft_graph(
method="GET",
resource="servicePrincipals",
params={"$filter": "appId eq '%s'" % appId},
)
if len(onefuzz_service_principals["value"]) == 0:
raise Exception("onefuzz app service principal not found")
onefuzz_service_principal = onefuzz_service_principals["value"][0]
scaleset_service_principals = query_microsoft_graph(
method="GET",
resource="servicePrincipals",
params={"$filter": "displayName eq '%s'" % application_name},
)
if len(scaleset_service_principals["value"]) == 0:
raise Exception("scaleset service principal not found")
scaleset_service_principal = scaleset_service_principals["value"][0]
managed_node_role = (
seq(onefuzz_service_principal["appRoles"])
.filter(lambda x: x["value"] == app_role.value)
.head_option()
)
if not managed_node_role:
raise Exception(
f"{app_role.value} role not found in the OneFuzz application "
"registration. Please redeploy the instance"
)
assignments = query_microsoft_graph(
method="GET",
resource="servicePrincipals/%s/appRoleAssignments"
% scaleset_service_principal["id"],
)
# check if the role is already assigned
role_assigned = seq(assignments["value"]).find(
lambda assignment: assignment["appRoleId"] == managed_node_role["id"]
)
if not role_assigned:
query_microsoft_graph(
method="POST",
resource="servicePrincipals/%s/appRoleAssignedTo"
% scaleset_service_principal["id"],
body={
"principalId": scaleset_service_principal["id"],
"resourceId": onefuzz_service_principal["id"],
"appRoleId": managed_node_role["id"],
},
)
except AuthenticationError:
assign_app_role_manually(
onefuzz_instance_name, application_name, subscription_id, app_role
) )
def set_app_audience(objectId: str, audience: str) -> None: def set_app_audience(
objectId: str, audience: str, subscription_id: Optional[str] = None
) -> None:
# typical audience values: AzureADMyOrg, AzureADMultipleOrgs # typical audience values: AzureADMyOrg, AzureADMultipleOrgs
http_body = {"signInAudience": audience} http_body = {"signInAudience": audience}
try: try:
@ -599,6 +621,7 @@ def set_app_audience(objectId: str, audience: str) -> None:
method="PATCH", method="PATCH",
resource="applications/%s" % objectId, resource="applications/%s" % objectId,
body=http_body, body=http_body,
subscription=subscription_id,
) )
except GraphQueryError: except GraphQueryError:
query = ( query = (
@ -680,7 +703,7 @@ def main() -> None:
display_secret=True, display_secret=True,
) )
elif args.command == "assign_scaleset_role": elif args.command == "assign_scaleset_role":
assign_app_role( assign_instance_app_role(
onefuzz_instance_name, onefuzz_instance_name,
args.scaleset_name, args.scaleset_name,
args.subscription_id, args.subscription_id,

View File

@ -265,6 +265,7 @@ class ErrorCode(Enum):
NOTIFICATION_FAILURE = 470 NOTIFICATION_FAILURE = 470
UNABLE_TO_UPDATE = 471 UNABLE_TO_UPDATE = 471
PROXY_FAILED = 472 PROXY_FAILED = 472
INVALID_CONFIGURATION = 473
class HeartbeatType(Enum): class HeartbeatType(Enum):