mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
feat(m365): add entra_service_prinicipal_privileged_role_no_owners_check (#11189)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
This commit is contained in:
co-authored by
Daniel Barranquero
parent
62955dd16b
commit
7e60e8f8da
@@ -15,6 +15,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- `elbv2_alb_drop_invalid_header_fields_enabled` check for AWS provider, verifying Application Load Balancers have `routing.http.drop_invalid_header_fields.enabled` set to `true` to mitigate HTTP desync attacks (AWS FSBP ELB.4) [(#11471)](https://github.com/prowler-cloud/prowler/pull/11471)
|
||||
- `user`, `systemlog` and `idp` service for Okta provider with `user_inactivity_automation_35d_enabled`, `systemlog_streaming_enabled` and `idp_smart_card_dod_approved_ca` checks [(#11496)](https://github.com/prowler-cloud/prowler/pull/11496)
|
||||
- AWS AI Security Framework support in the CLI dashboard [(#11475)](https://github.com/prowler-cloud/prowler/pull/11475)
|
||||
- `entra_service_principal_privileged_role_no_owners` check for M365 provider, failing when a service principal with a permanent Tier 0 directory role has owners on the service principal or its parent app registration [(#11070)](https://github.com/prowler-cloud/prowler/issues/11070)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
|
||||
@@ -1197,6 +1197,10 @@ OAuthAppInfo
|
||||
service_principals_by_app_id = {
|
||||
sp.app_id: sp for sp in service_principals.values() if sp.app_id
|
||||
}
|
||||
# Remember each SP's parent application object ID so the owner
|
||||
# lookup below can address it directly without re-walking
|
||||
# /applications.
|
||||
application_object_id_by_sp_id: Dict[str, str] = {}
|
||||
app_response = await self.client.applications.get()
|
||||
while app_response:
|
||||
for app in getattr(app_response, "value", []) or []:
|
||||
@@ -1207,6 +1211,10 @@ OAuthAppInfo
|
||||
if target_sp is None:
|
||||
continue
|
||||
|
||||
app_object_id = getattr(app, "id", None)
|
||||
if app_object_id:
|
||||
application_object_id_by_sp_id[target_sp.id] = app_object_id
|
||||
|
||||
for cred in getattr(app, "password_credentials", []) or []:
|
||||
target_sp.password_credentials.append(
|
||||
PasswordCredential(
|
||||
@@ -1257,6 +1265,49 @@ OAuthAppInfo
|
||||
next_link
|
||||
).get()
|
||||
|
||||
# Resolve owners only for service principals that hold a permanent
|
||||
# Tier 0 directory role. Owner ownership of the SP object or its
|
||||
# parent app registration is a credential-rotation escalation path
|
||||
# outside PIM and Conditional Access; fetching owners for every
|
||||
# consented SP would multiply Graph traffic for no benefit.
|
||||
for sp in service_principals.values():
|
||||
if not sp.directory_role_template_ids:
|
||||
continue
|
||||
try:
|
||||
sp_owners_response = (
|
||||
await self.client.service_principals.by_service_principal_id(
|
||||
sp.id
|
||||
).owners.get()
|
||||
)
|
||||
sp.sp_owner_ids = [
|
||||
getattr(owner, "id", None)
|
||||
for owner in (getattr(sp_owners_response, "value", []) or [])
|
||||
if getattr(owner, "id", None)
|
||||
]
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
app_object_id = application_object_id_by_sp_id.get(sp.id)
|
||||
if not app_object_id:
|
||||
continue
|
||||
try:
|
||||
app_owners_response = (
|
||||
await self.client.applications.by_application_id(
|
||||
app_object_id
|
||||
).owners.get()
|
||||
)
|
||||
sp.app_owner_ids = [
|
||||
getattr(owner, "id", None)
|
||||
for owner in (getattr(app_owners_response, "value", []) or [])
|
||||
if getattr(owner, "id", None)
|
||||
]
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
@@ -1436,7 +1487,7 @@ class PlatformConditions(BaseModel):
|
||||
|
||||
@validator("include_platforms", "exclude_platforms", pre=True)
|
||||
@classmethod
|
||||
def normalize_platforms(cls, values):
|
||||
def normalize_platforms(cls, values): # noqa: vulture
|
||||
if not values:
|
||||
return []
|
||||
|
||||
@@ -1832,6 +1883,12 @@ class ServicePrincipal(BaseModel):
|
||||
key_credentials: List of key credentials (certificates).
|
||||
directory_role_template_ids: List of directory role template IDs permanently
|
||||
assigned to this service principal.
|
||||
sp_owner_ids: Principal IDs that own the service principal object.
|
||||
Populated only for service principals that hold a permanent Tier 0
|
||||
directory role assignment, to keep Graph traffic bounded.
|
||||
app_owner_ids: Principal IDs that own the parent app registration.
|
||||
Populated only for service principals that hold a permanent Tier 0
|
||||
directory role assignment.
|
||||
"""
|
||||
|
||||
id: str
|
||||
@@ -1841,6 +1898,8 @@ class ServicePrincipal(BaseModel):
|
||||
password_credentials: List[PasswordCredential] = []
|
||||
key_credentials: List[KeyCredential] = []
|
||||
directory_role_template_ids: List[str] = []
|
||||
sp_owner_ids: List[str] = []
|
||||
app_owner_ids: List[str] = []
|
||||
|
||||
|
||||
class AppRegistration(BaseModel):
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"Provider": "m365",
|
||||
"CheckID": "entra_service_principal_privileged_role_no_owners",
|
||||
"CheckTitle": "Service principals with privileged Entra directory roles must have no owners",
|
||||
"CheckType": [],
|
||||
"ServiceName": "entra",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "critical",
|
||||
"ResourceType": "NotDefined",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "Microsoft Entra **service principals** holding permanent **Control Plane (Tier 0)** directory roles (such as **Global Administrator** or **Privileged Role Administrator**) are evaluated for the presence of **owners** on either the service principal itself or its parent **app registration**.",
|
||||
"Risk": "An **owner** of a service principal or its parent app registration can **rotate credentials** and sign in as the service principal, inheriting its **Tier 0** role outside **PIM** and **Conditional Access** controls. This is a documented privilege escalation path impacting **confidentiality**, **integrity**, and **availability** of the tenant's control plane.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://learn.microsoft.com/en-us/graph/api/serviceprincipal-list-owners?view=graph-rest-1.0",
|
||||
"https://learn.microsoft.com/en-us/graph/api/application-list-owners?view=graph-rest-1.0",
|
||||
"https://learn.microsoft.com/en-us/graph/api/rbacapplication-list-roleassignments?view=graph-rest-1.0"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Microsoft Entra admin center (https://entra.microsoft.com)\n2. Go to Identity > Applications > Enterprise applications > select the service principal\n3. Under Owners, remove all owners\n4. Repeat for the parent App Registration under Identity > Applications > App registrations\n5. Use PIM eligible assignments instead of permanent role assignments where possible",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Remove all owners from service principals that hold privileged Entra directory roles. Manage privileged service principals exclusively via PIM-eligible role assignments and break-glass controls. Ensure no human account can silently inherit control-plane privileges through ownership.",
|
||||
"Url": "https://hub.prowler.com/check/entra_service_principal_privileged_role_no_owners"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"entra_service_principal_no_secrets_for_permanent_tier0_roles"
|
||||
],
|
||||
"Notes": "Only service principals with permanent Tier 0 directory role assignments are evaluated. Microsoft first-party service principals and multi-tenant ISV apps consented from other publishers are excluded by the service layer."
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
"""Check for service principals with privileged roles that have owners."""
|
||||
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportM365
|
||||
from prowler.providers.m365.services.entra.entra_client import entra_client
|
||||
|
||||
|
||||
class entra_service_principal_privileged_role_no_owners(Check):
|
||||
"""Service principal with a permanent Tier 0 directory role has no owners.
|
||||
|
||||
Owners of a service principal or its parent app registration can rotate
|
||||
credentials and sign in as the service principal, inheriting its privileged
|
||||
directory role outside PIM approval flows and Conditional Access policies
|
||||
targeting user accounts.
|
||||
|
||||
- PASS: The service principal does not hold a permanent Tier 0 directory
|
||||
role, or it does but has zero owners on both the service principal and
|
||||
its parent app registration.
|
||||
- FAIL: The service principal holds a permanent Tier 0 directory role and
|
||||
has at least one owner on either the service principal or its parent
|
||||
app registration.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportM365]:
|
||||
"""Execute the privileged service principal owner check.
|
||||
|
||||
Returns:
|
||||
A list of reports, one per service principal owned by the audited
|
||||
tenant.
|
||||
"""
|
||||
findings = []
|
||||
for sp in entra_client.service_principals.values():
|
||||
report = CheckReportM365(
|
||||
metadata=self.metadata(),
|
||||
resource=sp,
|
||||
resource_name=sp.name,
|
||||
resource_id=sp.id,
|
||||
)
|
||||
|
||||
if not sp.directory_role_template_ids:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Service principal '{sp.name}' has no permanent Tier 0 "
|
||||
f"directory role assignments."
|
||||
)
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
unique_owners = set(sp.sp_owner_ids) | set(sp.app_owner_ids)
|
||||
tier0_role_count = len(sp.directory_role_template_ids)
|
||||
|
||||
if unique_owners:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Service principal '{sp.name}' holds {tier0_role_count} "
|
||||
f"permanent Tier 0 directory role(s) and has "
|
||||
f"{len(unique_owners)} owner(s) "
|
||||
f"({len(sp.sp_owner_ids)} on the service principal, "
|
||||
f"{len(sp.app_owner_ids)} on the parent app registration)."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Service principal '{sp.name}' holds {tier0_role_count} "
|
||||
f"permanent Tier 0 directory role(s) and has no owners on "
|
||||
f"either the service principal or its parent app registration."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
return findings
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from prowler.providers.m365.services.entra.entra_service import ServicePrincipal
|
||||
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
|
||||
|
||||
GLOBAL_ADMIN_ROLE = "62e90394-69f5-4237-9190-012177145e10"
|
||||
PRIV_ROLE_ADMIN = "e8611ab8-c189-46e8-94e1-60213ab1f814"
|
||||
|
||||
|
||||
class Test_entra_service_principal_privileged_role_no_owners:
|
||||
"""Tests for the entra_service_principal_privileged_role_no_owners 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_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
|
||||
entra_service_principal_privileged_role_no_owners,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {}
|
||||
|
||||
check = entra_service_principal_privileged_role_no_owners()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
def test_service_principal_no_tier0_roles(self):
|
||||
"""Service principal without 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_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
|
||||
entra_service_principal_privileged_role_no_owners,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="NonPrivilegedApp",
|
||||
app_id=str(uuid4()),
|
||||
directory_role_template_ids=[],
|
||||
sp_owner_ids=[],
|
||||
app_owner_ids=[],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_privileged_role_no_owners()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert result[0].resource_id == sp_id
|
||||
assert result[0].resource_name == "NonPrivilegedApp"
|
||||
assert (
|
||||
"no permanent Tier 0 directory role assignments"
|
||||
in result[0].status_extended
|
||||
)
|
||||
|
||||
def test_service_principal_tier0_no_owners(self):
|
||||
"""Privileged SP with no owners on SP or app: 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_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
|
||||
entra_service_principal_privileged_role_no_owners,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="SecureApp",
|
||||
app_id=str(uuid4()),
|
||||
directory_role_template_ids=[GLOBAL_ADMIN_ROLE],
|
||||
sp_owner_ids=[],
|
||||
app_owner_ids=[],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_privileged_role_no_owners()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "PASS"
|
||||
assert result[0].resource_id == sp_id
|
||||
assert result[0].resource_name == "SecureApp"
|
||||
assert "no owners" in result[0].status_extended
|
||||
|
||||
def test_service_principal_tier0_with_sp_owners(self):
|
||||
"""Privileged SP with owners on SP only: expected FAIL."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id = str(uuid4())
|
||||
owner_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_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
|
||||
entra_service_principal_privileged_role_no_owners,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="RiskyApp",
|
||||
app_id=str(uuid4()),
|
||||
directory_role_template_ids=[GLOBAL_ADMIN_ROLE],
|
||||
sp_owner_ids=[owner_id],
|
||||
app_owner_ids=[],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_privileged_role_no_owners()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert result[0].resource_id == sp_id
|
||||
assert "1 owner(s)" in result[0].status_extended
|
||||
assert "1 on the service principal" in result[0].status_extended
|
||||
assert "0 on the parent app registration" in result[0].status_extended
|
||||
|
||||
def test_service_principal_tier0_with_app_owners(self):
|
||||
"""Privileged SP with owners on parent app only: expected FAIL."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id = str(uuid4())
|
||||
app_owner_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_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
|
||||
entra_service_principal_privileged_role_no_owners,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="AppRegOwnerRisk",
|
||||
app_id=str(uuid4()),
|
||||
directory_role_template_ids=[GLOBAL_ADMIN_ROLE],
|
||||
sp_owner_ids=[],
|
||||
app_owner_ids=[app_owner_id],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_privileged_role_no_owners()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "1 owner(s)" in result[0].status_extended
|
||||
assert "0 on the service principal" in result[0].status_extended
|
||||
assert "1 on the parent app registration" in result[0].status_extended
|
||||
|
||||
def test_service_principal_tier0_with_both_owners(self):
|
||||
"""Privileged SP with distinct owners on both SP and app: expected FAIL."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id = str(uuid4())
|
||||
sp_owner_id = str(uuid4())
|
||||
app_owner_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_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
|
||||
entra_service_principal_privileged_role_no_owners,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="HighRiskApp",
|
||||
app_id=str(uuid4()),
|
||||
directory_role_template_ids=[GLOBAL_ADMIN_ROLE, PRIV_ROLE_ADMIN],
|
||||
sp_owner_ids=[sp_owner_id],
|
||||
app_owner_ids=[app_owner_id],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_privileged_role_no_owners()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "2 permanent Tier 0 directory role(s)" in result[0].status_extended
|
||||
assert "2 owner(s)" in result[0].status_extended
|
||||
|
||||
def test_service_principal_tier0_same_owner_on_sp_and_app(self):
|
||||
"""Same principal owns both SP and parent app: owner count deduplicated."""
|
||||
entra_client = mock.MagicMock
|
||||
entra_client.audited_tenant = "audited_tenant"
|
||||
entra_client.audited_domain = DOMAIN
|
||||
|
||||
sp_id = str(uuid4())
|
||||
shared_owner_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_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners.entra_client",
|
||||
new=entra_client,
|
||||
),
|
||||
):
|
||||
from prowler.providers.m365.services.entra.entra_service_principal_privileged_role_no_owners.entra_service_principal_privileged_role_no_owners import (
|
||||
entra_service_principal_privileged_role_no_owners,
|
||||
)
|
||||
|
||||
entra_client.service_principals = {
|
||||
sp_id: ServicePrincipal(
|
||||
id=sp_id,
|
||||
name="DualOwnedApp",
|
||||
app_id=str(uuid4()),
|
||||
directory_role_template_ids=[GLOBAL_ADMIN_ROLE],
|
||||
sp_owner_ids=[shared_owner_id],
|
||||
app_owner_ids=[shared_owner_id],
|
||||
)
|
||||
}
|
||||
|
||||
check = entra_service_principal_privileged_role_no_owners()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert "1 owner(s)" in result[0].status_extended
|
||||
assert "1 on the service principal" in result[0].status_extended
|
||||
assert "1 on the parent app registration" in result[0].status_extended
|
||||
Reference in New Issue
Block a user