# How to Design, Inventory and Detect Permissions for Connected Apps (OAuth and Service Principals): Least Privilege for Builders and Integrators, Lessons From the ConnectWise Case

> A stolen connected-app credential bypasses customer passwords and MFA. Least privilege, inventory and detection for app permissions, with tested code.

- Published: 2026-11-04
- Author: 友田 陽大
- Tags: セキュリティ, 認証・認可, B2B SaaS, アーキテクチャ設計
- URL: https://tomodahinata.com/en/blog/oauth-service-principal-least-privilege-supply-chain-guide
- Category: Security incident analysis
- Pillar guide: https://tomodahinata.com/en/blog/gmo-desktop-cloud-hack-2026-security-analysis

## Key points

- In Microsoft's report, the identities of several ConnectWise cloud apps were taken over, and stolen app credentials were used to authenticate into customer environments. That path sits ahead of the customer's passwords and MFA
- Three preparations: minimize app-level permissions (application permissions), keep credentials short-lived and protected, and detect changes after a grant. RFC 9700 also recommends limiting permissions to the minimum necessary
- A constraint that is easy to miss: Conditional Access for workload identities does not apply to third-party multitenant apps (Microsoft documentation)
- Inventory and self-privilege-grant detection are shown as pure functions (11 tests, 12 kinds of mutation caught). Synthetic data only; not tested against a real tenant
- The author's audit does not include auditing Microsoft 365 or Entra tenant settings. The scope is authorization and the handling of integrations in apps you build

---

## Conclusion: a connected app comes in ahead of the customer's passwords and MFA

**If the identity (credential) of a multitenant connected app that a customer has authorized is stolen, the attacker can get in using the permissions that app already holds in the customer's environment, with no interactive user sign-in.** The strength of the customer's passwords and their MFA do not sit in front of this path.

According to Microsoft's report of 21 October 2026, the attacker took over the identities of several ConnectWise cloud apps (including Cloud Backup) and used "stolen app credentials" to authenticate into customer environments. **Microsoft does not say how the credentials were stolen.** Microsoft assesses that Storm-0940 is responsible for the compromise of these multitenant apps. After the compromise, the actor used ARTEX to accelerate sign-in attempts against 2,250+ tenants to machine speed. In several environments, Microsoft also observed reconnaissance through Microsoft Graph, changes to app credentials and attempts to expand permissions. Microsoft does not say that ARTEX performed the compromise itself. It also states plainly that this was not a compromise of Microsoft's own systems or products. For a summary of ARTEX itself, see [What is ARTEX?](/blog/artex-ai-pentesting-architecture-guide).

This article is written for SaaS engineering leads and engineers, both **those who build connected apps and those who plug them into their own environment**. It makes three claims.

1. Preparation comes down to three things: (1) **minimize and inventory app-level permissions (the "application permissions" an app uses on its own while no person is signed in)**, (2) **keep credentials short-lived, protected and, where possible, absent**, and (3) **detect changes after a grant (self-granted permissions, added credentials)**.
2. **Conditional Access for workload identities does not work for third-party multitenant apps** (Microsoft documentation). It is an easy constraint to miss.
3. If you write inventory and detection as **pure functions of their inputs and outputs**, tests can guarantee them. The code in this article was tested on synthetic data and **has not been verified against a real tenant**.

**Scope first.** I offer paid web application security audits, but **I do not offer audits of Microsoft 365 or Entra tenant settings**.

### Questions for executives

Before the technical detail, check these three things.

1. **Who looks at the connected apps in your environment, and the permissions each one holds, and when?**
2. **Does the procedure for granting an integration's permissions include approval and a record?**
3. **If a connected app is compromised, are the steps for disabling it and the person responsible already decided?**

I do not give loss amounts or probabilities, because they would be figures with no basis. The kinds of impact that can occur are unauthorized access to customer environments, the effort of investigation, notification and audit response, contractual problems with customers, and loss of trust.

### How certain each claim is

| Label | Meaning |
|---|---|
| **[Confirmed]** | Stated directly in a primary source such as Microsoft or the IETF (checked on 4 November 2026) |
| **[Design analysis]** | Analysis the author derived from confirmed facts |
| **[Unverified]** | Cannot be established from public material, or has not been verified by the author |

## Terms: app, service principal, permission, consent

| Term | Meaning (Microsoft's explanation) |
|---|---|
| **Application object** | The global representation of an app, **common to all tenants**. The template |
| **Service principal** | The local representation of an app **in one specific tenant**. Created per customer tenant |
| **Delegated permission (scope)** | Permission used when acting **on behalf of the signed-in user**. Stays within what that user can reach |
| **Application permission (app role)** | Permission for the app acting on its own, **with no signed-in user**. Can reach the data tied to the permission broadly |

For example, an app granted the Microsoft Graph application permission `Files.Read.All` can read any file even with no signed-in user (Microsoft's example). **That no person is logged in is both the advantage and the danger here.** Defenses that sit in front of human authentication (passwords, MFA, device checks) do not apply.

**Consent** is the procedure for granting these permissions, and some permissions require administrator consent.

## What happened **[Confirmed]**

From Microsoft's report, here are only the facts that bear on design.

- **The path**: The identities of several ConnectWise cloud apps were taken over, and the attacker's AI agent authenticated into customer environments with the stolen app credentials. **No interactive user sign-in was needed.** The report describes this as abusing the already-established trust in those apps. One of the source addresses the vendor shared matched exactly the infrastructure behind the earlier password spray (327 accounts, 8 organizations).
- **Afterwards**: In several environments, reconnaissance through Microsoft Graph, changes to app credentials and attempts to expand permissions were observed. Large-scale secret reads in Azure Key Vault are also reported.
- **Scale**: Microsoft says most of the targeting did not succeed, but that privileged cloud persistence was gained in a number of organizations within a short time. In some cases, less than 3 hours passed from targeting to privileged access. Hundreds of organizations were targeted, but no quantitative count of damage is given. Microsoft also notes that the presence of an app or a successful authentication does not necessarily mean data was stolen.
- **Backdoors**: Microsoft warns that changes to app credentials the attacker made in victim environments can lead to persistence through other identities, credentials and cloud services, and **may remain active as a backdoor even after the original compromised app has been dealt with**.

Unlike the familiar theft of a user's ID and password, **what was targeted here was a different kind of identity: the app**. From the customer's side, an app they legitimately authorized appears to be using its legitimate permissions.

## Design for those who build: so that a theft does not spread

If your app holds permissions that reach into customers' environments, **the blast radius is how many companies, and what in them, the app's identity can reach when it is stolen**. The design below is general preparation for the case the ConnectWise incident showed, in which an app's identity is stolen (it is not Microsoft's procedure itself). **The code in this article is a customer-side inventory tool. The only thing a builder can use directly is the audit log of their own home tenant** (see below).

### 1. Keep permissions to the necessary minimum

RFC 9700 (the OAuth 2.0 security BCP, January 2025), section 2.3, says that **the permissions tied to an access token SHOULD be the minimum needed for that app and purpose**, and in the same section also recommends **restricting the audience of an access token to a specific resource server**. **Sender-constrained tokens** appear in section 2.2.1 as a way to prevent token replay.

Microsoft's least-privilege guidance, at development time, is to (1) understand which API calls you need, (2) look up the minimum permission for each call with tools such as Graph Explorer, (3) remove overlapping permissions, and (4) grant only the minimum. It also says that running apps deserve periodic updates, because excess permissions both make an incident possible and make it heavier.

**[Design analysis]**

| Decision | Policy |
|---|---|
| Delegated or app-only | **Use delegated permissions if the signed-in user's context is enough.** Limit app-only permissions to **the functions that need them**, such as batch jobs and syncs with no person present |
| Breadth of permission | Avoid "read and write everything" (the `*.ReadWrite.All` type). Design it so that widening later requires the customer to **consent again** |
| Separation per customer | Design so that customer A's token cannot touch B's data. Make token storage and revocation **per tenant** |
| Revocation | When a customer removes the integration, **reliably revoke** the tokens and credentials. Prepare a procedure that can **revoke for all customers at once** if you are compromised |

### 2. Protect what can be stolen (the credential), keep it short-lived, and ideally do not hold it

What was stolen in this case was the app's credentials. **A secret (a password-type credential) is a key that makes whoever holds it the app, the moment it leaks.**

- **Protect it**: Keep credentials in a place such as a key vault where access can be controlled and recorded, not in plaintext on developers' PCs or in CI variables.
- **Make it short-lived**: Use short expiry and automate rotation.
- **Do not hold it**: Microsoft's **workload identity federation** lets you access resources protected by Microsoft Entra without managing secrets, and works in scenarios such as GitHub Actions, Kubernetes and compute outside Azure. Keyless CI/CD is covered in [Making GitHub Actions keyless with OIDC](/blog/github-actions-oidc-keyless-cicd-aws-gcp-guide). **[Unverified]**: Which authentication methods are available to a multitenant app that enters a customer environment depends on the API, so check each in the official documentation.

### 3. Watch changes to your own app in your home tenant

Look in your own tenant's audit log for **unintended credentials added** to your app registration (the application object). The `reviewAuditEvents` function in the code below can be used for this. You cannot see inside a customer's tenant.

## Design for those who integrate: inventory, consent governance, monitoring

### Inventory: search by application ID

Microsoft's procedure is to **search by application ID, never by display name**, to record the object ID of the affected app's service principal in each tenant, and to check tenants where the app was **introduced or authorized in the past, even if it is not in use now**.

You can check and revoke permissions already granted in your own tenant from the Microsoft Entra admin center, or with Graph or PowerShell (Microsoft's PowerShell revocation steps assume you sign in as at least Cloud Application Administrator).

### Consent governance

Do not leave permissions grantable by anyone. Microsoft Entra has a workflow (the admin consent workflow) in which users can request access to an app that needs administrator consent, and you can designate the reviewers of those requests.

### The limit of Conditional Access **[Confirmed]**

**Conditional Access for workload identities can apply to single-tenant service principals registered in your own tenant (it requires a Workload Identities Premium licence). Microsoft and third-party SaaS apps (including multitenant apps) are out of scope.** Managed identities are also out of scope, and Microsoft says to use access reviews instead.

For a vendor-managed multitenant app such as ConnectWise's, a conditional-access control such as "allowed source locations" is not available. What remains is permission minimization, consent governance and monitoring.

### Monitoring: sign-in logs and audit logs

Microsoft Entra sign-in logs have a category for service principal sign-ins. Microsoft recommends reviewing successful and failed service principal sign-ins **separately**, examining **sign-ins from outside the source addresses the vendor expects** (not only the published ones), and, in the audit log, looking at **credentials added** to apps or service principals, new apps, service principals and owners, **app role assignments and consent grants**, privileged role assignments, and Conditional Access changes.

In MITRE ATT&CK, these correspond to [T1098.001 (Additional Cloud Credentials)](https://attack.mitre.org/techniques/T1098/001/) and [T1098.003 (Additional Cloud Roles)](https://attack.mitre.org/techniques/T1098/003/).

### In an emergency: disable and preserve first, delete later

Microsoft's steps:

1. **Keep the affected integration disabled** until the vendor's recovery guidance is out.
2. **Do not delete** the enterprise application as an initial response. Preserve its configuration and the evidence before any irreversible action.
3. Preserve sign-in and audit logs and SIEM data.
4. **Disabling is not enough.** Credentials the attacker added in the victim environment can remain as a backdoor even after the original app has been dealt with. After preservation, **have an authorized person remove the confirmed unauthorized credentials, grants, owners, assignments and identities**.

The full incident response process is in [Incident response based on NIST SP 800-61 Rev.3](/blog/incident-response-nist-800-61r3-csirt-runbook-playbook-production-guide).

## Tested code: inventory and permission-change detection (synthetic data)

The inventory judgment and the audit-event judgment are **pure functions of their inputs and outputs**. They make no Graph calls. The input is a normalized shape built from a Graph export, with field names that follow the Graph documentation (`appId`, `appOwnerOrganizationId`, `servicePrincipalType`, `startDateTime` and `endDateTime`, `appRoleId` and `resourceId`).

**Prerequisites**: Node 24 (runs `.ts` directly through type stripping), TypeScript strict, no external dependencies. **Tested on synthetic data only; not run against a real tenant.**

```ts
// sp-review.ts
export type Credential = { readonly kind: "secret" | "certificate"; readonly startDateTime: string; readonly endDateTime: string };
/** An application permission granted to a service principal. A role ID only means something together with its resource. */
export type GrantedAppRole = { readonly resourceAppId: string; readonly appRoleId: string };
export type ServicePrincipalSnapshot = {
  readonly appId: string;
  readonly displayName: string;
  readonly servicePrincipalType: "Application" | "ManagedIdentity" | "Legacy" | "SocialIdp";
  readonly appOwnerOrganizationId: string | null;  // the tenant where the app is "registered" (null for non-app principals)
  readonly credentials: readonly Credential[];
  readonly grantedAppRoles: readonly GrantedAppRole[];
};
export type Rule =
  | "high_privilege_app_role" | "foreign_app_only_access" | "credential_on_foreign_sp" | "unknown_app_owner"
  | "long_lived_credential" | "expired_credential" | "invalid_credential_date";
export type Severity = "high" | "medium" | "low";
export type Finding = { readonly appId: string; readonly rule: Rule; readonly severity: Severity; readonly detail: string };
export type ReviewPolicy = {
  readonly homeTenantId: string;
  readonly now: Date;
  readonly maxLifetimeDays: { readonly secret: number; readonly certificate: number }; // numbers to decide from your own rotation practice
  readonly highPrivilegeRoles: ReadonlyMap<string, string>; // roleKey(resourceAppId, appRoleId) -> permission name. Not exhaustive
};

export const MICROSOFT_GRAPH_APP_ID = "00000003-0000-0000-c000-000000000000";
/** GUIDs are case-insensitive. Normalize once so that a difference in notation cannot silently disable a rule. */
export const roleKey = (resourceAppId: string, appRoleId: string): string => `${resourceAppId.toLowerCase()}/${appRoleId.toLowerCase()}`;

/** Three application permissions confirmed as powerful in the Graph documentation. A starting point, not a complete list. */
export const GRAPH_HIGH_PRIVILEGE_ROLES: ReadonlyMap<string, string> = new Map([
  [roleKey(MICROSOFT_GRAPH_APP_ID, "06b708a9-e830-4db3-a914-8e69da51d44f"), "AppRoleAssignment.ReadWrite.All"],
  [roleKey(MICROSOFT_GRAPH_APP_ID, "9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8"), "RoleManagement.ReadWrite.Directory"],
  [roleKey(MICROSOFT_GRAPH_APP_ID, "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9"), "Application.ReadWrite.All"],
]);

const DAY_MS = 86_400_000;
const RANK: Record<Severity, number> = { high: 0, medium: 1, low: 2 };

/** A configuration error stops the run instead of silently disabling a rule (NaN makes every comparison false). */
function assertValidPolicy(p: ReviewPolicy): void {
  const ok = (n: number) => Number.isFinite(n) && n > 0;
  if (Number.isNaN(p.now.getTime())) throw new RangeError("policy.now is not a valid date");
  if (!ok(p.maxLifetimeDays.secret) || !ok(p.maxLifetimeDays.certificate)) throw new RangeError("policy.maxLifetimeDays must be finite and > 0");
}

export function reviewServicePrincipals(inventory: readonly ServicePrincipalSnapshot[], policy: ReviewPolicy): Finding[] {
  assertValidPolicy(policy);
  const home = policy.homeTenantId.toLowerCase();
  const findings: Finding[] = [];
  for (const sp of inventory) {
    const add = (rule: Rule, severity: Severity, detail: string) => findings.push({ appId: sp.appId, rule, severity, detail });

    const powerful = sp.grantedAppRoles.flatMap((r) => policy.highPrivilegeRoles.get(roleKey(r.resourceAppId, r.appRoleId)) ?? []);
    if (powerful.length > 0) add("high_privilege_app_role", "high", `holds ${powerful.join(", ")}`);

    // The registration tenant only means something for app-backed principals (a managed identity has no registration tenant).
    if (sp.servicePrincipalType === "Application") {
      if (sp.appOwnerOrganizationId === null) add("unknown_app_owner", "medium", "application-backed principal without appOwnerOrganizationId");
      else if (sp.appOwnerOrganizationId.toLowerCase() !== home) {
        if (sp.grantedAppRoles.length > 0)
          add("foreign_app_only_access", "medium", `${sp.grantedAppRoles.length} application permission(s) on an app registered in another tenant`);
        // Heuristic: a vendor app's credentials normally live in the vendor's own tenant. A credential added here is exactly the
        // persistence technique Microsoft reported (MITRE T1098.001), so a credential on a foreign-registered principal gets a human look.
        if (sp.credentials.length > 0)
          add("credential_on_foreign_sp", "high", `${sp.credentials.length} credential(s) present on an app registered in another tenant`);
      }
    }

    for (const c of sp.credentials) {
      const start = Date.parse(c.startDateTime), end = Date.parse(c.endDateTime);
      if (Number.isNaN(start) || Number.isNaN(end) || end < start) { add("invalid_credential_date", "medium", `unusable ${c.kind} dates`); continue; } // report what cannot be judged
      if (end < policy.now.getTime()) { add("expired_credential", "low", `${c.kind} expired and still present`); continue; }
      const days = (end - start) / DAY_MS, max = policy.maxLifetimeDays[c.kind];
      if (days > max) add("long_lived_credential", "medium", `${c.kind} valid for ${Math.round(days)} days (policy: ${max})`);
    }
  }
  // Make the order deterministic so that runs can be diffed.
  return findings.sort((a, b) => RANK[a.severity] - RANK[b.severity] || a.appId.localeCompare(b.appId) || a.rule.localeCompare(b.rule));
}

// ---- Audit-log side -------------------------------------------------------------------------------------
// Normalized from the Entra audit log (activityDisplayName / initiatedBy / targetResources). Mapping a raw row to `kind`, and
// aligning both IDs to the same kind of identifier (appId), is tenant-specific work and is not done here.
// If one side is an object ID and the other an appId, the self-grant comparison below silently never matches.
export type AuditEvent = {
  readonly at: string;
  readonly kind: "role_member_added" | "app_role_assigned" | "credential_added";
  readonly actorAppId: string | null;   // null = performed by a person. Not evaluated by these rules (a stolen admin is invisible here)
  readonly targetAppId: string;
};
export type AuditPolicy = { readonly allowedCredentialWriters: ReadonlySet<string>; readonly allowedPrivilegeGrantors: ReadonlySet<string> };
export type AuditAlert = { readonly rule: "self_privilege_grant" | "privilege_grant_by_app" | "unexpected_credential_writer"; readonly severity: Severity; readonly event: AuditEvent };

export function reviewAuditEvents(events: readonly AuditEvent[], policy: AuditPolicy): AuditAlert[] {
  const alerts: AuditAlert[] = [];
  for (const e of events) {
    const actor = e.actorAppId?.toLowerCase();
    if (actor === undefined) continue;
    if (e.kind === "credential_added") {
      if (!policy.allowedCredentialWriters.has(actor)) alerts.push({ rule: "unexpected_credential_writer", severity: "medium", event: e });
    } else if (actor === e.targetAppId.toLowerCase()) alerts.push({ rule: "self_privilege_grant", severity: "high", event: e });
    else if (!policy.allowedPrivilegeGrantors.has(actor)) alerts.push({ rule: "privilege_grant_by_app", severity: "medium", event: e });
  }
  return alerts;
}
```

The key design points:

- **`foreign_app_only_access`**: An app registered in another tenant that holds app-only permissions becomes a review target. Apps with only delegated permissions are outside this rule (broad tenant-wide delegated consent is also worth reviewing separately, but this function does not evaluate it).
- **`credential_on_foreign_sp`**: If a foreign-registered principal has a credential, a person looks at it. **This is the author's heuristic, not a rule Microsoft set out.** A credential can be present for a legitimate reason.
- **`self_privilege_grant`**: An app granting a role to itself. It is the same idea as the alerts in Microsoft's detections, "app assigned itself a high-privilege role" and "service principal added an app role assignment to itself".
- **Role IDs are judged together with their resource**, and case is ignored. A configuration error (such as NaN) raises an exception instead of silently disabling a rule.

### Tests (11)

```ts
// sp-review.test.ts — node --test sp-review.test.ts
import test from "node:test";
import assert from "node:assert/strict";
import { GRAPH_HIGH_PRIVILEGE_ROLES, MICROSOFT_GRAPH_APP_ID, reviewAuditEvents, reviewServicePrincipals, type AuditEvent, type ReviewPolicy, type ServicePrincipalSnapshot } from "./sp-review.ts";

const HOME = "11111111-1111-1111-1111-111111111111", VENDOR = "99999999-9999-9999-9999-999999999999";
const APP_RW = { resourceAppId: MICROSOFT_GRAPH_APP_ID, appRoleId: "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9" }; // Application.ReadWrite.All on Microsoft Graph
const policy: ReviewPolicy = { homeTenantId: HOME, now: new Date("2026-11-04T00:00:00Z"), maxLifetimeDays: { secret: 180, certificate: 730 }, highPrivilegeRoles: GRAPH_HIGH_PRIVILEGE_ROLES };
const sp = (o: Partial<ServicePrincipalSnapshot> & { appId: string }): ServicePrincipalSnapshot =>
  ({ displayName: "x", servicePrincipalType: "Application", appOwnerOrganizationId: HOME, credentials: [], grantedAppRoles: [], ...o });
const rules = (inv: ServicePrincipalSnapshot[], p = policy) => reviewServicePrincipals(inv, p).map((f) => f.rule);

test("a high-privilege role is matched on resource + role id, case-insensitively", () => {
  assert.deepEqual(rules([sp({ appId: "a", grantedAppRoles: [APP_RW] })]), ["high_privilege_app_role"]);
  assert.deepEqual(rules([sp({ appId: "a", grantedAppRoles: [{ resourceAppId: MICROSOFT_GRAPH_APP_ID.toUpperCase(), appRoleId: APP_RW.appRoleId.toUpperCase() }] })]), ["high_privilege_app_role"]);
  assert.deepEqual(rules([sp({ appId: "a", grantedAppRoles: [{ resourceAppId: "some-other-resource", appRoleId: APP_RW.appRoleId }] })]), []); // same GUID, different resource
});
test("a vendor-registered app is flagged for app-only access, not for delegated-only", () => {
  assert.deepEqual(rules([sp({ appId: "v1", appOwnerOrganizationId: VENDOR })]), []);
  assert.deepEqual(rules([sp({ appId: "v2", appOwnerOrganizationId: VENDOR.toUpperCase(), grantedAppRoles: [{ resourceAppId: "r", appRoleId: "x" }] })]), ["foreign_app_only_access"]);
  assert.deepEqual(rules([sp({ appId: "h", appOwnerOrganizationId: HOME.toUpperCase(), grantedAppRoles: [{ resourceAppId: "r", appRoleId: "x" }] })]), []); // casing must not make a home app 'foreign'
});
test("any credential on a vendor-registered principal is flagged high; the same credential on a home app is not", () => {
  const c = [{ kind: "certificate" as const, startDateTime: "2026-10-01T00:00:00Z", endDateTime: "2027-01-01T00:00:00Z" }];
  assert.deepEqual(rules([sp({ appId: "v", appOwnerOrganizationId: VENDOR, credentials: c })]), ["credential_on_foreign_sp"]);
  assert.deepEqual(rules([sp({ appId: "h", credentials: c })]), []);
});
test("managed identities are not 'foreign'; an application without an owner id is reported", () => {
  assert.deepEqual(rules([sp({ appId: "m", servicePrincipalType: "ManagedIdentity", appOwnerOrganizationId: null, grantedAppRoles: [{ resourceAppId: "r", appRoleId: "x" }] })]), []);
  assert.deepEqual(rules([sp({ appId: "n", appOwnerOrganizationId: null })]), ["unknown_app_owner"]);
  assert.deepEqual(rules([sp({ appId: "m2", servicePrincipalType: "ManagedIdentity", appOwnerOrganizationId: null, grantedAppRoles: [APP_RW] })]), ["high_privilege_app_role"]); // privilege rule still applies
});
test("secret AND certificate lifetimes are judged; expired credentials are hygiene", () => {
  const f = rules([sp({ appId: "c", credentials: [
    { kind: "secret", startDateTime: "2026-10-01T00:00:00Z", endDateTime: "2027-10-01T00:00:00Z" },       // 365d > 180d
    { kind: "secret", startDateTime: "2026-10-01T00:00:00Z", endDateTime: "2026-12-01T00:00:00Z" },       // ok
    { kind: "certificate", startDateTime: "2026-10-01T00:00:00Z", endDateTime: "2030-10-01T00:00:00Z" },  // > 730d
    { kind: "certificate", startDateTime: "2026-10-01T00:00:00Z", endDateTime: "2027-10-01T00:00:00Z" },  // 365d: over the SECRET limit, within the CERTIFICATE limit -> must NOT be flagged
    { kind: "secret", startDateTime: "2025-01-01T00:00:00Z", endDateTime: "2025-06-01T00:00:00Z" },       // expired
  ] })]);
  assert.deepEqual([...f].sort(), ["expired_credential", "long_lived_credential", "long_lived_credential"]);
});
test("unusable dates (garbage, or end before start) are reported, never silently passed", () => {
  const bad = (s: string, e: string) => rules([sp({ appId: "d", credentials: [{ kind: "secret", startDateTime: s, endDateTime: e }] })]);
  assert.deepEqual(bad("garbage", "2027-01-01T00:00:00Z"), ["invalid_credential_date"]);
  assert.deepEqual(bad("2027-01-01T00:00:00Z", "2026-12-01T00:00:00Z"), ["invalid_credential_date"]);
});
test("a broken policy throws instead of quietly disabling a rule", () => {
  assert.throws(() => rules([], { ...policy, maxLifetimeDays: { secret: Number.NaN, certificate: 730 } }), RangeError);
  assert.throws(() => rules([], { ...policy, maxLifetimeDays: { secret: 180, certificate: 0 } }), RangeError);
  assert.throws(() => rules([], { ...policy, now: new Date("nope") }), RangeError);
});
test("output order is deterministic and the input is not mutated", () => {
  const inv = [sp({ appId: "z", grantedAppRoles: [APP_RW] }), sp({ appId: "b", grantedAppRoles: [APP_RW] })];
  const before = JSON.stringify(inv);
  assert.deepEqual(reviewServicePrincipals(inv, policy).map((x) => x.appId), ["b", "z"]);
  assert.equal(JSON.stringify(inv), before);
  assert.deepEqual(reviewServicePrincipals([], policy), []);
});

const ev = (o: Partial<AuditEvent> & Pick<AuditEvent, "kind" | "targetAppId">): AuditEvent => ({ at: "2026-11-04T00:00:00Z", actorAppId: null, ...o });
const none = { allowedCredentialWriters: new Set<string>(), allowedPrivilegeGrantors: new Set<string>() };
test("an app granting a role to ITSELF is high; to ANOTHER principal is medium unless the grantor is allow-listed", () => {
  const a = reviewAuditEvents([
    ev({ kind: "role_member_added", actorAppId: "app-1", targetAppId: "APP-1" }),   // self (case-insensitive)
    ev({ kind: "app_role_assigned", actorAppId: "app-1", targetAppId: "app-2" }),   // app -> other
    ev({ kind: "app_role_assigned", actorAppId: "provisioner", targetAppId: "app-2" }),
  ], { ...none, allowedPrivilegeGrantors: new Set(["provisioner"]) });
  assert.deepEqual(a.map((x) => [x.rule, x.severity]), [["self_privilege_grant", "high"], ["privilege_grant_by_app", "medium"]]);
});
test("credential additions by an app outside the allow-list are flagged", () => {
  const a = reviewAuditEvents([
    ev({ kind: "credential_added", actorAppId: "deploy-bot", targetAppId: "app-1" }),
    ev({ kind: "credential_added", actorAppId: "rogue", targetAppId: "app-1" }),
  ], { ...none, allowedCredentialWriters: new Set(["deploy-bot"]) });
  assert.deepEqual(a.map((x) => [x.rule, x.event.actorAppId]), [["unexpected_credential_writer", "rogue"]]);
});
test("KNOWN LIMIT, pinned on purpose: events initiated by a human (actorAppId = null) are not evaluated", () => {
  assert.deepEqual(reviewAuditEvents([ev({ kind: "credential_added", targetAppId: "app-1" }), ev({ kind: "role_member_added", targetAppId: "app-1" })], none), []);
});
```

All 11 pass, and type checking with `tsc --strict --noUncheckedIndexedAccess --noEmit --allowImportingTsExtensions` passes on both the source and the tests. To check that **the tests can actually fail**, I applied the following 12 kinds of mutation: inverting the registration-tenant check; the role key ignoring the resource; the role key not lower-casing; removing the foreign-principal credential rule; treating managed identities as apps; allowing a date where the end precedes the start; allowing NaN in the policy; using the secret limit for certificates (and the reverse); making the self-grant comparison case-sensitive; removing the self-grant check; and not detecting an app granting to another principal. **In the first version, the mutation "use the secret limit for the certificate limit" went undetected, and I closed the gap by adding a boundary test.** All 12 are now detected.

### Limits of this code

- **It has not been run against a real tenant.** I checked the Graph field names against the official documentation, but the conversion from a real export to `ServicePrincipalSnapshot` and `AuditEvent` (including unifying the ID kinds) is your own work and is not verified here.
- **The list of high-privilege permissions is not exhaustive.** It has only three.
- **Operations performed by a person are not evaluated by the audit rules** (a credential added by a stolen administrator account is invisible). A test pins this limit explicitly.
- **Broad tenant-wide delegated consent and owner changes are not evaluated.**
- **The thresholds (`maxLifetimeDays`) are numbers to decide from your own rotation practice, not recommended values.**
- **Detection is after-the-fact awareness.** It is not prevention. What to stop automatically needs a separate design.

## What you can check yourself, and when independent review helps

I offer paid web application security audits. Because this section carries a **conflict of interest**, I put what you can do yourself first.

**What you can start on your own (free)**

- Inventory the connected apps in your own tenant by application ID, and pick out those that hold application permissions and those registered in other tenants. The approach in the code above can be used.
- Check credential expiry, and credentials attached to externally registered apps.
- Look in the audit log for self-granted permissions and unexpected credential additions.
- Whether the authorization in the app you build protects other tenants' data can be turned into tests yourself, using the method in [How should web apps prepare for ARTEX-style AI attacks?](/blog/artex-agentic-ai-web-application-security-defense).
- For Next.js × Supabase, the free OSS (Aegis) can make your current state visible ([/aegis](/aegis)).

**Situations where independent review can help (limited to what I offer)**

- When you want a third party to check the **API authorization and tenant isolation** of the app you build (checking in code the design in [Data isolation and authorization in multitenant SaaS](/blog/multi-tenant-saas-data-isolation-authorization-design-guide)).
- When you want to check, from the app-side code and configuration, the **design of how integration credentials are stored and revoked**.

**What I do not offer**: **Auditing a customer's Microsoft 365 or Entra tenant settings, which is the subject of this article, is outside my scope.** The same goes for post-compromise forensics. Carry out the inventory with the steps in Microsoft's official documentation and your own IT department or a specialist provider.

**When you may not need it**: If you have few connected apps, their permissions are minimal, and you have completed one pass of the self-checks above, running those yourself first is the more reasonable choice.

The scope and fees of an audit that reads the code to check your own app's authorization, tenant isolation and configuration are at [/security-audit](/security-audit). For the overall design of logging and detection, see [Security logging and detection engineering](/blog/security-logging-detection-engineering-sigma-mitre-attack-siem-guide), and for the basics of OAuth tokens, [ID token vs access token](/blog/id-token-vs-access-token-oidc-oauth2-guide).
