This document explains the security controls applied throughout this repository. Every pattern here is designed to be auditable, least-privilege, and repeatable across any Microsoft 365 tenant — not just the examples shown in the code.
- Least-Privilege App Registration
- Scoping Application Permissions to Specific Mailboxes
- Secrets Management
- Conditional Access and Audit Logging
- Data Protection When AI Touches Sensitive Data
- NIST SP 800-171 / CMMC Control-Family Mapping
Microsoft Graph permissions are not all equivalent. A permission like Mail.ReadWrite.All gives an
application the ability to modify every mailbox in the tenant. Requesting broad permissions "just in
case" is a security anti-pattern that violates the principle of least privilege.
Rule: start with the narrowest permission that satisfies the use case and widen only with documented justification.
| Use case | Correct permission | Do NOT use |
|---|---|---|
| Read email only | Mail.Read |
Mail.ReadWrite, Mail.ReadWrite.All |
| Read calendar events | Calendars.Read |
Calendars.ReadWrite |
| Send mail on behalf of a service account | Mail.Send |
Mail.ReadWrite.All |
| Read user profile | User.Read |
User.ReadWrite.All, Directory.ReadWrite.All |
| Read group membership | GroupMember.Read.All |
Group.ReadWrite.All |
Full permission reference: Microsoft Graph permissions reference
| Type | When to use | Risk profile |
|---|---|---|
| Delegated | A signed-in user is present; the app acts on their behalf and is bounded by what that user can access | Lower — scoped to one user's data |
| Application | Background services, daemons, automation with no interactive user | Higher — accesses data across the tenant without a user context |
Prefer delegated permissions wherever a signed-in user flow is possible. Use application permissions only for headless automation, and always pair them with mailbox-scope restrictions (see Section 2).
Application permissions always require admin consent. Delegated permissions that access other users'
data (e.g., Mail.Read without the /me/ prefix) also require admin consent. Consent must be granted
by a Global Administrator or Privileged Role Administrator before the app can be used.
Never ship code that prompts end-users to consent to application-level permissions.
# Connect to Microsoft Graph with the required scope
Connect-MgGraph -Scopes "Application.ReadWrite.All"
# Create the app registration
$app = New-MgApplication -DisplayName "Contoso Mail Reader" `
-SignInAudience "AzureADMyOrg"
# Add ONLY Mail.Read (application permission)
# Role ID for Mail.Read (application): 810c84a8-4a9e-49e6-bf7d-12d183f40d01
$resourceAppId = "00000003-0000-0000-c000-000000000000" # Microsoft Graph
$mailReadRoleId = "810c84a8-4a9e-49e6-bf7d-12d183f40d01"
$requiredAccess = @{
ResourceAppId = $resourceAppId
ResourceAccess = @(
@{ Id = $mailReadRoleId; Type = "Role" } # "Role" = application permission
)
}
Update-MgApplication -ApplicationId $app.Id `
-RequiredResourceAccess @($requiredAccess)Permission type values:
"Role"= application permission,"Scope"= delegated permission.
When you grant Mail.Read as an application permission, the app can — by default — read every
mailbox in the tenant. A compromised credential or a buggy app could exfiltrate the entire
organization's email.
Microsoft replaced Application Access Policies with Role Based Access Control for Applications in Exchange Online. This is the current, supported mechanism.
The approach creates a scoped management role assignment so the app's service principal can only access a specific mailbox or mail-enabled security group.
# Step 1 — Connect to Exchange Online
Connect-ExchangeOnline -UserPrincipalName admin@contoso.onmicrosoft.com
# Step 2 — Register the Entra service principal in Exchange Online
# ObjectId = the Enterprise Application (service principal) object ID from Entra
New-ServicePrincipal -AppId "<CLIENT_ID>" `
-ObjectId "<SERVICE_PRINCIPAL_OBJECT_ID>" `
-DisplayName "Contoso Mail Reader SP"
# Step 3 — Create a management scope limited to one mailbox or a security group
New-ManagementScope -Name "MailReaderScope" `
-RecipientRestrictionFilter { Alias -eq "notifications" }
# Step 4 — Assign the role to the service principal within that scope
New-ManagementRoleAssignment `
-Role "Application Mail.Read" `
-App "<CLIENT_ID>" `
-CustomResourceScope "MailReaderScope"Reference: Role Based Access Control for Applications in Exchange Online
New-ApplicationAccessPolicy is supported but is being replaced by RBAC for Applications.
New deployments should prefer Solution A. Existing policies will require migration when the legacy
feature is deprecated.
# Restrict an app to members of a mail-enabled security group
New-ApplicationAccessPolicy `
-AccessRight RestrictAccess `
-AppId "<CLIENT_ID>" `
-PolicyScopeGroupId "app-mailbox-scope@contoso.onmicrosoft.com" `
-Description "Restrict mail reader app to notification mailbox group only."
# Verify the policy works for a specific mailbox
Test-ApplicationAccessPolicy `
-AppId "<CLIENT_ID>" `
-Identity "notifications@contoso.onmicrosoft.com"AccessRight values:
RestrictAccess— app may ONLY access mailboxes listed in the scope group.DenyAccess— app is BLOCKED from accessing mailboxes listed in the scope group.
Reference: New-ApplicationAccessPolicy
# Should return "Granted"
Test-ApplicationAccessPolicy -AppId "<CLIENT_ID>" `
-Identity "notifications@contoso.onmicrosoft.com"
# Should return "Denied" (a mailbox outside the scope group)
Test-ApplicationAccessPolicy -AppId "<CLIENT_ID>" `
-Identity "ceo@contoso.onmicrosoft.com"The following are unacceptable:
- Client secrets or certificates checked into version control (
.env,appsettings.json,config.py, etc.) - Secrets in CI/CD pipeline environment variables stored in plaintext
- Secrets logged to application output or diagnostics
- Secrets emailed, Slacked, or shared outside a secrets manager
This repository uses placeholder values everywhere a secret would appear:
<CLIENT_ID>, <TENANT_ID>, <CLIENT_SECRET>, <VAULT_URI>.
When the workload runs on an Azure-hosted resource (App Service, Azure Functions, Container Apps, Azure VM, etc.), use a managed identity. Azure manages the credential lifecycle — there is no secret to rotate, leak, or revoke.
# Python example — DefaultAzureCredential picks up the managed identity automatically
from azure.identity import DefaultAzureCredential
from msgraph import GraphServiceClient
credential = DefaultAzureCredential()
client = GraphServiceClient(credential, scopes=["https://graph.microsoft.com/.default"])# PowerShell — connect to Graph using the managed identity of the current Azure resource
Connect-MgGraph -IdentityManaged identities can be used to access Microsoft Graph directly — no client secret, no certificate, no rotation policy needed.
Reference: Managed Identities — Azure App Service
If a managed identity is not available (on-premises runner, non-Azure host), store secrets in Azure Key Vault and retrieve them at runtime. The application authenticates to Key Vault using a short-lived certificate or, where possible, another managed identity.
# Retrieve a secret at runtime — never at build/deploy time
$secret = Get-AzKeyVaultSecret `
-VaultName "kv-contoso-prod" `
-Name "graph-client-secret" `
-AsPlainTextfrom azure.keyvault.secrets import SecretClient
from azure.identity import DefaultAzureCredential
client = SecretClient(
vault_url="https://kv-contoso-prod.vault.azure.net/",
credential=DefaultAzureCredential()
)
secret = client.get_secret("graph-client-secret").valueReference: Authenticate to Azure Key Vault
| Credential type | Maximum lifetime | Rotation trigger |
|---|---|---|
| Client secret | 90 days | Automated Key Vault rotation policy or pipeline alert |
| Certificate | 1 year | Certificate expiry alert in Key Vault |
| Managed identity token | ~24 hours | Automatic (Azure-managed) |
Configure Key Vault expiration alerts so rotation happens before expiry, not after an incident.
Always include the following in .gitignore:
.env
*.env.*
appsettings.*.json
local.settings.json
**/*secret*
**/*credential*
Consider adding git-secrets or Microsoft Defender for DevOps to your CI pipeline to block commits containing secret patterns.
Apply Conditional Access policies to the Entra Enterprise Application that represents this app. Recommended baseline:
| Control | Setting |
|---|---|
| Require compliant device or Hybrid Azure AD Join | Enabled for interactive flows |
| Require MFA | Enabled for all user-delegated flows |
| Allowed locations | Restrict to expected geographic regions |
| Sign-in risk policy | Block sign-ins with High risk (Microsoft Entra ID Protection) |
| Session controls | Limit session lifetime for sensitive workloads |
Conditional Access policies are evaluated at every token issuance, and the results — including policies applied, policies not applied, and failures — are recorded in the Entra sign-in logs.
Reference: Conditional Access and Microsoft Entra activity logs
Sign-in logs capture every authentication event for the application, including:
- Which Conditional Access policies were applied and whether they succeeded or failed
- IP address and location of the authentication
- Risk level assigned by Identity Protection
- Token issuance result
Query sign-in logs for failures related to this app:
Connect-MgGraph -Scopes "AuditLog.Read.All"
Get-MgAuditLogSignIn `
-Filter "appId eq '<CLIENT_ID>' and status/errorCode ne 0" `
-Top 50 | Select-Object CreatedDateTime, UserPrincipalName, IpAddress, `
ConditionalAccessStatus, StatusThe unified audit log (UAL) aggregates activity from Exchange Online, SharePoint, Teams, Entra, and Microsoft Graph API calls into a single searchable log. Retention is 90 days by default; Microsoft Purview Audit (Premium) extends this to 1 year or 10 years.
# Search audit log for Graph API calls by the application over the past 7 days
Search-UnifiedAuditLog `
-StartDate (Get-Date).AddDays(-7) `
-EndDate (Get-Date) `
-Operations "Send","MailItemsAccessed","FileAccessed" `
-ResultSize 1000 | Export-Csv "audit-export.csv" -NoTypeInformationKey operations to monitor:
MailItemsAccessed— triggered when mail is read by an application (requires Audit Premium)Send— email sent via GraphFileAccessed/FilePreviewed— SharePoint/OneDrive file access by the appConsentToApp— new app consent granted in the tenant
Reference: Audit log activities
Route audit log and sign-in log data to a SIEM (Microsoft Sentinel, Splunk, Elastic) or at minimum configure Microsoft Purview alert policies for:
- App consent granted to a new application
- Unusual volume of
MailItemsAccessedevents from a service principal - Sign-in from an unexpected location for this application
Before routing any Microsoft 365 content through an AI model — whether Azure OpenAI, a third-party API, or an on-premises model — classify what the data contains.
| Data category | Examples | External LLM policy |
|---|---|---|
| CUI (Controlled Unclassified Information) | ITAR-controlled documents, DoD contract info | Block — route only to FedRAMP-authorized AI services with a signed DUA/BAA |
| PII | Names + SSN, financial account numbers, health information (PHI/HIPAA) | Block or anonymize/mask before transmission; document the control |
| Company Confidential | M&A data, unreleased product plans, attorney-client communications | Evaluate on a case-by-case basis; prefer on-premises or sovereign AI |
| Sensitivity-labeled content | Any file or email with a Purview sensitivity label of "Confidential" or above | Respect label-based DLP rules; do not bypass labels in code |
| General business data | Meeting notes, general email threads with no label | May be permissible — verify your vendor's data processing agreement |
Apply sensitivity labels to content before it enters an AI pipeline. Labels persist with the content across services and can be enforced by DLP policies.
# List available sensitivity labels in the tenant via Graph
Connect-MgGraph -Scopes "InformationProtectionPolicy.Read"
Get-MgUserInformationProtectionSensitivityLabel -UserId "me"A DLP policy can be configured to block Graph API calls or Copilot prompts that contain content matching a specific sensitivity label or a sensitive information type (SIT) pattern (e.g., U.S. Social Security Number, credit card number).
Reference: Use sensitivity labels as conditions in DLP policies
Microsoft Purview captures prompts and responses for Microsoft 365 Copilot interactions in the unified audit log, including references to files accessed and sensitivity labels present on those files. For custom AI integrations using Graph, implement equivalent logging:
- Log the data classification of every item retrieved before forwarding to an AI model.
- Log the AI model endpoint, request timestamp, and a hash or truncated preview of the prompt.
- Never log full PII or CUI in application logs.
Reference: Microsoft Purview data security for Microsoft 365 Copilot
BLOCKED_SENSITIVITY_LABELS = {"Highly Confidential", "CUI", "Restricted"}
def is_safe_to_send_to_llm(item_sensitivity_label: str) -> bool:
"""Return False if the item's label prohibits external AI processing."""
return item_sensitivity_label not in BLOCKED_SENSITIVITY_LABELS
def redact_pii(text: str) -> str:
"""
Pass content through a PII detection step (e.g., Azure AI Language PII detection)
before forwarding to an external model.
Placeholder — replace with actual implementation.
"""
raise NotImplementedError("Implement PII redaction before sending to external LLM")The controls in this repository map to four key control families in NIST SP 800-171 Rev. 3 and CMMC Level 2. This section provides a generic mapping — apply it to your own System Security Plan (SSP) and Plan of Action & Milestones (POA&M).
CMMC Level 2 requires assessment against all 110 practices in NIST SP 800-171 Rev. 2. NIST SP 800-171 Rev. 3 was finalized May 14, 2024. Verify which revision applies to your specific contract.
| NIST control | Control description | How this repo addresses it |
|---|---|---|
| 3.1.1 | Limit system access to authorized users and processes | App registrations are scoped to minimum permissions; RBAC for Applications restricts mailbox access to explicit scope groups |
| 3.1.2 | Limit system access to the types of transactions and functions authorized users are permitted to execute | Delegated vs. application permission choice documented; Mail.Read not Mail.ReadWrite.All |
| 3.1.3 | Control the flow of CUI in accordance with approved authorizations | Sensitivity label checks in code gate whether content flows to external AI; DLP policies enforce at the platform layer |
| 3.1.5 | Employ the principle of least privilege | Enforced at every layer: Graph permissions, RBAC scope, Key Vault access policies, Conditional Access |
| NIST control | Control description | How this repo addresses it |
|---|---|---|
| 3.3.1 | Create and retain system audit logs to enable monitoring, analysis, investigation, and reporting | Unified Audit Log enabled; sign-in logs retained; Search-UnifiedAuditLog examples provided |
| 3.3.2 | Ensure the actions of individual users can be uniquely traced to those users | Service principal identity tied to all Graph API calls; MailItemsAccessed audit events capture per-item access |
| NIST control | Control description | How this repo addresses it |
|---|---|---|
| 3.5.1 | Identify system users, processes, and devices | Entra app registration provides a unique, auditable identity for the application; managed identity eliminates shared credentials |
| 3.5.2 | Authenticate the identities of users, processes, or devices before allowing access | OAuth 2.0 client credentials flow with certificate or managed identity; MFA enforced via Conditional Access for delegated flows |
| 3.5.3 | Use multi-factor authentication for local and network access to privileged accounts | Conditional Access policy requiring MFA applied to all interactive sign-ins to the application |
| 3.5.10 | Store and transmit only cryptographically-protected passwords | Secrets stored in Azure Key Vault (AES-256 at rest, TLS 1.2+ in transit); plaintext secrets never in code |
| NIST control | Control description | How this repo addresses it |
|---|---|---|
| 3.13.1 | Monitor, control, and protect communications at external boundaries | All Graph API traffic over HTTPS/TLS; Conditional Access restricts authentication to approved networks/locations |
| 3.13.10 | Establish and manage cryptographic keys for cryptography employed in organizational systems | Azure Key Vault manages key lifecycle; rotation policies enforced; managed identity eliminates long-lived credential keys |
| 3.13.15 | Protect the authenticity of communications sessions | OAuth 2.0 tokens are short-lived (1 hour default); refresh token rotation enforced; token audiences are scoped to the specific resource |
Before deploying any application in this repository to a production tenant:
- App registration requests only the minimum Graph permissions required — no
*.Allunless documented and justified - Application permissions are scoped using RBAC for Applications or ApplicationAccessPolicy to prevent tenant-wide mailbox access
- No secrets in code,
.envfiles, or version control — managed identity or Key Vault only - Key Vault secrets have expiration dates and rotation alerts configured
- Conditional Access policy applied to the Enterprise Application
- Unified Audit Log is enabled and retained for the required period
- Data classification check implemented before content is forwarded to any AI model
- Sensitivity label and DLP policies reviewed to ensure AI processing flows are covered
- Controls documented in SSP if the system processes CUI (NIST 800-171 / CMMC applicability)
References
- Microsoft Graph permissions reference
- Overview of Microsoft Graph permissions
- Role Based Access Control for Applications in Exchange Online
- Application Access Policies (legacy)
- New-ApplicationAccessPolicy
- Limiting application permissions to specific Exchange Online mailboxes
- Authenticate to Azure Key Vault
- Managed Identities — Azure App Service
- Security best practices for app registration
- Conditional Access and Microsoft Entra activity logs
- Audit log activities (Purview)
- Microsoft Purview data security for Microsoft 365 Copilot and AI
- Use sensitivity labels as conditions in DLP policies
- NIST SP 800-171 Rev. 3