mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 21:11:53 +00:00
feat(m365): add entra_service_principal_no_secrets_for_permanent_tier0_roles security check (#10788)
Co-authored-by: Hugo P.Brito <hugopbrito@Mac.home>
This commit is contained in:
committed by
GitHub
parent
6dfa135755
commit
1b99550572
@@ -6,7 +6,8 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- `iam_user_access_not_stale_to_sagemaker` check for aws provider with configurable `max_unused_sagemaker_access_days` (default 90) [(#11000)](https://github.com/prowler-cloud/prowler/pull/11000)
|
||||
- `entra_service_principal_no_secrets_for_permanent_tier0_roles` check for M365 provider [(#10788)](https://github.com/prowler-cloud/prowler/pull/10788)
|
||||
- `iam_user_access_not_stale_to_sagemaker` check for AWS provider with configurable `max_unused_sagemaker_access_days` (default 90) [(#11000)](https://github.com/prowler-cloud/prowler/pull/11000)
|
||||
- `cloudtrail_bedrock_logging_enabled` check for AWS provider [(#10858)](https://github.com/prowler-cloud/prowler/pull/10858)
|
||||
|
||||
---
|
||||
|
||||
@@ -261,6 +261,7 @@
|
||||
"entra_legacy_authentication_blocked",
|
||||
"entra_managed_device_required_for_authentication",
|
||||
"entra_seamless_sso_disabled",
|
||||
"entra_service_principal_no_secrets_for_permanent_tier0_roles",
|
||||
"entra_users_mfa_enabled",
|
||||
"exchange_organization_modern_authentication_enabled",
|
||||
"exchange_transport_config_smtp_auth_disabled",
|
||||
@@ -283,6 +284,7 @@
|
||||
"entra_admin_portals_access_restriction",
|
||||
"entra_app_registration_no_unused_privileged_permissions",
|
||||
"entra_policy_guest_users_access_restrictions",
|
||||
"entra_service_principal_no_secrets_for_permanent_tier0_roles",
|
||||
"sharepoint_external_sharing_managed",
|
||||
"sharepoint_external_sharing_restricted",
|
||||
"sharepoint_guest_sharing_restricted"
|
||||
@@ -676,7 +678,8 @@
|
||||
"entra_app_registration_no_unused_privileged_permissions",
|
||||
"entra_policy_ensure_default_user_cannot_create_tenants",
|
||||
"entra_policy_guest_invite_only_for_admin_roles",
|
||||
"entra_seamless_sso_disabled"
|
||||
"entra_seamless_sso_disabled",
|
||||
"entra_service_principal_no_secrets_for_permanent_tier0_roles"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -733,6 +736,7 @@
|
||||
"entra_identity_protection_sign_in_risk_enabled",
|
||||
"entra_managed_device_required_for_authentication",
|
||||
"entra_seamless_sso_disabled",
|
||||
"entra_service_principal_no_secrets_for_permanent_tier0_roles",
|
||||
"entra_users_mfa_enabled"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
from asyncio import gather
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional
|
||||
from uuid import UUID
|
||||
@@ -38,6 +39,7 @@ class Entra(M365Service):
|
||||
user_accounts_status (dict): Dictionary of user account statuses.
|
||||
oauth_apps (dict): Dictionary of OAuth applications from Defender XDR.
|
||||
authentication_method_configurations (dict): Dictionary of authentication method configurations.
|
||||
service_principals (dict): Dictionary of service principals with credentials and role assignments.
|
||||
"""
|
||||
|
||||
def __init__(self, provider: M365Provider):
|
||||
@@ -73,6 +75,7 @@ class Entra(M365Service):
|
||||
)
|
||||
|
||||
self.tenant_domain = provider.identity.tenant_domain
|
||||
self.tenant_id = getattr(provider.identity, "tenant_id", None)
|
||||
attributes = loop.run_until_complete(
|
||||
gather(
|
||||
self._get_authorization_policy(),
|
||||
@@ -85,6 +88,7 @@ class Entra(M365Service):
|
||||
self._get_oauth_apps(),
|
||||
self._get_directory_sync_settings(),
|
||||
self._get_authentication_method_configurations(),
|
||||
self._get_service_principals(),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -100,6 +104,7 @@ class Entra(M365Service):
|
||||
self.authentication_method_configurations: Dict[
|
||||
str, AuthenticationMethodConfiguration
|
||||
] = attributes[9]
|
||||
self.service_principals: Dict[str, "ServicePrincipal"] = attributes[10]
|
||||
self.user_accounts_status = {}
|
||||
|
||||
if created_loop:
|
||||
@@ -1090,6 +1095,154 @@ OAuthAppInfo
|
||||
)
|
||||
return authentication_method_configurations
|
||||
|
||||
async def _get_service_principals(self):
|
||||
"""Retrieve service principals owned by the audited tenant.
|
||||
|
||||
Fetches all service principals from Microsoft Graph and keeps only the
|
||||
ones whose ``appOwnerOrganizationId`` matches the audited tenant. Skips
|
||||
Microsoft first-party service principals and multi-tenant ISV apps
|
||||
consented from other publishers: their credentials live in the
|
||||
publisher's tenant, not this one, so they are out of scope for any
|
||||
check that evaluates secret hygiene or role assignments managed by the
|
||||
customer.
|
||||
|
||||
Returns:
|
||||
Dict[str, ServicePrincipal]: Customer-owned service principals
|
||||
keyed by service principal ID.
|
||||
"""
|
||||
logger.info("Entra - Getting service principals...")
|
||||
service_principals = {}
|
||||
tenant_id_normalized = str(self.tenant_id).lower() if self.tenant_id else None
|
||||
try:
|
||||
sp_response = await self.client.service_principals.get()
|
||||
|
||||
# Build a map of service principal IDs to their data
|
||||
while sp_response:
|
||||
for sp in getattr(sp_response, "value", []) or []:
|
||||
raw_owner = getattr(sp, "app_owner_organization_id", None)
|
||||
app_owner_org_id = str(raw_owner).lower() if raw_owner else None
|
||||
if (
|
||||
tenant_id_normalized
|
||||
and app_owner_org_id != tenant_id_normalized
|
||||
):
|
||||
# Skip Microsoft first-party SPs and consented
|
||||
# multi-tenant ISV apps; the customer cannot manage
|
||||
# their credentials.
|
||||
continue
|
||||
|
||||
password_credentials = []
|
||||
for cred in getattr(sp, "password_credentials", []) or []:
|
||||
password_credentials.append(
|
||||
PasswordCredential(
|
||||
key_id=str(getattr(cred, "key_id", "")),
|
||||
display_name=getattr(cred, "display_name", None),
|
||||
end_date_time=getattr(cred, "end_date_time", None),
|
||||
)
|
||||
)
|
||||
|
||||
key_credentials = []
|
||||
for cred in getattr(sp, "key_credentials", []) or []:
|
||||
key_credentials.append(
|
||||
KeyCredential(
|
||||
key_id=str(getattr(cred, "key_id", "")),
|
||||
display_name=getattr(cred, "display_name", None),
|
||||
)
|
||||
)
|
||||
|
||||
service_principals[sp.id] = ServicePrincipal(
|
||||
id=sp.id,
|
||||
name=getattr(sp, "display_name", "") or "",
|
||||
app_id=getattr(sp, "app_id", "") or "",
|
||||
app_owner_organization_id=app_owner_org_id,
|
||||
password_credentials=password_credentials,
|
||||
key_credentials=key_credentials,
|
||||
)
|
||||
|
||||
next_link = getattr(sp_response, "odata_next_link", None)
|
||||
if not next_link:
|
||||
break
|
||||
sp_response = await self.client.service_principals.with_url(
|
||||
next_link
|
||||
).get()
|
||||
|
||||
# Fold in credentials registered on the parent Application objects.
|
||||
# Microsoft Graph stores secrets and certificates added through
|
||||
# "Certificates & secrets" on /applications, not on the service
|
||||
# principal itself, so /servicePrincipals.passwordCredentials is
|
||||
# almost always empty for normal app registrations. Joining via
|
||||
# appId is required for the check to see those credentials.
|
||||
#
|
||||
# Index service principals by app_id once so the join below is
|
||||
# O(N+M) instead of scanning all SPs for every Application page.
|
||||
service_principals_by_app_id = {
|
||||
sp.app_id: sp for sp in service_principals.values() if sp.app_id
|
||||
}
|
||||
app_response = await self.client.applications.get()
|
||||
while app_response:
|
||||
for app in getattr(app_response, "value", []) or []:
|
||||
app_id = getattr(app, "app_id", None)
|
||||
if not app_id:
|
||||
continue
|
||||
target_sp = service_principals_by_app_id.get(app_id)
|
||||
if target_sp is None:
|
||||
continue
|
||||
|
||||
for cred in getattr(app, "password_credentials", []) or []:
|
||||
target_sp.password_credentials.append(
|
||||
PasswordCredential(
|
||||
key_id=str(getattr(cred, "key_id", "")),
|
||||
display_name=getattr(cred, "display_name", None),
|
||||
end_date_time=getattr(cred, "end_date_time", None),
|
||||
)
|
||||
)
|
||||
for cred in getattr(app, "key_credentials", []) or []:
|
||||
target_sp.key_credentials.append(
|
||||
KeyCredential(
|
||||
key_id=str(getattr(cred, "key_id", "")),
|
||||
display_name=getattr(cred, "display_name", None),
|
||||
)
|
||||
)
|
||||
|
||||
next_link = getattr(app_response, "odata_next_link", None)
|
||||
if not next_link:
|
||||
break
|
||||
app_response = await self.client.applications.with_url(next_link).get()
|
||||
|
||||
# Identify permanent Tier 0 directory role assignments via the unified
|
||||
# role management endpoint. ``directoryRoles/{id}/members`` mixes
|
||||
# permanent direct assignments with PIM-activated temporary ones, so
|
||||
# using it would mark just-in-time elevations as "permanent" and emit
|
||||
# false positives. ``roleManagement/directory/roleAssignments``
|
||||
# exposes only the durable, statically-assigned principals, which is
|
||||
# exactly what the Tier 0 check needs.
|
||||
role_assignments_response = (
|
||||
await self.client.role_management.directory.role_assignments.get()
|
||||
)
|
||||
while role_assignments_response:
|
||||
for assignment in getattr(role_assignments_response, "value", []) or []:
|
||||
principal_id = getattr(assignment, "principal_id", None)
|
||||
role_definition_id = getattr(assignment, "role_definition_id", None)
|
||||
if (
|
||||
principal_id in service_principals
|
||||
and role_definition_id in TIER_0_ROLE_TEMPLATE_IDS
|
||||
):
|
||||
service_principals[
|
||||
principal_id
|
||||
].directory_role_template_ids.append(role_definition_id)
|
||||
|
||||
next_link = getattr(role_assignments_response, "odata_next_link", None)
|
||||
if not next_link:
|
||||
break
|
||||
role_assignments_response = await self.client.role_management.directory.role_assignments.with_url(
|
||||
next_link
|
||||
).get()
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
return service_principals
|
||||
|
||||
|
||||
class ConditionalAccessPolicyState(Enum):
|
||||
ENABLED = "enabled"
|
||||
@@ -1521,3 +1674,94 @@ class OAuthApp(BaseModel):
|
||||
is_admin_consented: bool = False
|
||||
last_used_time: Optional[str] = None
|
||||
app_origin: str = ""
|
||||
|
||||
|
||||
class PasswordCredential(BaseModel):
|
||||
"""Model representing a password credential (client secret) on a service principal.
|
||||
|
||||
Attributes:
|
||||
key_id: The unique identifier of the credential.
|
||||
display_name: The optional display name of the credential.
|
||||
end_date_time: The expiration time of the credential. ``None`` indicates
|
||||
the secret has no recorded expiry and is treated as active.
|
||||
"""
|
||||
|
||||
key_id: str
|
||||
display_name: Optional[str] = None
|
||||
end_date_time: Optional[datetime] = None
|
||||
|
||||
def is_active(self, now: Optional[datetime] = None) -> bool:
|
||||
"""Return ``True`` when the credential has not expired.
|
||||
|
||||
A credential with no ``end_date_time`` is assumed to be active, matching
|
||||
the behavior of the Microsoft Graph API when the field is omitted.
|
||||
"""
|
||||
if self.end_date_time is None:
|
||||
return True
|
||||
reference = now or datetime.now(timezone.utc)
|
||||
return self.end_date_time > reference
|
||||
|
||||
|
||||
class KeyCredential(BaseModel):
|
||||
"""Model representing a key credential (certificate) on a service principal.
|
||||
|
||||
Attributes:
|
||||
key_id: The unique identifier of the credential.
|
||||
display_name: The optional display name of the credential.
|
||||
"""
|
||||
|
||||
key_id: str
|
||||
display_name: Optional[str] = None
|
||||
|
||||
|
||||
# Control Plane (Tier 0) role template IDs.
|
||||
#
|
||||
# Roles included grant tenant-wide control over identity, authentication, or the
|
||||
# directory itself, so a credential compromise on any of them is equivalent to a
|
||||
# tenant takeover. References:
|
||||
# https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/privileged-roles-permissions
|
||||
# https://learn.microsoft.com/en-us/security/privileged-access-workstations/privileged-access-access-model
|
||||
TIER_0_ROLE_TEMPLATE_IDS = {
|
||||
"62e90394-69f5-4237-9190-012177145e10", # Global Administrator
|
||||
"e8611ab8-c189-46e8-94e1-60213ab1f814", # Privileged Role Administrator
|
||||
"7be44c8a-adaf-4e2a-84d6-ab2649e08a13", # Privileged Authentication Administrator
|
||||
"9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3", # Application Administrator
|
||||
"158c047a-c907-4556-b7ef-446551a6b5f7", # Cloud Application Administrator
|
||||
"c4e39bd9-1100-46d3-8c65-fb160da0071f", # Authentication Administrator
|
||||
"0526716b-113d-4c15-b2c8-68e3c22b9f80", # Authentication Policy Administrator
|
||||
"b1be1c3e-b65d-4f19-8427-f6fa0d97feb9", # Conditional Access Administrator
|
||||
"8329153b-31d0-4727-b945-745eb3bc5f31", # Domain Name Administrator
|
||||
"be2f45a1-457d-42af-a067-6ec1fa63bc45", # External Identity Provider Administrator
|
||||
"8ac3fc64-6eca-42ea-9e69-59f4c7b60eb2", # Hybrid Identity Administrator
|
||||
"194ae4cb-b126-40b2-bd5b-6091b380977d", # Security Administrator
|
||||
"fe930be7-5e62-47db-91af-98c3a49a38b1", # User Administrator
|
||||
"d29b2b05-8046-44ba-8758-1e26182fcf32", # Directory Synchronization Accounts
|
||||
"e00e864a-17c5-4a4b-9c06-f5b95a8d5bd8", # Partner Tier2 Support
|
||||
}
|
||||
|
||||
|
||||
class ServicePrincipal(BaseModel):
|
||||
"""Model representing a Microsoft Entra ID service principal.
|
||||
|
||||
Attributes:
|
||||
id: The service principal's unique identifier.
|
||||
name: The service principal's display name.
|
||||
app_id: The application ID associated with the service principal.
|
||||
app_owner_organization_id: Tenant ID of the application's publisher.
|
||||
For customer-owned apps this matches the audited tenant; the
|
||||
service-layer fetch uses this to filter out Microsoft first-party
|
||||
and third-party multi-tenant service principals that the customer
|
||||
cannot manage credentials for.
|
||||
password_credentials: List of password credentials (client secrets).
|
||||
key_credentials: List of key credentials (certificates).
|
||||
directory_role_template_ids: List of directory role template IDs permanently
|
||||
assigned to this service principal.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
app_id: str = ""
|
||||
app_owner_organization_id: Optional[str] = None
|
||||
password_credentials: List[PasswordCredential] = []
|
||||
key_credentials: List[KeyCredential] = []
|
||||
directory_role_template_ids: List[str] = []
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Provider": "m365",
|
||||
"CheckID": "entra_service_principal_no_secrets_for_permanent_tier0_roles",
|
||||
"CheckTitle": "Secure credential management prevents client secret usage for service principals with permanent Tier 0 roles",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "critical",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "Microsoft Entra **service principals** with permanent assignments to **Control Plane (Tier 0)** directory roles are evaluated for the use of **client secrets** (password credentials) instead of more secure authentication methods such as certificates or managed identities.",
|
||||
"Risk": "A service principal authenticating with a **client secret** while holding a **Tier 0** role creates a high-impact credential theft path. Leaked or brute-forced secrets grant immediate control-plane access, enabling tenant-wide privilege escalation, security control bypass, data exfiltration, and persistent backdoor creation—impacting **confidentiality**, **integrity**, and **availability**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/security-planning#prefer-certificate-credentials",
|
||||
"https://learn.microsoft.com/en-us/entra/architecture/security-operations-applications#application-credentials"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Identity > Applications > App registrations > select the application\n3. Under Certificates & secrets, remove all client secrets\n4. Under Certificates & secrets > Certificates, upload a certificate for authentication\n5. Alternatively, migrate to a managed identity where possible",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Replace **client secrets** with **certificates** or **managed identities** for service principals holding Control Plane roles. Apply **least privilege** by removing unnecessary Tier 0 assignments. Use **Privileged Identity Management (PIM)** for just-in-time eligible assignments instead of permanent ones. Rotate credentials regularly and monitor sign-in logs for anomalies.",
|
||||
"Url": "https://hub.prowler.com/check/entra_service_principal_no_secrets_for_permanent_tier0_roles"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access",
|
||||
"secrets"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Tier 0 roles evaluated follow the Microsoft Entra Control Plane classification, including Global Administrator, Privileged Role Administrator, and Privileged Authentication Administrator."
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
"""Check for service principals using client secrets with permanent Tier 0 role assignments."""
|
||||
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportM365
|
||||
from prowler.providers.m365.services.entra.entra_client import entra_client
|
||||
from prowler.providers.m365.services.entra.entra_service import TIER_0_ROLE_TEMPLATE_IDS
|
||||
|
||||
|
||||
class entra_service_principal_no_secrets_for_permanent_tier0_roles(Check):
|
||||
"""
|
||||
Service principal with permanent Control Plane role does not use client secrets.
|
||||
|
||||
This check evaluates whether service principals that hold permanent assignments
|
||||
to Tier 0 (Control Plane) directory roles authenticate using client secrets
|
||||
instead of more secure alternatives such as certificates or managed identities.
|
||||
|
||||
- PASS: The service principal does not use client secrets or does not hold a
|
||||
permanent Tier 0 directory role assignment.
|
||||
- FAIL: The service principal uses client secrets and has a permanent assignment
|
||||
to at least one Tier 0 directory role.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportM365]:
|
||||
"""Execute the service principal secret management check.
|
||||
|
||||
Iterates over service principals and identifies those that combine password
|
||||
credentials (client secrets) with permanent Tier 0 directory role assignments.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the result of the check for each service principal.
|
||||
"""
|
||||
findings = []
|
||||
|
||||
for sp in entra_client.service_principals.values():
|
||||
report = CheckReportM365(
|
||||
metadata=self.metadata(),
|
||||
resource=sp,
|
||||
resource_name=sp.name,
|
||||
resource_id=sp.id,
|
||||
)
|
||||
|
||||
active_secrets = [
|
||||
credential
|
||||
for credential in sp.password_credentials
|
||||
if credential.is_active()
|
||||
]
|
||||
has_secrets = len(active_secrets) > 0
|
||||
tier0_roles = [
|
||||
role_id
|
||||
for role_id in sp.directory_role_template_ids
|
||||
if role_id in TIER_0_ROLE_TEMPLATE_IDS
|
||||
]
|
||||
|
||||
if has_secrets and tier0_roles:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Service principal '{sp.name}' uses client secrets and has "
|
||||
f"permanent assignment to {len(tier0_roles)} Control Plane "
|
||||
f"(Tier 0) directory role(s)."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
if not has_secrets and not tier0_roles:
|
||||
report.status_extended = (
|
||||
f"Service principal '{sp.name}' does not use client secrets "
|
||||
f"and has no Tier 0 directory role assignments."
|
||||
)
|
||||
elif not has_secrets:
|
||||
report.status_extended = (
|
||||
f"Service principal '{sp.name}' does not use client secrets."
|
||||
)
|
||||
else:
|
||||
report.status_extended = (
|
||||
f"Service principal '{sp.name}' has no permanent Tier 0 "
|
||||
f"directory role assignments."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from prowler.providers.m365.services.entra.entra_service import (
|
||||
KeyCredential,
|
||||
PasswordCredential,
|
||||
ServicePrincipal,
|
||||
)
|
||||
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
|
||||
|
||||
|
||||
class Test_entra_service_principal_no_secrets_for_permanent_tier0_roles:
|
||||
"""Tests for the entra_service_principal_no_secrets_for_permanent_tier0_roles check."""
|
||||
|
||||
def test_no_service_principals(self):
|
||||
"""No service principals configured: expected no findings."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
|
||||
entra_service_principal_no_secrets_for_permanent_tier0_roles,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {}
|
||||
|
||||
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
def test_service_principal_no_secrets_no_roles(self):
|
||||
"""Service principal without secrets and no Tier 0 roles: expected PASS."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id = str(uuid4())
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
|
||||
entra_service_principal_no_secrets_for_permanent_tier0_roles,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="TestApp",
|
||||
app_id=str(uuid4()),
|
||||
password_credentials=[],
|
||||
key_credentials=[
|
||||
KeyCredential(key_id=str(uuid4()), display_name="cert1")
|
||||
],
|
||||
directory_role_template_ids=[],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "does not use client secrets" in result[0].status_extended
|
||||
assert result[0].resource_id == sp_id
|
||||
assert result[0].resource_name == "TestApp"
|
||||
|
||||
def test_service_principal_with_secrets_no_tier0_roles(self):
|
||||
"""Service principal with secrets but no Tier 0 roles: expected PASS."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id = str(uuid4())
|
||||
non_tier0_role = "4a5d8f65-41da-4de4-8968-e035b65339cf" # Reports Reader
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
|
||||
entra_service_principal_no_secrets_for_permanent_tier0_roles,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="AppWithSecrets",
|
||||
app_id=str(uuid4()),
|
||||
password_credentials=[
|
||||
PasswordCredential(key_id=str(uuid4()), display_name="secret1")
|
||||
],
|
||||
key_credentials=[],
|
||||
directory_role_template_ids=[non_tier0_role],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "no permanent Tier 0" in result[0].status_extended
|
||||
assert result[0].resource_id == sp_id
|
||||
|
||||
def test_service_principal_no_secrets_with_tier0_roles(self):
|
||||
"""Service principal without secrets but with Tier 0 roles: expected PASS."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id = str(uuid4())
|
||||
global_admin_role = "62e90394-69f5-4237-9190-012177145e10"
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
|
||||
entra_service_principal_no_secrets_for_permanent_tier0_roles,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="CertBasedApp",
|
||||
app_id=str(uuid4()),
|
||||
password_credentials=[],
|
||||
key_credentials=[
|
||||
KeyCredential(key_id=str(uuid4()), display_name="cert1")
|
||||
],
|
||||
directory_role_template_ids=[global_admin_role],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "does not use client secrets" in result[0].status_extended
|
||||
assert result[0].resource_id == sp_id
|
||||
|
||||
def test_service_principal_with_secrets_and_tier0_role(self):
|
||||
"""Service principal with secrets and Tier 0 role: expected FAIL."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id = str(uuid4())
|
||||
global_admin_role = "62e90394-69f5-4237-9190-012177145e10"
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
|
||||
entra_service_principal_no_secrets_for_permanent_tier0_roles,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="VulnerableApp",
|
||||
app_id=str(uuid4()),
|
||||
password_credentials=[
|
||||
PasswordCredential(key_id=str(uuid4()), display_name="secret1")
|
||||
],
|
||||
key_credentials=[],
|
||||
directory_role_template_ids=[global_admin_role],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "uses client secrets" in result[0].status_extended
|
||||
assert "Control Plane" in result[0].status_extended
|
||||
assert result[0].resource_id == sp_id
|
||||
assert result[0].resource_name == "VulnerableApp"
|
||||
|
||||
def test_service_principal_with_secrets_and_multiple_tier0_roles(self):
|
||||
"""Service principal with secrets and multiple Tier 0 roles: expected FAIL."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id = str(uuid4())
|
||||
global_admin_role = "62e90394-69f5-4237-9190-012177145e10"
|
||||
priv_role_admin = "e8611ab8-c189-46e8-94e1-60213ab1f814"
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
|
||||
entra_service_principal_no_secrets_for_permanent_tier0_roles,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="HighRiskApp",
|
||||
app_id=str(uuid4()),
|
||||
password_credentials=[
|
||||
PasswordCredential(key_id=str(uuid4()), display_name="secret1"),
|
||||
PasswordCredential(key_id=str(uuid4()), display_name="secret2"),
|
||||
],
|
||||
key_credentials=[],
|
||||
directory_role_template_ids=[
|
||||
global_admin_role,
|
||||
priv_role_admin,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "2 Control Plane" in result[0].status_extended
|
||||
|
||||
def test_multiple_service_principals_mixed(self):
|
||||
"""Multiple service principals with mixed states: mixed results."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id_pass = str(uuid4())
|
||||
sp_id_fail = str(uuid4())
|
||||
global_admin_role = "62e90394-69f5-4237-9190-012177145e10"
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
|
||||
entra_service_principal_no_secrets_for_permanent_tier0_roles,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id_pass: ServicePrincipal(
|
||||
id=sp_id_pass,
|
||||
name="SafeApp",
|
||||
app_id=str(uuid4()),
|
||||
password_credentials=[],
|
||||
key_credentials=[
|
||||
KeyCredential(key_id=str(uuid4()), display_name="cert1")
|
||||
],
|
||||
directory_role_template_ids=[global_admin_role],
|
||||
),
|
||||
sp_id_fail: ServicePrincipal(
|
||||
id=sp_id_fail,
|
||||
name="UnsafeApp",
|
||||
app_id=str(uuid4()),
|
||||
password_credentials=[
|
||||
PasswordCredential(key_id=str(uuid4()), display_name="secret1")
|
||||
],
|
||||
key_credentials=[],
|
||||
directory_role_template_ids=[global_admin_role],
|
||||
),
|
||||
}
|
||||
|
||||
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 2
|
||||
statuses = {r.resource_id: r.status for r in result}
|
||||
assert statuses[sp_id_pass] == "PASS"
|
||||
assert statuses[sp_id_fail] == "FAIL"
|
||||
|
||||
def test_service_principal_only_expired_secrets_with_tier0_role(self):
|
||||
"""Service principal whose only client secrets are expired: expected PASS."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id = str(uuid4())
|
||||
global_admin_role = "62e90394-69f5-4237-9190-012177145e10"
|
||||
expired = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
|
||||
entra_service_principal_no_secrets_for_permanent_tier0_roles,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="LegacyApp",
|
||||
app_id=str(uuid4()),
|
||||
password_credentials=[
|
||||
PasswordCredential(
|
||||
key_id=str(uuid4()),
|
||||
display_name="expired-secret",
|
||||
end_date_time=expired,
|
||||
)
|
||||
],
|
||||
key_credentials=[],
|
||||
directory_role_template_ids=[global_admin_role],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert "does not use client secrets" in result[0].status_extended
|
||||
|
||||
def test_service_principal_active_and_expired_secrets_with_tier0_role(self):
|
||||
"""Service principal with at least one active client secret: expected FAIL."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id = str(uuid4())
|
||||
global_admin_role = "62e90394-69f5-4237-9190-012177145e10"
|
||||
expired = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
future = datetime.now(timezone.utc) + timedelta(days=180)
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=set_mocked_m365_provider(),
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_no_secrets_for_permanent_tier0_roles.entra_service_principal_no_secrets_for_permanent_tier0_roles import (
|
||||
entra_service_principal_no_secrets_for_permanent_tier0_roles,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="MixedSecretsApp",
|
||||
app_id=str(uuid4()),
|
||||
password_credentials=[
|
||||
PasswordCredential(
|
||||
key_id=str(uuid4()),
|
||||
display_name="old",
|
||||
end_date_time=expired,
|
||||
),
|
||||
PasswordCredential(
|
||||
key_id=str(uuid4()),
|
||||
display_name="current",
|
||||
end_date_time=future,
|
||||
),
|
||||
],
|
||||
key_credentials=[],
|
||||
directory_role_template_ids=[global_admin_role],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_no_secrets_for_permanent_tier0_roles()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "uses client secrets" in result[0].status_extended
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -684,3 +685,159 @@ class Test_Entra_Service:
|
||||
registration_builder.get.assert_awaited()
|
||||
registration_builder.with_url.assert_called_once_with("next-link")
|
||||
registration_builder_next.get.assert_awaited()
|
||||
|
||||
def test__get_service_principals_filters_third_party_owners(self):
|
||||
"""Service principals owned by another tenant must not be returned."""
|
||||
# Mixed-case input to verify the service normalizes both sides before
|
||||
# comparison, so a Graph response that returns the owner id in upper
|
||||
# case still matches the lower-case identity in the provider.
|
||||
tenant_id_in = "AAAAAAAA-1111-1111-1111-111111111111"
|
||||
tenant_id_lower = tenant_id_in.lower()
|
||||
microsoft_tenant_id = "f8cdef31-a31e-4b4a-93e4-5f571e91255a"
|
||||
|
||||
owned_sp = SimpleNamespace(
|
||||
id="sp-owned",
|
||||
display_name="Customer App",
|
||||
app_id="app-owned",
|
||||
app_owner_organization_id=tenant_id_in,
|
||||
password_credentials=[
|
||||
SimpleNamespace(
|
||||
key_id="cred-1",
|
||||
display_name="secret",
|
||||
end_date_time=None,
|
||||
)
|
||||
],
|
||||
key_credentials=[],
|
||||
)
|
||||
first_party_sp = SimpleNamespace(
|
||||
id="sp-first-party",
|
||||
display_name="Microsoft Graph",
|
||||
app_id="app-graph",
|
||||
app_owner_organization_id=microsoft_tenant_id,
|
||||
password_credentials=[
|
||||
SimpleNamespace(
|
||||
key_id="cred-2",
|
||||
display_name="secret",
|
||||
end_date_time=None,
|
||||
)
|
||||
],
|
||||
key_credentials=[],
|
||||
)
|
||||
third_party_sp = SimpleNamespace(
|
||||
id="sp-third-party",
|
||||
display_name="Some Vendor App",
|
||||
app_id="app-vendor",
|
||||
app_owner_organization_id="22222222-2222-2222-2222-222222222222",
|
||||
password_credentials=[],
|
||||
key_credentials=[],
|
||||
)
|
||||
|
||||
sp_response = SimpleNamespace(
|
||||
value=[owned_sp, first_party_sp, third_party_sp],
|
||||
odata_next_link=None,
|
||||
)
|
||||
|
||||
empty_assignments_response = SimpleNamespace(value=[], odata_next_link=None)
|
||||
|
||||
role_assignments_builder = SimpleNamespace(
|
||||
get=AsyncMock(return_value=empty_assignments_response)
|
||||
)
|
||||
role_management_builder = SimpleNamespace(
|
||||
directory=SimpleNamespace(
|
||||
role_assignments=role_assignments_builder,
|
||||
)
|
||||
)
|
||||
|
||||
service_principals_builder = SimpleNamespace(
|
||||
get=AsyncMock(return_value=sp_response),
|
||||
with_url=MagicMock(),
|
||||
)
|
||||
|
||||
# The /applications endpoint returns no entries for this case, so the
|
||||
# service-level test just exercises the customer-owned filter, not the
|
||||
# secret join.
|
||||
applications_response = SimpleNamespace(value=[], odata_next_link=None)
|
||||
applications_builder = SimpleNamespace(
|
||||
get=AsyncMock(return_value=applications_response),
|
||||
with_url=MagicMock(),
|
||||
)
|
||||
|
||||
entra_service = Entra.__new__(Entra)
|
||||
entra_service.tenant_id = tenant_id_lower
|
||||
entra_service.client = SimpleNamespace(
|
||||
service_principals=service_principals_builder,
|
||||
role_management=role_management_builder,
|
||||
applications=applications_builder,
|
||||
)
|
||||
|
||||
result = asyncio.run(entra_service._get_service_principals())
|
||||
|
||||
assert set(result.keys()) == {"sp-owned"}
|
||||
assert result["sp-owned"].app_owner_organization_id == tenant_id_lower
|
||||
|
||||
def test__get_service_principals_merges_application_credentials(self):
|
||||
"""Secrets registered on the parent Application must be attributed to the SP."""
|
||||
tenant_id = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
# SP returned by Graph with NO password_credentials of its own (the
|
||||
# common case in production when the secret was added through "App
|
||||
# registrations > Certificates & secrets").
|
||||
sp_without_sp_level_secret = SimpleNamespace(
|
||||
id="sp-owned",
|
||||
display_name="m365-dev",
|
||||
app_id="app-owned",
|
||||
app_owner_organization_id=tenant_id,
|
||||
password_credentials=[],
|
||||
key_credentials=[],
|
||||
)
|
||||
sp_response = SimpleNamespace(
|
||||
value=[sp_without_sp_level_secret], odata_next_link=None
|
||||
)
|
||||
|
||||
# The corresponding Application carries the actual secret.
|
||||
future = datetime(2099, 1, 1, tzinfo=timezone.utc)
|
||||
application = SimpleNamespace(
|
||||
id="app-object",
|
||||
app_id="app-owned",
|
||||
password_credentials=[
|
||||
SimpleNamespace(
|
||||
key_id="cred-app",
|
||||
display_name="app-level-secret",
|
||||
end_date_time=future,
|
||||
),
|
||||
],
|
||||
key_credentials=[],
|
||||
)
|
||||
applications_response = SimpleNamespace(
|
||||
value=[application], odata_next_link=None
|
||||
)
|
||||
|
||||
empty_assignments_response = SimpleNamespace(value=[], odata_next_link=None)
|
||||
|
||||
entra_service = Entra.__new__(Entra)
|
||||
entra_service.tenant_id = tenant_id
|
||||
entra_service.client = SimpleNamespace(
|
||||
service_principals=SimpleNamespace(
|
||||
get=AsyncMock(return_value=sp_response),
|
||||
with_url=MagicMock(),
|
||||
),
|
||||
applications=SimpleNamespace(
|
||||
get=AsyncMock(return_value=applications_response),
|
||||
with_url=MagicMock(),
|
||||
),
|
||||
role_management=SimpleNamespace(
|
||||
directory=SimpleNamespace(
|
||||
role_assignments=SimpleNamespace(
|
||||
get=AsyncMock(return_value=empty_assignments_response),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
result = asyncio.run(entra_service._get_service_principals())
|
||||
|
||||
merged = result["sp-owned"]
|
||||
assert len(merged.password_credentials) == 1
|
||||
assert merged.password_credentials[0].key_id == "cred-app"
|
||||
assert merged.password_credentials[0].display_name == "app-level-secret"
|
||||
assert merged.password_credentials[0].is_active()
|
||||
|
||||
Reference in New Issue
Block a user