Flow Configuration Guide¶
Documentation-only — These instructions describe how to manually build each flow in Power Automate designer. This solution does not include exported flow JSON files.
Overview¶
This guide provides step-by-step instructions for building the six Cross-Tenant External Sharing Governance (CTSG) Power Automate flows. Together, these flows validate tenant isolation settings, detect unauthorized external agent shares, audit Entra cross-tenant access policies, manage external tenant onboarding with dual-approval, remediate unauthorized access, and drive annual review cycles.
Flows to Build:
- Validate-TenantIsolation-Daily (Scheduled Cloud Flow)
- Detect-ExternalAgentShares-Daily (Scheduled Cloud Flow)
- Audit-EntraCrossTenantSettings-Weekly (Scheduled Cloud Flow)
- Execute-ExternalTenantOnboarding (Instant Cloud Flow)
- Remediate-UnauthorizedExternalAccess (Instant Cloud Flow)
- Send-AnnualReviewReminders-Daily (Scheduled Cloud Flow)
⚠️ Option Set Value Reference¶
All choice/option-set fields in OData expressions use integer values, not string labels. The schema source of truth is scripts/create_ctsg_dataverse_schema.py. All fsi_ctsg_* option sets use the Dataverse-default 100000000-based encoding (migrated from 0-based in v1.1.0; see CHANGELOG migration notes). The shared fsi_acv_zone option set retains its legacy 0-based encoding for cross-solution compatibility. Key mappings:
| Option Set | Values |
|---|---|
fsi_ctsg_approvalstatus |
100000000=Pending, 100000001=Approved, 100000002=Expired, 100000003=Suspended, 100000004=Revoked |
fsi_ctsg_findingstatus |
100000000=Open, 100000001=Under Review, 100000002=Remediated, 100000003=Approved Exception, 100000004=False Positive |
fsi_ctsg_severity |
100000000=Critical, 100000001=High, 100000002=Medium, 100000003=Low |
fsi_ctsg_findingtype |
100000000=Unapproved Tenant Isolation Exception, 100000001=Unapproved Guest Share, 100000002=Unapproved B2B Access, 100000003=Tenant Isolation Disabled, 100000004=Approved Tenant - Review Required |
fsi_ctsg_governancelayer |
100000000=Layer 1 (Tenant Isolation), 100000001=Layer 2 (Entra CTA), 100000002=Layer 3 (Agent Share) |
fsi_ctsg_remediationstatus |
100000000=Pending, 100000001=Approved for Auto-Remediation, 100000002=Manually Remediated, 100000003=Deferred |
fsi_ctsg_eventtype |
100000000–100000020 (see create_ctsg_dataverse_schema.py for full mapping) |
fsi_acv_zone (SHARED — 0-based) |
0=Unclassified, 1=Zone 1, 2=Zone 2, 3=Zone 3 |
Note for migrators: All integer literals shown in this document use the post-v1.1.0 (100000000-based) values. If you maintain custom flows from an earlier release, re-key picklist integers as described in CHANGELOG v1.1.0 migration notes.
Prerequisites¶
- All Dataverse tables deployed via
create_ctsg_dataverse_schema.py - All environment variables configured via
create_ctsg_environment_variables.py - All connection references created via
create_ctsg_connection_references.py - Two Managed Identities provisioned (see Prerequisites)
agent-registry-automationsolution deployed (providesfsi_agentinventory.fsi_zone)unrestricted-agent-sharing-detectorsolution deployed
Connections Required¶
| Connector | Purpose | License |
|---|---|---|
| Dataverse | Read/write governance tables | Included |
| HTTP with Microsoft Entra ID | Power Platform Admin API, Graph API, Entra CTA API calls | Premium |
| Microsoft Teams | Notifications, Adaptive Cards, daily summaries | Included |
| Approvals | Remediation approval, dual-approval tenant onboarding | Included |
| Office 365 Outlook | Fallback email notifications | Included |
Managed Identities¶
| Identity | Used By | Permissions |
|---|---|---|
MI-CrossTenantReadOnly |
Flows 1, 2, 3, 6 | Read-only access to PPAC API, Graph API, Entra CTA policies |
MI-CrossTenantReadWrite |
Flows 4, 5 | Read/write access to PPAC tenant isolation, Entra CTA policies, Graph role assignments |
Feature Flag¶
CRITICAL: Every flow must check the
fsi_CTSG_IsCrossTenantGovernanceEnabledenvironment variable as its first action. If the value is"false", the flow must log a skip event tofsi_crosstenantcomplianceeventwithfsi_eventtype=100000017(Feature Flag Skip) and terminate withCancelledstatus and message"Cross-Tenant Governance not enabled". This gate prevents API calls before the solution is fully configured.
Flow 1: Validate-TenantIsolation-Daily¶
Type: Scheduled Cloud Flow
Trigger: Recurrence — Daily at 04:00 AM UTC
Identity: MI-CrossTenantReadOnly
Purpose: Validate Power Platform tenant isolation is enabled and all allow-list entries are approved
Build Steps¶
- Create New Flow
- Power Automate → Cloud Flows → Scheduled Cloud Flow
- Flow name:
Validate-TenantIsolation-Daily -
Recurrence: Daily at 04:00 UTC
-
Check Feature Flag
- Action: Environment Variable — Get value
- Variable:
fsi_CTSG_IsCrossTenantGovernanceEnabled - Condition: Value equals
"false" - If true:
- Create record in
fsi_crosstenantcomplianceeventwithfsi_eventtype=100000017(Feature Flag Skip) - Terminate with status
Cancelled, message"Cross-Tenant Governance not enabled"
- Create record in
-
If false: Continue to Step 3
-
Initialize Variables
timestamp: ExpressionutcNow()runId: Expressionguid()findingsCount: Integer0-
tenantIsolationPolicyEnabled: Booleanfalse -
Validate Tenant Isolation Policy Schema
- Action: Power Platform tenant isolation validation (documented source of truth: Microsoft.PowerApps.Administration.PowerShell)
Current Microsoft Learn guidance documents tenant isolation configuration through PPAC and the PowerApps Administration PowerShell cmdlets:
Get-PowerAppTenantIsolationPolicy -TenantId <tenantId>
Set-PowerAppTenantIsolationPolicy -TenantId <tenantId> -TenantIsolationPolicy <policyObject>
For a Power Automate implementation, use a Power Platform for Admins action, custom connector, or HTTP action only after the Delivery Checklist confirms the live endpoint/action and response shape in your tenant. Do not hard-code legacy preview governance paths.
- Normalize the returned policy into:
tenantIsolationPolicyEnabled: Boolean tenant isolation statetenantIsolationRules: Array of allow-list entries- Per-entry fields: external tenant ID/domain and allowed direction (
Inbound,Outbound, orBoth)
- If schema validation fails:
- Send Teams alert to
fsi_CTSG_FlowAdministratorschannel:"API Schema Validation Failed — tenant isolation policy response did not match the confirmed checklist shape." - Create
fsi_crosstenantcomplianceeventwithfsi_eventtype=100000016(API Schema Validation Failed),fsi_eventdetails= sanitized response metadata - Terminate with status
Failed
- Send Teams alert to
- If true: Continue
Retry Policy:
{
"type": "exponential",
"count": 3,
"interval": "PT30S",
"minimumInterval": "PT10S",
"maximumInterval": "PT5M"
}
- Validate Tenant Isolation Rules
- Use the normalized
tenantIsolationRulesarray from Step 4. - Confirm each entry includes a tenant identifier and allowed direction.
-
If PPAC reports tenant isolation off, allow-list entries may exist but are not enforced until isolation is turned on.
-
Check Tenant Isolation Status
- Condition:
tenantIsolationPolicyEnabledequalsfalse - If true (isolation disabled):
- Set
findingsCount=findingsCount + 1 - Create
fsi_externalsharefindingrecord: fsi_findingtype:100000003(Tenant Isolation Disabled)fsi_severity:100000000(Critical)fsi_findingstatus:100000000(Open)fsi_detectedby:"Validate-TenantIsolation-Daily"
fsi_governancelayer:100000000(Layer 1 — Tenant Isolation)fsi_remediationstatus:100000000(Pending)fsi_detecteddate: Variabletimestampfsi_remediationnotes:"Power Platform tenant isolation is disabled. All external tenants can access resources without restriction."
- Trigger Flow 5 (
Remediate-UnauthorizedExternalAccess) with finding ID
- Set
-
If false: Continue
-
Get Approved Tenants
- Action: "List rows" (Dataverse)
- Table:
fsi_approvedexternaltenants - Filter:
fsi_approvalstatus eq 100000001 -
Select:
fsi_tenantid,fsi_tenantname,fsi_primarydomain,fsi_ppisolationdirection,fsi_approvalstatus -
Build Approved Tenant Index
- Action: Select — build a lookup array of approved tenant IDs for quick comparison
-
Map
fsi_tenantid→ full record (includingfsi_ppisolationdirection) -
For Each Allow-List Entry
- Action: Apply to each on tenant isolation rules from Step 5
- Concurrency: Set degree of parallelism to
1(sequential to avoid throttling)
9a. Check Against Approved Registry
- Condition: Current entry tenantId exists in approved tenant index
- If false (unapproved entry):
1. Increment findingsCount
2. Create fsi_externalsharefinding:
- fsi_findingtype: 100000000 (Unapproved Tenant Isolation Exception)
- fsi_severity: 100000001 (High)
- fsi_findingstatus: 100000000 (Open)
- fsi_detectedby: "Validate-TenantIsolation-Daily"
- fsi_governancelayer: 100000000 (Layer 1 — Tenant Isolation)
- fsi_remediationstatus: 100000000 (Pending)
- fsi_externaltenanttenantid: Entry tenantId
- fsi_remediationnotes: Expression concat('Tenant ', tenantId, ' found in PPAC allow-list but not in approved registry')
9b. Check Direction Match
- Condition: Entry direction matches approved record fsi_ppisolationdirection
- If false (direction mismatch):
1. Increment findingsCount
2. Create fsi_externalsharefinding:
- fsi_findingtype: 100000000 (Unapproved Tenant Isolation Exception)
- fsi_severity: 100000002 (Medium)
- fsi_findingstatus: 100000000 (Open)
- fsi_detectedby: "Validate-TenantIsolation-Daily"
- fsi_governancelayer: 100000000 (Layer 1 — Tenant Isolation)
- fsi_remediationstatus: 100000000 (Pending)
- fsi_remediationnotes: Expression concat('Approved direction: ', approvedDirection, '; Actual direction: ', actualDirection)
-
Create Tenant Isolation Record
- Action: "Create a new record" (Dataverse)
- Table:
fsi_tenantisolationrecord - Fields:
fsi_name: Expressionconcat('TI-Snapshot-', variables('runId'))fsi_isolationenabled: VariabletenantIsolationPolicyEnabledfsi_allowlistcount: Length of normalized tenant isolation rulesfsi_findingscreated: VariablefindingsCountfsi_allowlistsnapshot: Normalized tenant isolation policy and rules from Steps 4 and 5 serializedfsi_auditdate: Variabletimestamp
-
Log Compliance Event
- Condition:
findingsCountequals0 - If true:
- Create
fsi_crosstenantcomplianceeventwithfsi_eventtype=100000000(Tenant Isolation Validated),fsi_eventdetails="All allow-list entries match approved tenant registry" - If false:
- Create
fsi_crosstenantcomplianceeventwithfsi_eventtype=100000001(Tenant Isolation Violation),fsi_eventdetails= Expressionconcat(variables('findingsCount'), ' finding(s) detected')
- Condition:
Error Handling¶
Wrap Steps 4–11 in a Scope: Main Logic action. Add a parallel Scope: Catch configured with Run after → Failed, Timed out, Cancelled:
- Log error to
fsi_crosstenantcomplianceeventwithfsi_eventtype=100000018(Flow Error),fsi_eventdetails= error message - Send Teams alert to
fsi_CTSG_FlowAdministratorswith flow name, run ID, and error details - Terminate with status
Failed
Flow 2: Detect-ExternalAgentShares-Daily¶
Type: Scheduled Cloud Flow
Trigger: Recurrence — Daily at 05:00 AM UTC
Identity: MI-CrossTenantReadOnly
Purpose: Detect guest users with agent role assignments and create findings for unapproved external shares
Build Steps¶
- Create New Flow
- Power Automate → Cloud Flows → Scheduled Cloud Flow
- Flow name:
Detect-ExternalAgentShares-Daily -
Recurrence: Daily at 05:00 UTC
-
Check Feature Flag
-
Same pattern as Flow 1, Step 2
-
Initialize Variables
timestamp: ExpressionutcNow()runId: Expressionguid()findingsCount: Integer0guestsProcessed: Integer0internalDomains: Array (empty — populated in Step 4)-
guestUserIndex: Object (empty — populated in Step 5) -
Get Home Tenant Verified Domains
- Action: HTTP with Microsoft Entra ID
| Parameter | Value |
|---|---|
| Method | GET |
| Base Resource URL | https://graph.microsoft.com |
| Microsoft Entra ID Resource URI | https://graph.microsoft.com |
| URI | /v1.0/organization?$select=id,displayName,verifiedDomains |
-
Parse response to build
internalDomainsarray fromverifiedDomains[].name -
Get All Guest Users
- Action: HTTP with Microsoft Entra ID
| Parameter | Value |
|---|---|
| Method | GET |
| Base Resource URL | https://graph.microsoft.com |
| Microsoft Entra ID Resource URI | https://graph.microsoft.com |
| URI | /v1.0/users?$filter=userType eq 'Guest'&$select=id,displayName,mail,userPrincipalName,externalUserState,createdDateTime,creationType&$count=true |
Headers:
Pagination: The Graph API returns a maximum of 100 users per page. If
@odata.nextLinkis present in the response, loop with subsequent GET requests until all pages are retrieved.
Retry Policy:
{
"type": "exponential",
"count": 3,
"interval": "PT30S",
"minimumInterval": "PT10S",
"maximumInterval": "PT5M"
}
- For Each Guest User — Determine Home Tenant Domain
For each guest user, apply the 5-value detection method using a three-method fallback chain to resolve the guest's home tenant domain.
#### Method 1: EXT# UPN Parsing
- Condition:
userPrincipalNamecontains#EXT# - If true:
- Use the rightmost occurrence of
#EXT#in the UPN - Parse the substring before
#EXT#to extract the original UPN - Extract the domain from the original UPN (substring after the last underscore before
@) - Edge cases:
- Multiple underscores in original UPN → take the last underscore as the domain separator
- MSA guests (
live.com,outlook.com,hotmail.com) → setmethod1Domainto the extracted domain and flag"MSA origin"infsi_remediationnotes - UPN contains
#EXT#more than once → always use the rightmost occurrence
- If domain extracted successfully:
method1Domain= extracted domain - If extraction fails:
method1Domain=null
- Use the rightmost occurrence of
- If false:
method1Domain=null
#### Method 2: Mail Field
- Condition:
mailfield is not null and not empty - If true:
method2Domain= domain portion ofmailfield (substring after@)- Condition:
method2Domainis ininternalDomainsarray- If true: Discard — this is an internal user, not external. Skip to next guest.
- If false:
method2Domainis a candidate external domain
- If false:
method2Domain=null
#### Method 3: CreationType Confirmation
- Condition:
creationTypeequals"Invitation" - If true:
externalOriginConfirmed=true - If false:
externalOriginConfirmed=false - Note: Method 3 confirms external B2B origin but does not independently resolve a domain.
#### Determine homeTenantDomain and fsi_guestdetectionmethod
Use the following decision table to set the final values:
| Condition | homeTenantDomain |
fsi_guestdetectionmethod |
Notes |
|---|---|---|---|
method1Domain AND method2Domain both not null AND equal |
method1Domain |
100000003 (Multi-Method Agreed) |
Highest confidence |
method1Domain not null AND method2Domain null |
method1Domain |
100000000 (EXT# Parsing) |
— |
method1Domain null AND method2Domain not null |
method2Domain |
100000001 (Mail Field) |
— |
method1Domain AND method2Domain both not null AND NOT equal |
method2Domain |
100000001 (Mail Field) |
Append mismatch note to fsi_remediationnotes: concat('UPN domain mismatch: EXT#=', method1Domain, ' vs Mail=', method2Domain) |
Both null AND externalOriginConfirmed = true |
"Unknown" |
100000002 (CreationType) |
Append note: "B2B origin confirmed via creationType=Invitation but domain could not be resolved" |
| All methods failed | "Unknown" |
100000004 (Unresolved) |
Append note with all raw field values for manual review; assign conservative High severity |
-
Build
guestUserIndexmappingid→{ displayName, homeTenantDomain, detectionMethod, remediationNotes } -
Get Approved Tenants
- Action: "List rows" (Dataverse)
- Table:
fsi_approvedexternaltenants - Filter:
fsi_approvalstatus eq 100000001 -
Build
approvedTenantIndexkeyed by bothfsi_primarydomainandfsi_tenantid -
Get All Power Platform Environments
- Action: HTTP with Microsoft Entra ID
| Parameter | Value |
|---|---|
| Method | GET |
| Base Resource URL | https://api.powerplatform.com |
| Microsoft Entra ID Resource URI | https://api.powerplatform.com |
| URI | /environmentmanagement/environments?api-version=2024-10-01 |
- For Each Environment
- Action: Apply to each on environments from Step 8
- Concurrency: Set degree of parallelism to
5
9a. Get Agents in Environment
- Action: "List rows" (Dataverse) against the environment's bot table
(GET .../api/data/v9.2/bots?$select=botid,name,_owninguser_value) — Copilot Studio
agents are stored as Dataverse bot records.
9b. For Each Agent - Action: Apply to each on agents
9b-i. Get Agent Shares (record sharing)
- Action: HTTP with Microsoft Entra ID (Dataverse Web API)
- Call the documented RetrieveSharedPrincipalsAndAccess function on the agent's bot
record to list every user/team/organization the agent is shared with:
GET .../api/data/v9.2/RetrieveSharedPrincipalsAndAccess(Target=@t)?@t={'@odata.id':'bots(<botid>)'}
- Schema validation: Confirm the response PrincipalAccess array is present; each entry
exposes Principal (the shared user/team, whose id maps to principalId below) and
AccessMask. Ref:
https://learn.microsoft.com/power-apps/developer/data-platform/webapi/reference/retrievesharedprincipalsandaccess
9b-ii. For Each Shared Principal
- Condition:
principalId(thePrincipal.idfrom eachPrincipalAccessentry) exists inguestUserIndex -
If true (guest found):
-
Check Approved Status
- Look up
homeTenantDomainfromguestUserIndex - Condition:
homeTenantDomainexists inapprovedTenantIndex - If true: Skip — approved external share
- If false: Continue to create finding
- Look up
-
Deduplication Check
- Query
fsi_externalsharefindingsfor existing Open finding matching samefsi_agentid,fsi_externaltenanttenantid, andfsi_findingtype: - If match exists: Update
fsi_detecteddateto current timestamp, skip creation
- Query
-
Determine Severity from Agent Zone
- Query
fsi_agentinventoryfor agent'sfsi_zonevalue: - Apply severity mapping:
fsi_zoneValue (SHARED — 0-based)Severity Code 3(Zone 3)Critical 1000000002(Zone 2)High 1000000011(Zone 1)Medium 100000002nullor not foundHigh (conservative) 100000001 - Query
-
Create Finding
- Action: "Create a new record" (Dataverse)
- Table:
fsi_externalsharefinding - Fields:
fsi_findingtype:100000001(Unapproved Guest Share)fsi_severity: Severity code from zone mappingfsi_findingstatus:100000000(Open)fsi_detectedby:"Detect-ExternalAgentShares-Daily"fsi_governancelayer:100000002(Layer 3 — Agent Shares)fsi_remediationstatus:100000000(Pending)fsi_agentid: Agent GUIDfsi_agentname: Agent display namefsi_environmentid: Environment GUIDfsi_externaltenantname:homeTenantDomainfrom guest indexfsi_externaluserupn: GuestuserPrincipalNamefsi_guestdetectionmethod: Detection method integer code from Step 6 (100000000–100000004)fsi_detecteddate: Variabletimestampfsi_remediationnotes: Any accumulated notes from detection- Increment
findingsCount
-
Trigger Flow 5 with finding ID
-
-
Resolve External Tenant Names
- For each unique
homeTenantDomainin findings, attempt tenant name resolution: - Action: HTTP with Microsoft Entra ID
Parameter Value Method GETBase Resource URL https://graph.microsoft.comMicrosoft Entra ID Resource URI https://graph.microsoft.comURI /v1.0/tenantRelationships/findTenantInformationByDomainName(domainName='{domain}')- If resolution succeeds: Update finding records with resolved tenant name
- If resolution fails: Do not block — log warning and continue
- For each unique
-
Send Daily Summary
- Action: Microsoft Teams → Post Adaptive Card in a chat or channel
- Channel: Environment variable
fsi_CTSG_GovernanceTeamChannelId - Card: Daily scan summary Adaptive Card (see Teams Adaptive Card Templates — Template 1)
Error Handling¶
Wrap Steps 4–11 in a Scope: Main Logic. Add parallel Scope: Catch with Run after → Failed, Timed out, Cancelled:
- Log error to
fsi_crosstenantcomplianceevent - Send Teams alert with flow name, run ID, error details
- Terminate with status
Failed
Flow 3: Audit-EntraCrossTenantSettings-Weekly¶
Type: Scheduled Cloud Flow
Trigger: Recurrence — Every Monday at 06:00 AM UTC
Identity: MI-CrossTenantReadOnly
Purpose: Audit Entra ID cross-tenant access (CTA) settings against governance baseline and approved tenant registry
Build Steps¶
- Create New Flow
- Power Automate → Cloud Flows → Scheduled Cloud Flow
- Flow name:
Audit-EntraCrossTenantSettings-Weekly -
Recurrence: Week, Monday at 06:00 UTC
-
Check Feature Flag
-
Same pattern as Flow 1, Step 2
-
Initialize Variables
timestamp: ExpressionutcNow()runId: Expressionguid()findingsCount: Integer0baselineInboundBlocked: Environment variablefsi_CTSG_CTABaselineInboundB2BBlockedbaselineOutboundBlocked: Environment variablefsi_CTSG_CTABaselineOutboundB2BBlocked-
baselineDirectConnectBlocked: Environment variablefsi_CTSG_CTABaselineDirectConnectBlocked -
Get Default CTA Policy
- Action: HTTP with Microsoft Entra ID
| Parameter | Value |
|---|---|
| Method | GET |
| Base Resource URL | https://graph.microsoft.com |
| Microsoft Entra ID Resource URI | https://graph.microsoft.com |
| URI | /v1.0/policies/crossTenantAccessPolicy/default |
- Parse response to capture:
b2bCollaborationInboundsettingsb2bCollaborationOutboundsettingsb2bDirectConnectInboundsettingsb2bDirectConnectOutboundsettings
Retry Policy:
{
"type": "exponential",
"count": 3,
"interval": "PT30S",
"minimumInterval": "PT10S",
"maximumInterval": "PT5M"
}
- Evaluate Default Policy Against Baseline
Microsoft Graph v1.0 CTA settings do not expose a single isBlocked field. Normalize each B2B setting from usersAndGroups.accessType and applications.accessType:
inboundB2BBlocked: bothb2bCollaborationInbound.usersAndGroups.accessTypeandb2bCollaborationInbound.applications.accessTypeareblockedoutboundB2BBlocked: bothb2bCollaborationOutbound.usersAndGroups.accessTypeandb2bCollaborationOutbound.applications.accessTypeareblockeddirectConnectBlocked: inbound and outbound direct-connect user/group and application target configurations areblocked
5a. Check Inbound B2B
- Condition: inboundB2BBlocked does NOT match baselineInboundBlocked
- If true:
1. Increment findingsCount
2. Create fsi_externalsharefinding:
- fsi_findingtype: 100000002 (Unapproved B2B Access)
- fsi_severity: 100000002 (Medium)
- fsi_findingstatus: 100000000 (Open)
- fsi_detectedby: "Audit-EntraCrossTenantSettings-Weekly"
- fsi_governancelayer: 100000001 (Layer 2 — Entra CTA)
- fsi_remediationstatus: 100000000 (Pending)
- fsi_remediationnotes: Expression concat('Expected inbound B2B blocked=', baselineInboundBlocked, '; Actual accessType values=', serializedAccessTypes)
5b. Check Outbound B2B
- Same pattern — compare outboundB2BBlocked against baselineOutboundBlocked
- Finding type: "Unapproved B2B Access"
5c. Check Direct Connect
- Same pattern — compare directConnectBlocked against baselineDirectConnectBlocked
- Finding type: "Unapproved B2B Access"
5d. Check Default Inbound Trust
- Review inboundTrust.isMfaAccepted, inboundTrust.isCompliantDeviceAccepted, and inboundTrust.isHybridAzureADJoinedDeviceAccepted.
- If any trust flag is enabled outside an approved governance decision, create an Unapproved B2B Access finding with fsi_remediationnotes documenting the accepted claim.
5e. Check Automatic User Consent / Redemption
- Default automaticUserConsentSettings.inboundAllowed and outboundAllowed are read-only and always false in the default configuration.
- Partner policies can override automatic consent; evaluate partner-specific values in Step 8 and require explicit approval before allowing automatic redemption.
- Get All Partner CTA Policies
- Action: HTTP with Microsoft Entra ID
| Parameter | Value |
|---|---|
| Method | GET |
| Base Resource URL | https://graph.microsoft.com |
| Microsoft Entra ID Resource URI | https://graph.microsoft.com |
| URI | /v1.0/policies/crossTenantAccessPolicy/partners |
Pagination: Follow
@odata.nextLinkif present.
- Get Approved Tenants
- Action: "List rows" (Dataverse)
- Table:
fsi_approvedexternaltenants -
Filter:
fsi_approvalstatus eq 100000001 -
For Each Partner Policy Entry
- Action: Apply to each on partner policies from Step 6
8a. Check Against Approved Registry
- Condition: Partner tenantId exists in approved tenant index
- If false (unapproved partner):
1. Increment findingsCount
2. Create fsi_externalsharefinding:
- fsi_findingtype: 100000002 (Unapproved B2B Access)
- fsi_severity: 100000002 (Medium)
- fsi_findingstatus: 100000000 (Open)
- fsi_detectedby: "Audit-EntraCrossTenantSettings-Weekly"
- fsi_governancelayer: 100000001 (Layer 2 — Entra CTA)
- fsi_remediationstatus: 100000000 (Pending)
- fsi_externaltenanttenantid: Partner tenantId
- fsi_remediationnotes: "Partner CTA policy exists for tenant not in approved registry"
8b. Check Scope Against Approval
- Condition: Partner entry exists in approved registry but scope exceeds approved level (e.g., approved for inbound-only but outbound also enabled, inboundTrust accepts unapproved external claims, or automaticUserConsentSettings allows automatic redemption without approval)
- If true:
1. Increment findingsCount
2. Create fsi_externalsharefinding:
- fsi_findingtype: 100000002 (Unapproved B2B Access)
- fsi_severity: 100000002 (Medium)
- fsi_findingstatus: 100000000 (Open)
- fsi_detectedby: "Audit-EntraCrossTenantSettings-Weekly"
- fsi_governancelayer: 100000001 (Layer 2 — Entra CTA)
- fsi_remediationstatus: 100000000 (Pending)
- fsi_remediationnotes: Expression detailing approved vs. actual scope
- Create Entra CTA Record
- Action: "Create a new record" (Dataverse)
- Table:
fsi_entractarecord -
Fields:
fsi_name: Expressionconcat('CTA-Audit-', variables('runId'))fsi_partnersnapshot: Serialized default policy response from Step 4fsi_partnerentrycount: Count of partner policies from Step 6fsi_findingscreated: VariablefindingsCountfsi_auditdate: Variabletimestamp
-
Log Compliance Event
- Create
fsi_crosstenantcomplianceevent: fsi_eventtype: IffindingsCount= 0 →100000004(Entra CTA Audited) else100000005(Entra CTA Violation)fsi_eventdetails: Expressionconcat('Partner policies audited: ', partnerCount, '; Findings: ', findingsCount)
- Create
Error Handling¶
Same scope-based try/catch pattern as Flow 1.
Flow 4: Execute-ExternalTenantOnboarding¶
Type: Instant Cloud Flow
Trigger: Instant — from External Tenant Registry Portal (manual trigger or HTTP request)
Identity: MI-CrossTenantReadWrite
Purpose: Process external tenant onboarding requests with dual-approval workflow and automatic expiration
Build Steps¶
- Create New Flow
- Power Automate → Cloud Flows → Instant Cloud Flow
- Flow name:
Execute-ExternalTenantOnboarding -
Trigger: Manually trigger a flow (or HTTP request trigger from portal)
-
Check Feature Flag
-
Same pattern as Flow 1, Step 2
-
Receive Input Parameters
-
Parse trigger inputs:
requestedTenantId: String (GUID)requestedTenantDomain: StringbusinessJustification: StringrequestedDirection: Choice (Inbound / Outbound / Both)requestorUpn: String (email)requestorTeam: String
-
Initialize Variables
timestamp: ExpressionutcNow()requestId: Expressionguid()annualReviewDate: ExpressionaddMonths(utcNow(), 12)securityTeamUpn: Environment variablefsi_CTSG_SecurityTeamUPN-
governanceCommitteeUpn: Environment variablefsi_CTSG_GovernanceCommitteeUPN -
Input Validation
5a. Validate Business Justification Length
- Condition: Length of businessJustification is less than 100 characters
- If true:
1. Send notification to requestor: "Business justification must be at least 100 characters. Please resubmit with a detailed explanation."
2. Terminate with status Cancelled
5b. Check for Duplicate Request
- Query fsi_approvedexternaltenants:
fsi_approvedexternaltenants?$filter=fsi_tenantid eq '{requestedTenantId}' and fsi_approvalstatus eq 100000001&$top=1
"Tenant is already approved. No new request is required."
2. Terminate with status Cancelled
- Resolve Tenant Information
- Action: HTTP with Microsoft Entra ID
| Parameter | Value |
|---|---|
| Method | GET |
| Base Resource URL | https://graph.microsoft.com |
| Microsoft Entra ID Resource URI | https://graph.microsoft.com |
| URI | /v1.0/tenantRelationships/findTenantInformationByDomainName(domainName='{requestedTenantDomain}') |
- Capture resolved
tenantIdanddisplayName - Condition: Resolved
tenantIddoes NOT matchrequestedTenantId -
If true: Append mismatch note to approval cards:
concat('WARNING: Requested tenant ID (', requestedTenantId, ') does not match resolved tenant ID (', resolvedTenantId, '). Verify before approving.') -
Create Pending Approval Record
- Action: "Create a new record" (Dataverse)
- Table:
fsi_approvedexternaltenant -
Fields:
fsi_name: Expressionconcat('Onboard-', variables('requestId'))fsi_tenantid:requestedTenantIdfsi_tenantname: ResolveddisplayName(orrequestedTenantDomainif resolution failed)fsi_primarydomain:requestedTenantDomainfsi_ppisolationdirection:requestedDirectionfsi_approvalstatus:100000000(Pending)fsi_businessjustification:businessJustificationfsi_notes:requestorUpn(include requestor UPN with prefix "Requestor: ")fsi_requestingteam:requestorTeamfsi_annualreviewdue: VariableannualReviewDate
-
Log Compliance Event
-
Create
fsi_crosstenantcomplianceeventwithfsi_eventtype=100000006(Tenant Onboarding Initiated),fsi_eventdetails= Expressionconcat('Tenant: ', requestedTenantDomain, ' requested by ', requestorUpn) -
Send Parallel Approval Requests
Use a Parallel Branch with two simultaneous approval actions:
Branch A — Security Team Approval:
- Action: Approvals → Start and wait for an approval
- Approval type: Approve/Reject — First to respond
- Title: Expression concat('External Tenant Onboarding — Security Review: ', requestedTenantDomain)
- Assigned to: Variable securityTeamUpn
- Details: Security attestation Adaptive Card (see Template 4)
- Timeout: P10D (10 business days)
Branch B — Governance Committee Approval:
- Action: Approvals → Start and wait for an approval
- Approval type: Approve/Reject — First to respond
- Title: Expression concat('External Tenant Onboarding — Governance Review: ', requestedTenantDomain)
- Assigned to: Variable governanceCommitteeUpn
- Details: Governance approval Adaptive Card (see Template 5)
- Timeout: P10D (10 business days)
-
Handle Timeout (Either Branch)
- Configure Run after → Has timed out on both approval branches
- If timed out:
- Update
fsi_approvedexternaltenant:fsi_approvalstatus:100000002(Expired)fsi_expirynotes:"Approval request expired after 10 business days without response from all required approvers"
- Log
fsi_crosstenantcomplianceeventwithfsi_eventtype=100000008(Tenant Expired) - Send Teams notification to requestor, security team, and governance committee:
"External tenant onboarding request has expired due to incomplete approvals" - Terminate with status
Cancelled
-
Evaluate Approval Outcomes
Condition: Both Branch A AND Branch B outcomes equal
"Approve"If both approved: 1. Update
fsi_approvedexternaltenant: -fsi_approvalstatus:100000001(Approved) -fsi_approvaldate: ExpressionutcNow()-fsi_approvedby: Expressionconcat(securityApprover, '; ', governanceApprover)2. Update PPAC tenant isolation allow-list (if applicable): - Action: HTTP with Microsoft Entra ID — PATCH to add tenant to allow-list 3. Update Entra CTA partner policy (if applicable): - Action: HTTP with Microsoft Entra ID — create or update/v1.0/policies/crossTenantAccessPolicy/partnerswith the Graph v1.0 partner configuration shape (b2bCollaborationInbound,b2bCollaborationOutbound,b2bDirectConnectInbound,b2bDirectConnectOutbound,inboundTrust, andautomaticUserConsentSettings) 4. Close any open findings for this tenant: - Queryfsi_externalsharefindingswherefsi_externaltenanttenantid eq '{tenantId}' and fsi_findingstatus eq 100000000- Update each tofsi_findingstatus=100000002(Remediated),fsi_assignedto="Onboarding Approval",fsi_remediationdate=utcNow()5. Logfsi_crosstenantcomplianceeventwithfsi_eventtype=100000007(Tenant Approved) 6. Send Teams notification to requestor:"External tenant onboarding approved"If either rejected: 1. Update
fsi_approvedexternaltenant: -fsi_approvalstatus:100000004(Revoked) -fsi_expirynotes: Rejection comments from the rejecting approver 2. Logfsi_crosstenantcomplianceeventwithfsi_eventtype=100000010(Tenant Revoked) 3. Send Teams notification to requestor with rejection reason
Error Handling¶
Same scope-based try/catch pattern. For approval actions specifically, configure Run after for both Has failed and Has timed out branches.
Flow 5: Remediate-UnauthorizedExternalAccess¶
Type: Instant Cloud Flow
Trigger: Instant — called from Flow 1, Flow 2, or Registry Portal
Identity: MI-CrossTenantReadWrite
Purpose: Remediate unauthorized external access findings through approval-gated actions across three layers
Remediation Layers¶
| Layer | Scope | Action |
|---|---|---|
| Layer 3 | Individual role assignment | Remove guest user's agent role assignment |
| Layer 2 | Entra CTA partner policy | Restrict or remove partner CTA configuration |
| Layer 1 | PPAC tenant isolation | Remove tenant from allow-list (or defer for manual action) |
Build Steps¶
- Create New Flow
- Power Automate → Cloud Flows → Instant Cloud Flow
- Flow name:
Remediate-UnauthorizedExternalAccess -
Trigger: Manually trigger a flow (accepts
findingIdparameter) -
Check Feature Flag
-
Same pattern as Flow 1, Step 2
-
Retrieve Finding Record
- Action: "Get a row by ID" (Dataverse)
- Table:
fsi_externalsharefinding -
Row ID: Trigger input
findingId -
Check for Duplicate Processing
- Condition:
fsi_findingstatusis NOT100000000(Open) -
If true (already handled):
- Log
fsi_crosstenantcomplianceeventwithfsi_eventtype=100000019(Duplicate Remediation Skipped),fsi_eventdetails= Expressionconcat('Finding ', findingId, ' already in status ', currentStatus) - Terminate with status
Cancelled
- Log
-
Set Status to Under Review
- Action: "Update a record" (Dataverse)
- Table:
fsi_externalsharefinding -
Fields:
fsi_findingstatus:100000001(Under Review)
-
Check Finding Type — Tenant Isolation Disabled
- Condition:
fsi_findingtypeequals"Tenant Isolation Disabled" -
If true (cannot auto-remediate):
- Update finding:
fsi_remediationstatus:100000003(Deferred)fsi_remediationnotes:"Tenant isolation cannot be enabled via automation. Manual action required in Power Platform Admin Center → Tenant Settings → Tenant Isolation → Enable."
- Send CRITICAL Teams alert to
fsi_CTSG_FlowAdministratorsandfsi_CTSG_SecurityTeamUPN:- Use Adaptive Card with red banner:
"CRITICAL: Power Platform tenant isolation is DISABLED" - Include manual remediation steps in card body
- Use Adaptive Card with red banner:
- Log
fsi_crosstenantcomplianceeventwithfsi_eventtype=100000020(Critical Finding — Manual Remediation Required) - Terminate with status
Succeeded(finding remains Open with Deferred remediation)
- Update finding:
-
Send Remediation Approval Request
- Action: Approvals → Start and wait for an approval
- Approval type: Approve/Reject — First to respond
- Title: Expression
concat('Remediation Approval: ', fsi_findingtype, ' — ', fsi_externaltenantname) - Assigned to: Environment variable
fsi_CTSG_SecurityTeamUPN - Details: Remediation approval Adaptive Card (see Template 2)
-
Timeout:
P3D(3 days) -
Handle Approval Response
If Approved:
8a. Layer 3 — Remove Role Assignment
- Condition: Finding has associated fsi_agentid and fsi_externaluserupn
- If true:
- Action: HTTP with Microsoft Entra ID
| Parameter | Value |
|-----------|-------|
| Method | `DELETE` |
| Base Resource URL | Target environment API |
| URI | Role assignment deletion endpoint for the specific agent and principal |
- If DELETE succeeds: Append to `fsi_remediationnotes`: `"Layer 3: Role assignment removed"`
- If DELETE fails: Log error, continue to Layer 2
8b. Layer 2 — Restrict CTA Partner Policy - Condition: Finding involves an unapproved CTA partner - If true: - Action: HTTP with Microsoft Entra ID
| Parameter | Value |
|-----------|-------|
| Method | `PATCH` |
| Base Resource URL | `https://graph.microsoft.com` |
| Microsoft Entra ID Resource URI | `https://graph.microsoft.com` |
| URI | `/v1.0/policies/crossTenantAccessPolicy/partners/{tenantId}` |
- Body: Restrict inbound/outbound as appropriate
- Append to `fsi_remediationnotes`: `"Layer 2: CTA partner policy restricted"`
8c. Layer 1 — PPAC Tenant Isolation
- Condition: Finding involves a PPAC allow-list entry
- If true:
- Attempt to remove tenant from allow-list via PPAC API
- If API supports deletion: Execute and log
- If API does not support automated removal: Set fsi_remediationstatus = 100000003 (Deferred) with manual instructions
- Append to fsi_remediationnotes: Layer 1 action taken or deferred
8d. Update Finding — Remediated
- Action: "Update a record" (Dataverse)
- Table: fsi_externalsharefinding
- Fields:
- fsi_findingstatus: 100000002 (Remediated)
- fsi_remediationstatus: 100000002 (Manually Remediated)
- fsi_assignedto: Approver email
- fsi_remediationdate: Expression utcNow()
8e. Log Compliance Event
- Create fsi_crosstenantcomplianceevent with fsi_eventtype = 100000003 (External Share Remediated)
If Rejected:
8f. Update Finding — Deferred
- Action: "Update a record" (Dataverse)
- Fields:
- fsi_remediationstatus: 100000003 (Deferred)
- fsi_remediationnotes: Rejection reason from approver
- Finding remains in Open status
8g. Log to Immutable Audit
- Create fsi_crosstenantcomplianceevent with fsi_eventtype = 100000015 (Remediation Rejected), fsi_eventdetails = Rejection reason
- This event record serves as the immutable audit trail of the decision
Error Handling¶
Same scope-based try/catch pattern. For HTTP DELETE/PATCH actions, configure individual Run after → Has failed branches that log the error but continue processing remaining layers.
Flow 6: Send-AnnualReviewReminders-Daily¶
Type: Scheduled Cloud Flow
Trigger: Recurrence — Daily at 07:00 AM UTC
Identity: MI-CrossTenantReadOnly
Purpose: Send tiered reminder notifications for upcoming and overdue annual reviews of approved external tenants
Reminder Thresholds¶
| Threshold | Timing | Recipients | Deduplication Window |
|---|---|---|---|
| 90-day advance | 90 days before fsi_annualreviewdue |
fsi_CTSG_GovernanceTeamEmail + requesting team |
Check fsi_crosstenantcomplianceevent for matching event in past 30 days |
| 30-day advance | 30 days before fsi_annualreviewdue |
fsi_CTSG_GovernanceTeamEmail + requesting team + fsi_CTSG_GovernanceCommitteeUPN |
Check fsi_crosstenantcomplianceevent for matching event in past 7 days |
| Overdue | Past fsi_annualreviewdue |
fsi_CTSG_GovernanceTeamEmail + requesting team + fsi_CTSG_GovernanceCommitteeUPN |
No deduplication — sends daily until resolved |
Build Steps¶
- Create New Flow
- Power Automate → Cloud Flows → Scheduled Cloud Flow
- Flow name:
Send-AnnualReviewReminders-Daily -
Recurrence: Daily at 07:00 UTC
-
Check Feature Flag
-
Same pattern as Flow 1, Step 2
-
Initialize Variables
timestamp: ExpressionutcNow()today: ExpressionstartOfDay(utcNow())ninetyDayWindow: ExpressionaddDays(utcNow(), 90)thirtyDayWindow: ExpressionaddDays(utcNow(), 30)governanceTeamEmail: Environment variablefsi_CTSG_GovernanceTeamEmailgovernanceCommitteeUpn: Environment variablefsi_CTSG_GovernanceCommitteeUPN-
remindersQueued: Integer0 -
Get All Approved Tenants
- Action: "List rows" (Dataverse)
- Table:
fsi_approvedexternaltenants - Filter:
fsi_approvalstatus eq 100000001 -
Select:
fsi_tenantid,fsi_tenantname,fsi_primarydomain,fsi_annualreviewdue,fsi_notes,fsi_requestingteam -
For Each Approved Tenant
- Action: Apply to each on approved tenants from Step 4
5a. Evaluate 90-Day Threshold
- Condition: fsi_annualreviewdue is less than or equal to ninetyDayWindow AND fsi_annualreviewdue is greater than thirtyDayWindow
- If true:
1. Deduplication Check:
- Query fsi_crosstenantcomplianceevent:
fsi_crosstenantcomplianceevents?$filter=fsi_eventtype eq 100000011 and fsi_externaltenanttenantid eq '{tenantId}' and createdon ge {thirtyDaysAgo}&$top=1
governanceTeamEmail and requesting team (fsi_notes):
- Use Annual Review Reminder Adaptive Card (see Template 3)
- Set urgency indicator: "Upcoming"
- Create fsi_crosstenantcomplianceevent with fsi_eventtype = 100000011 (Annual Review Due), fsi_externaltenanttenantid = fsi_tenantid
- Increment remindersQueued
5b. Evaluate 30-Day Threshold
- Condition: fsi_annualreviewdue is less than or equal to thirtyDayWindow AND fsi_annualreviewdue is greater than today
- If true:
1. Deduplication Check:
- Query for "Annual Review Due" events for this tenant in past 7 days
- If already sent: Skip
2. If no recent reminder:
- Send URGENT Teams notification to governanceTeamEmail, requesting team, AND governanceCommitteeUpn
- Use Adaptive Card with urgency: "Urgent"
- Create compliance event
- Increment remindersQueued
5c. Evaluate Overdue Threshold
- Condition: fsi_annualreviewdue is less than today
- If true:
1. Create Finding (no deduplication — daily until resolved):
- Action: "Create a new record" (Dataverse)
- Table: fsi_externalsharefinding
- Fields:
- fsi_findingtype: 100000004 (Approved Tenant - Review Required)
- fsi_severity: 100000003 (Low)
- fsi_findingstatus: 100000000 (Open)
- fsi_detectedby: "Send-AnnualReviewReminders-Daily"
- fsi_governancelayer: 100000000 (Layer 1 — Tenant Isolation)
- fsi_remediationstatus: 100000000 (Pending)
- fsi_externaltenanttenantid: fsi_tenantid
- fsi_externaltenantname: fsi_primarydomain
- fsi_remediationnotes: Expression concat('Annual review overdue since ', fsi_annualreviewdue, ' for tenant ', fsi_tenantname)
2. Send OVERDUE Teams notification to all three groups (governanceTeamEmail, requesting team, governanceCommitteeUpn)
3. Use Adaptive Card with urgency: "Overdue" and red accent
4. Create compliance event with fsi_eventtype = 100000012 (Annual Review Overdue)
- Check for Expired Onboarding Requests
- Query
fsi_approvedexternaltenantswherefsi_approvalstatus eq 100000002(Expired) - This data is for alert banner context only — no remediation actions
-
If expired requests exist: Include count in daily summary
-
Log Daily Summary Event
- Create
fsi_crosstenantcomplianceeventwithfsi_eventtype=100000013(Annual Review Completed),fsi_eventdetails= Expressionconcat('Reminders sent: ', remindersQueued)
Error Handling¶
Same scope-based try/catch pattern as Flow 1.
Severity Assignment Reference¶
| Finding Type | Severity | Code |
|---|---|---|
| Tenant Isolation Disabled | Critical | 100000000 |
| Unapproved Tenant Isolation Exception | High | 100000001 |
| Unapproved Guest Share on Zone 3 agent | Critical | 100000000 |
| Unapproved Guest Share on Zone 2 agent | High | 100000001 |
| Unapproved Guest Share on Zone 1 agent | Medium | 100000002 |
| Unapproved Guest Share — zone unknown | High (conservative) | 100000001 |
| Unapproved B2B Access setting drift | Medium | 100000002 |
| Approved Tenant — Annual Review Overdue | Low | 100000003 |
Note: Zone-based severity is derived from
fsi_agentinventory.fsi_zone(fromagent-registry-automation). When the zone value is null or the agent is not found in the registry, apply High severity as a conservative default.
Teams Adaptive Card Templates¶
All cards use Adaptive Cards schema v1.3 with Action.Submit (NOT Action.Execute).
Template 1: Daily Scan Summary¶
{
"type": "AdaptiveCard",
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"version": "1.3",
"body": [
{
"type": "TextBlock",
"text": "Cross-Tenant Governance — Daily Scan Summary",
"weight": "Bolder",
"size": "Large"
},
{
"type": "TextBlock",
"text": "${ScanDate}",
"isSubtle": true,
"spacing": "None"
},
{
"type": "ColumnSet",
"columns": [
{
"type": "Column",
"width": "stretch",
"items": [
{
"type": "TextBlock",
"text": "Findings",
"weight": "Bolder"
},
{
"type": "TextBlock",
"text": "${TotalFindings}",
"size": "ExtraLarge",
"color": "${FindingsColor}"
}
]
},
{
"type": "Column",
"width": "stretch",
"items": [
{
"type": "TextBlock",
"text": "Guests Scanned",
"weight": "Bolder"
},
{
"type": "TextBlock",
"text": "${GuestsProcessed}",
"size": "ExtraLarge"
}
]
},
{
"type": "Column",
"width": "stretch",
"items": [
{
"type": "TextBlock",
"text": "Environments",
"weight": "Bolder"
},
{
"type": "TextBlock",
"text": "${EnvironmentsScanned}",
"size": "ExtraLarge"
}
]
}
]
},
{
"type": "FactSet",
"facts": [
{ "title": "Critical", "value": "${CriticalCount}" },
{ "title": "High", "value": "${HighCount}" },
{ "title": "Medium", "value": "${MediumCount}" },
{ "title": "Low", "value": "${LowCount}" }
]
},
{
"type": "TextBlock",
"text": "Tenant Isolation: ${TenantIsolationStatus}",
"weight": "Bolder",
"color": "${TenantIsolationColor}"
},
{
"type": "TextBlock",
"text": "Run ID: ${RunId}",
"isSubtle": true,
"size": "Small"
}
],
"actions": [
{
"type": "Action.OpenUrl",
"title": "View in Compliance Dashboard",
"url": "${DashboardUrl}"
}
]
}
Template 2: Remediation Approval Request¶
{
"type": "AdaptiveCard",
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"version": "1.3",
"body": [
{
"type": "TextBlock",
"text": "⚠️ Remediation Approval Required",
"weight": "Bolder",
"size": "Large",
"color": "Attention"
},
{
"type": "TextBlock",
"text": "An unauthorized external access finding requires remediation approval.",
"wrap": true
},
{
"type": "FactSet",
"facts": [
{ "title": "Finding Type", "value": "${FindingType}" },
{ "title": "Severity", "value": "${Severity}" },
{ "title": "External Tenant", "value": "${SourceTenantDomain}" },
{ "title": "Agent", "value": "${AgentName}" },
{ "title": "Environment", "value": "${EnvironmentName}" },
{ "title": "Detected", "value": "${DetectedAt}" },
{ "title": "Detection Method", "value": "${GuestDetectionMethod}" }
]
},
{
"type": "TextBlock",
"text": "Proposed Remediation Actions:",
"weight": "Bolder",
"spacing": "Medium"
},
{
"type": "TextBlock",
"text": "${RemediationPlan}",
"wrap": true
},
{
"type": "TextBlock",
"text": "Finding ID: ${FindingId}",
"isSubtle": true,
"size": "Small"
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Approve Remediation",
"data": {
"action": "approve",
"findingId": "${FindingId}"
}
},
{
"type": "Action.Submit",
"title": "Reject (Defer)",
"data": {
"action": "reject",
"findingId": "${FindingId}"
}
}
]
}
Template 3: Annual Review Reminder¶
{
"type": "AdaptiveCard",
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"version": "1.3",
"body": [
{
"type": "TextBlock",
"text": "🔔 External Tenant Annual Review — ${Urgency}",
"weight": "Bolder",
"size": "Large",
"color": "${UrgencyColor}"
},
{
"type": "TextBlock",
"text": "The following approved external tenant requires annual review.",
"wrap": true
},
{
"type": "FactSet",
"facts": [
{ "title": "Tenant", "value": "${TenantName}" },
{ "title": "Domain", "value": "${PrimaryDomain}" },
{ "title": "Review Due", "value": "${AnnualReviewDue}" },
{ "title": "Days Remaining", "value": "${DaysRemaining}" },
{ "title": "Originally Requested By", "value": "${RequestorUpn}" },
{ "title": "Requesting Team", "value": "${RequestorTeam}" },
{ "title": "Approved Direction", "value": "${ApprovedDirection}" }
]
},
{
"type": "TextBlock",
"text": "${UrgencyMessage}",
"wrap": true,
"weight": "Bolder",
"color": "${UrgencyColor}"
}
],
"actions": [
{
"type": "Action.OpenUrl",
"title": "Start Review in Portal",
"url": "${ReviewPortalUrl}"
}
]
}
Urgency values:
| Threshold | ${Urgency} |
${UrgencyColor} |
${UrgencyMessage} |
|---|---|---|---|
| 90-day | Upcoming |
Default |
"This tenant's annual review is due within 90 days. Please schedule the review." |
| 30-day | Urgent |
Warning |
"This tenant's annual review is due within 30 days. Immediate scheduling is recommended." |
| Overdue | Overdue |
Attention |
"This tenant's annual review is OVERDUE. A finding has been created. Review and re-approve or revoke access immediately." |
Template 4: Tenant Onboarding Security Attestation¶
{
"type": "AdaptiveCard",
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"version": "1.3",
"body": [
{
"type": "TextBlock",
"text": "🔐 External Tenant Onboarding — Security Review",
"weight": "Bolder",
"size": "Large"
},
{
"type": "TextBlock",
"text": "A new external tenant onboarding request requires security team approval.",
"wrap": true
},
{
"type": "FactSet",
"facts": [
{ "title": "Requested Tenant", "value": "${TenantName}" },
{ "title": "Tenant ID", "value": "${TenantId}" },
{ "title": "Domain", "value": "${PrimaryDomain}" },
{ "title": "Requested Direction", "value": "${RequestedDirection}" },
{ "title": "Requestor", "value": "${RequestorUpn}" },
{ "title": "Requesting Team", "value": "${RequestorTeam}" },
{ "title": "Request Date", "value": "${RequestDate}" }
]
},
{
"type": "TextBlock",
"text": "Business Justification:",
"weight": "Bolder",
"spacing": "Medium"
},
{
"type": "TextBlock",
"text": "${BusinessJustification}",
"wrap": true
},
{
"type": "TextBlock",
"text": "${TenantMismatchWarning}",
"wrap": true,
"color": "Attention",
"isVisible": "${ShowMismatchWarning}"
},
{
"type": "TextBlock",
"text": "Security Attestation Checklist:",
"weight": "Bolder",
"spacing": "Medium"
},
{
"type": "TextBlock",
"text": "By approving, you attest that:\n- The external tenant has been verified\n- Data sharing risks have been assessed\n- The requested direction is appropriate\n- Annual review cadence is accepted",
"wrap": true
},
{
"type": "TextBlock",
"text": "Request ID: ${RequestId}",
"isSubtle": true,
"size": "Small"
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Approve — Security Cleared",
"data": {
"action": "approve",
"requestId": "${RequestId}",
"approvalType": "security"
}
},
{
"type": "Action.Submit",
"title": "Reject",
"data": {
"action": "reject",
"requestId": "${RequestId}",
"approvalType": "security"
}
}
]
}
Template 5: Tenant Onboarding Governance Approval¶
{
"type": "AdaptiveCard",
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"version": "1.3",
"body": [
{
"type": "TextBlock",
"text": "📋 External Tenant Onboarding — Governance Review",
"weight": "Bolder",
"size": "Large"
},
{
"type": "TextBlock",
"text": "A new external tenant onboarding request requires governance committee approval.",
"wrap": true
},
{
"type": "FactSet",
"facts": [
{ "title": "Requested Tenant", "value": "${TenantName}" },
{ "title": "Tenant ID", "value": "${TenantId}" },
{ "title": "Domain", "value": "${PrimaryDomain}" },
{ "title": "Requested Direction", "value": "${RequestedDirection}" },
{ "title": "Requestor", "value": "${RequestorUpn}" },
{ "title": "Requesting Team", "value": "${RequestorTeam}" },
{ "title": "Request Date", "value": "${RequestDate}" },
{ "title": "Annual Review Due", "value": "${AnnualReviewDue}" }
]
},
{
"type": "TextBlock",
"text": "Business Justification:",
"weight": "Bolder",
"spacing": "Medium"
},
{
"type": "TextBlock",
"text": "${BusinessJustification}",
"wrap": true
},
{
"type": "TextBlock",
"text": "${TenantMismatchWarning}",
"wrap": true,
"color": "Attention",
"isVisible": "${ShowMismatchWarning}"
},
{
"type": "TextBlock",
"text": "Security Team Status: ${SecurityApprovalStatus}",
"weight": "Bolder",
"spacing": "Medium"
},
{
"type": "TextBlock",
"text": "Governance Review Scope:",
"weight": "Bolder",
"spacing": "Medium"
},
{
"type": "TextBlock",
"text": "By approving, the governance committee confirms:\n- The business relationship justifies cross-tenant access\n- The access direction and scope are appropriate\n- The requesting team accepts responsibility for annual review\n- Regulatory obligations (if applicable) have been considered",
"wrap": true
},
{
"type": "TextBlock",
"text": "Request ID: ${RequestId}",
"isSubtle": true,
"size": "Small"
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Approve — Governance Cleared",
"data": {
"action": "approve",
"requestId": "${RequestId}",
"approvalType": "governance"
}
},
{
"type": "Action.Submit",
"title": "Reject",
"data": {
"action": "reject",
"requestId": "${RequestId}",
"approvalType": "governance"
}
}
]
}
Testing Checklist¶
After building all flows:
- Feature flag check — set
fsi_CTSG_IsCrossTenantGovernanceEnabledto"false"and verify all flows terminate gracefully - Flow 1 — Run manually; verify
fsi_tenantisolationrecordcreated with snapshot - Flow 1 — Disable tenant isolation in test environment; verify Critical finding created
- Flow 2 — Run manually; verify guest users detected and domain resolution works
- Flow 2 — Confirm zone-based severity from
fsi_agentinventory.fsi_zone - Flow 2 — Verify deduplication prevents duplicate Open findings
- Flow 3 — Run manually; verify
fsi_entractarecordcreated - Flow 3 — Modify baseline env var to force drift finding
- Flow 4 — Submit test onboarding request; verify dual-approval flow
- Flow 4 — Let approval expire; verify Expired status set
- Flow 4 — Reject from one approver; verify Revoked status
- Flow 5 — Trigger with test finding; verify approval card sent
- Flow 5 — Approve remediation; verify Layer 3 action executed
- Flow 5 — Test "Tenant Isolation Disabled" path; verify Deferred status
- Flow 6 — Set review dates to trigger each threshold (90-day, 30-day, overdue)
- Flow 6 — Verify deduplication prevents repeat 90-day reminders within 30 days
- Teams Adaptive Cards render correctly in desktop and mobile clients
- All compliance events logged to
fsi_crosstenantcomplianceevent
Troubleshooting¶
Issue: Graph API returns 403 for guest user queries
- Cause: Managed Identity lacks User.Read.All permission
- Resolution: Grant User.Read.All (application) permission to MI-CrossTenantReadOnly in Entra ID → App registrations → API permissions
Issue: PPAC tenant settings API returns unexpected schema
- Cause: API version may have changed or the api-version parameter is outdated
- Resolution: Verify the tenant isolation policy action or custom connector against the Delivery Checklist. Flow 1 Step 4 includes schema validation that alerts fsi_CTSG_FlowAdministrators when the confirmed response shape changes.
Issue: Guest UPN parsing fails for MSA guests
- Cause: Microsoft Account (MSA) guests use live.com, outlook.com, or hotmail.com domains which follow different UPN encoding
- Resolution: The 5-value detection method in Flow 2 Step 6 handles MSA guests by flagging them in fsi_remediationnotes. If MSA guests are legitimate in your environment, add their domains to an exclusion list in the flow logic.
Issue: Dual-approval timeout fires before both approvers respond
- Cause: The P10D timeout counts calendar days, not business days. Weekends and holidays reduce effective response time.
- Resolution: Adjust the timeout value in Flow 4 Step 9 based on your organization's approval SLA. Consider P14D for organizations with slower approval cycles.
Issue: Annual review reminders send repeatedly for the same tenant
- Cause: Deduplication check query may not match if fsi_eventdetails content changed between runs
- Resolution: Verify the fsi_eventdetails field in the deduplication query uses the exact fsi_tenantid value. The 90-day check uses a 30-day deduplication window; the 30-day check uses a 7-day window.