| name | permission-least-privilege-review |
| description | Step-by-step playbook for auditing a Microsoft Graph app's permission scope list: delegated vs application decision, narrowing over-broad scopes, finding resource-scoped alternatives, and the escalation criteria for ravenclaude-core/security-reviewer. Owned by graph-identity-engineer. |
Permission Least-Privilege Review
When to invoke
- Selecting permissions for a new Graph app registration.
- Auditing an existing app's scope list before a security review or compliance audit.
- A call is returning
403 Forbidden and the scope is unclear.
- The app currently uses
.ReadWrite.All or .All permissions and needs narrowing.
Step 1 — Delegated vs application: make the decision explicit
| Type | When to use | Identity at runtime |
|---|
| Delegated | A signed-in user is present; the action is on behalf of that user | The user + the app (both must consent) |
| Application | No user; daemon, background job, scheduled task, service | The app only (admin consent required) |
Never default to application permissions "because it's simpler." Application permissions grant the app access to ALL users' data in the tenant (e.g., Mail.Read application reads every mailbox). Delegated limits the blast radius to the signed-in user's data.
Decision rule:
Is a user interactively signed in?
├── YES → delegated
└── NO → application (proceed to Step 2 with extra scrutiny)
Step 2 — Scope narrowing checklist
For each scope in the app's manifest:
| Check | Action |
|---|
Is it *.ReadWrite.All when only read is needed? | Replace with *.Read.All |
Is it *.All when a resource-scoped permission exists? | Find the narrower permission (see table below) |
| Is it an application permission when a delegated one would work? | Downgrade to delegated |
Is it Directory.ReadWrite.All? | Escalate to security-reviewer — this is near-tenant-admin |
Is it Mail.ReadWrite application when only read is needed? | Replace with Mail.Read |
Common resource-scoped alternatives:
| Broad scope | Narrower alternative | Tradeoff |
|---|
Files.ReadWrite.All | Files.ReadWrite (delegated, signed-in user's files only) | User must be signed in |
Calendars.ReadWrite (application) | Calendars.ReadWrite (delegated) | Scoped to signed-in user |
Group.ReadWrite.All | Group.Read.All + GroupMember.ReadWrite.All | Split read vs membership write |
Sites.ReadWrite.All | Sites.Selected (application, site-specific consent) | Requires Site admin to grant; preferred for service apps |
Step 3 — Sites.Selected for SharePoint/OneDrive daemon apps
If the app is a daemon accessing specific SharePoint sites:
- Register the app with
Sites.Selected (application permission).
- Have a SharePoint site admin grant the app access to specific sites via the Graph API or SharePoint admin center.
- The app can only read/write those specific sites — not the entire tenant.
This is the least-privilege choice for SharePoint service integrations. Sites.ReadWrite.All on a daemon app is a high-blast anti-pattern.
Step 4 — Verify consent state
A 403 Forbidden from Graph is almost always one of:
- Missing admin consent for an application permission (use
GET /servicePrincipals/{id}/appRoleAssignments to verify granted roles).
- Missing user consent for a delegated permission (the user hasn't seen the consent dialog).
- Correct permission but wrong type (delegated permission granted but app is using client-credentials flow → application context; vice versa).
- Scope missing from the access token (the permission is granted in Entra but not included in the token's
scp claim — check token with jwt.ms).
import jwt
import base64, json
parts = access_token.split(".")
payload = json.loads(base64.b64decode(parts[1] + "=="))
print("scp:", payload.get("scp"))
print("roles:", payload.get("roles"))
Step 5 — Escalation criteria
Always escalate to ravenclaude-core/security-reviewer when:
Pitfalls
- Requesting
offline_access without a stated reason — this enables long-lived refresh tokens; document the session duration requirement.
- Using
User.Read.All (application) when User.Read (delegated) plus the signed-in user's profile is sufficient.
- Testing with a Global Admin account and concluding "it works" — admins bypass consent and some permission checks; always test with a standard user account.
- Adding permissions "just in case" — every unused permission is a blast radius waiting to be exploited; audit quarterly and remove unused scopes.