AID-E-001.004: Delegated Grant & Connected-App Authorization Revocation

Version 1.20261015 · Actionable sub-technique

Revoke the complete incident-scoped population of delegated OAuth grants, connected-app consents, service-principal app-role assignments, token-exchange or on-behalf-of delegated authorizations, and SaaS application grants that can mint fresh tokens or sustain access after ordinary token invalidation.

Pillars: app, infra

Lifecycle: response

Scope boundary

Owns incident-time revocation of delegated OAuth grants, connected-app consents, app-role assignments, token-exchange or on-behalf-of authorizations, and SaaS application grants that can mint fresh tokens or sustain access. It does not own credential, token, principal, application-state, or pre-incident authorization design.

AID-E-001.001: AID-E-001.004 removes delegated authorization objects; AID-E-001.001 evicts root and long-lived credentials. Grant revocation does not rotate an exposed password, key, client secret, signing key, or certificate, and credential rotation does not necessarily remove an independent connected-app consent.

AID-E-001.002: AID-E-001.004 removes authorization that can sustain or recreate access; AID-E-001.002 revokes tokens and authentication sessions already issued under that authority. Both controls are required when issued artifacts remain valid after the underlying grant is removed.

AID-E-001.003: AID-E-001.004 revokes delegated grants and app authorizations; AID-E-001.003 disables the compromised AI-agent or workload principal and stops new principal issuance. Removing one grant does not disable the principal's other authorization paths, while principal disablement does not prove removal of every connected-app or delegated grant object.

AID-H-034.002: AID-E-001.004 performs incident-time removal of delegated grant objects; AID-H-034.002 enforces MCP server OAuth resource boundaries and delegation safety before compromise. Preventive OAuth design does not revoke a compromised live grant, and grant eviction does not replace correct protected-resource and audience enforcement.

AID-E-005: AID-E-001.004 removes authorization grants; AID-E-005 deletes rogue webhook, tool-registration, queue, schedule, and durable application-state records. Revoke the grant before or alongside teardown, but do not treat removal of either population as proof that the other is gone.

Implementation guidance

AID-E-001.004-G001

Enumerate delegated OAuth grants, connected apps, app-role assignments, and SaaS consents tied to the compromised principal or agent.
Take a read-only, class-complete inventory before revocation

Inventory is a prerequisite, not proof of eviction. Use read-only Graph permissions and enumerate each declared authorization class separately. The Entra example below exports delegated OAuth grants and direct user app-role assignments. Microsoft Graph also returns assignments inherited through groups, so the adapter filters direct assignments by immutable user principal ID; group-derived authorization is a separate class that needs its own signed scope and group-membership adapter. Connected-app, OBO/token-exchange, cross-application, and SaaS-native authorization objects require provider-specific read-only adapters; list those classes in the signed incident scope and fail the inventory when an applicable class has no authoritative enumerator. A workload identity or service principal is not a user: select its provider endpoint explicitly rather than silently returning an empty user inventory.

Export Entra delegated grants and app-role assignments
# File: incident_response/export_entra_principal_authorizations.ps1
param(
  [Parameter(Mandatory=$true)][string]$UserPrincipalName,
  [Parameter(Mandatory=$true)][string]$IncidentId,
  [Parameter(Mandatory=$true)][string]$OutputPath
)

$ErrorActionPreference = "Stop"
Connect-MgGraph -Scopes "Directory.Read.All" -NoWelcome
$user = Get-MgUser -UserId $UserPrincipalName -Property Id,UserPrincipalName

$delegated = @(
  Get-MgUserOauth2PermissionGrant -UserId $user.Id -All |
    Sort-Object Id |
    ForEach-Object {
      [ordered]@{
        grant_id = $_.Id
        client_id = $_.ClientId
        resource_id = $_.ResourceId
        scope = $_.Scope
        consent_type = $_.ConsentType
        principal_id = $_.PrincipalId
      }
    }
)

$appRoles = @(
  Get-MgUserAppRoleAssignment -UserId $user.Id -All |
    Where-Object { $_.PrincipalId -eq $user.Id } |
    Sort-Object Id |
    ForEach-Object {
      [ordered]@{
        assignment_id = $_.Id
        app_role_id = $_.AppRoleId
        resource_id = $_.ResourceId
        principal_id = $_.PrincipalId
        created_at = $_.CreatedDateTime
      }
    }
)

$record = [ordered]@{
  schema_version = "aidefend.e001004.authorization-inventory.v1"
  control = "AID-E-001.004"
  provider = "microsoft_entra"
  incident_id = $IncidentId
  principal_id = $user.Id
  principal_type = "user"
  user_principal_name = $user.UserPrincipalName
  enumerated_classes = @("delegated_oauth_grant", "user_app_role_assignment")
  delegated_grants = $delegated
  app_role_assignments = $appRoles
  exported_at = (Get-Date).ToUniversalTime().ToString("o")
}
$json = $record | ConvertTo-Json -Depth 10 -Compress
$parent = Split-Path -Parent $OutputPath
if ($parent) {
  New-Item -ItemType Directory -Force -Path $parent | Out-Null
}
[System.IO.File]::WriteAllText(
  $OutputPath,
  $json + [Environment]::NewLine,
  [System.Text.UTF8Encoding]::new($false)
)
#!/usr/bin/env bash
set -euo pipefail
: "${ENTRA_USER_PRINCIPAL_NAME:?Set ENTRA_USER_PRINCIPAL_NAME}"
: "${INCIDENT_ID:?Set INCIDENT_ID from the verified incident record}"
: "${INVENTORY_PHASE:?Set INVENTORY_PHASE to before or after}"
: "${INVENTORY_SIGNING_KEY:?Set INVENTORY_SIGNING_KEY for Cosign}"
: "${E001004_COMMAND_TIMEOUT_SECONDS:?Set the signed runtime-profile timeout}"
if [[ ! "$INCIDENT_ID" =~ ^INC-[A-Z0-9][A-Z0-9._-]{2,63}$ ]]; then
  echo "INCIDENT_ID is not a bounded incident identifier" >&2
  exit 2
fi
case "$INVENTORY_PHASE" in before|after) ;; *) echo "INVENTORY_PHASE must be before or after" >&2; exit 2;; esac
if [[ ! "$E001004_COMMAND_TIMEOUT_SECONDS" =~ ^[0-9]+([.][0-9]+)?$ ]] ||
   ! awk -v value="$E001004_COMMAND_TIMEOUT_SECONDS" 'BEGIN { exit !(value > 0) }'; then
  echo "command timeout must be positive" >&2
  exit 2
fi
umask 077
OUTPUT_DIR="artifacts/$INCIDENT_ID"
OUTPUT_PATH="$OUTPUT_DIR/entra-authorization-$INVENTORY_PHASE.json"
BUNDLE_PATH="$OUTPUT_PATH.sigstore.json"
mkdir -p -- "$OUTPUT_DIR"
pwsh -File incident_response/export_entra_principal_authorizations.ps1 \
  -UserPrincipalName "$ENTRA_USER_PRINCIPAL_NAME" \
  -IncidentId "$INCIDENT_ID" \
  -OutputPath "$OUTPUT_PATH"
cosign sign-blob --yes --key env://INVENTORY_SIGNING_KEY \
  --bundle "$BUNDLE_PATH" "$OUTPUT_PATH"
timeout "$E001004_COMMAND_TIMEOUT_SECONDS" \
  cosign verify-blob --key trust/inventory-reader.pub \
  --bundle "$BUNDLE_PATH" "$OUTPUT_PATH"
Verify completeness before any destructive action

A separate incident reviewer compares enumerated_classes with the signed provider-class scope, checks that the principal type and immutable provider ID are correct, verifies the incident-scoped inventory payload and sibling Cosign bundle, and records the exact target object IDs in a different signed revocation manifest. Empty results are acceptable only when the authoritative endpoint successfully enumerated an applicable class and the signed scope expected zero objects. Missing permissions, pagination failure, unsupported principal type, or an undeclared provider authorization class is insufficient data and blocks revocation acceptance.

Action: run this exporter with INVENTORY_PHASE=before before revocation and INVENTORY_PHASE=after after propagation, then preserve both signed inventories and provider readback metadata before removing any grant or assignment. Never request write scopes merely to enumerate.

AID-E-001.004-G002

Revoke delegated OAuth grants, connected-app consents, token-exchange or on-behalf-of authorizations, and app-role assignments that allow continued access after compromise.
Production implementation

A provider adapter selected from trusted configuration re-fetches each object and compares all signed fields. It invokes the conditional delete or revoke API for that exact grant or consent object and removes exact app-role or connected-app assignments. Already-issued access tokens, refresh tokens, and issuer or gateway sessions are enumerated and invalidated under AID-E-001.002; those receipts are dependency evidence, not E-001.004 evidence. Unsupported class or partial provider failure aborts acceptance and records per-object state; it never broad-revokes unlisted principals/applications.

Independent verification

A separate read-only connector re-enumerates all declared grant classes at a newer consistent revision and proves authorized objects absent/disabled and unlisted controls unchanged. Controlled OAuth authorization, OBO or token-exchange, fresh-token issuance, and app-role attempts for applicable revoked paths must be denied; a clean approved application control still succeeds.

AID-E-001.004-G003

Verify grant-revocation propagation and monitor for attempted reuse of revoked connected-app access.
Verify only the signed target population

Do not interpret the entire pre-incident inventory as malicious. Before revocation, an authorized reviewer signs the exact delegated-grant and app-role-assignment IDs to remove. Verification must prove that every target disappeared, every non-target object is unchanged, no new object appeared during the action window, and the incident and immutable principal bindings match. This avoids both false PASS and collateral revocation.

{
  "schema_version": "aidefend.e001004.revocation-targets.v1",
  "control": "AID-E-001.004",
  "provider": "microsoft_entra",
  "incident_id": "INC-2026-0718-0042",
  "principal_id": "51d3c809-58a2-46b8-8136-e3fda2bfef85",
  "authorization_id": "change://CHG-2026-0718-031",
  "before_inventory_sha256": "c61e73c8f22a997daf0f9a4acbb7e0c45bc7184a2b81cb184034a3e879b330e2",
  "target_delegated_grant_ids": ["df8f12d6-3f82-4a20-8d31-7105a8d65d4f"],
  "target_app_role_assignment_ids": ["602dd83c-4cec-4a54-9e11-52f1ae6c64a8"]
}
#!/usr/bin/env bash
set -euo pipefail
: "${INCIDENT_ID:?Set INCIDENT_ID from the verified incident record}"
: "${E001004_COMMAND_TIMEOUT_SECONDS:?Set the signed runtime-profile timeout}"
if [[ ! "$INCIDENT_ID" =~ ^INC-[A-Z0-9][A-Z0-9._-]{2,63}$ ]]; then
  echo "INCIDENT_ID is not a bounded incident identifier" >&2
  exit 2
fi
if [[ ! "$E001004_COMMAND_TIMEOUT_SECONDS" =~ ^[0-9]+([.][0-9]+)?$ ]] ||
   ! awk -v value="$E001004_COMMAND_TIMEOUT_SECONDS" 'BEGIN { exit !(value > 0) }'; then
  echo "command timeout must be positive" >&2
  exit 2
fi
artifact_root="artifacts/$INCIDENT_ID"
SNAPSHOT_ROOT="$(mktemp -d)"
chmod 700 "$SNAPSHOT_ROOT"
trap 'rm -rf -- "$SNAPSHOT_ROOT"' EXIT
: > "$SNAPSHOT_ROOT/verified-inputs.sha256"
chmod 400 "$SNAPSHOT_ROOT/verified-inputs.sha256"

hash_file() { sha256sum -- "$1" | awk '{print $1}'; }
copy_and_verify_pair() {
  local source_payload=$1 trust_key=$2 snapshot_name=$3
  local source_bundle="${source_payload}.sigstore.json"
  local snapshot_payload="$SNAPSHOT_ROOT/$snapshot_name"
  local snapshot_bundle="$SNAPSHOT_ROOT/$snapshot_name.sigstore.json"
  if [[ ! -s "$source_payload" || ! -s "$source_bundle" ]]; then
    echo "missing or empty payload/bundle pair: $source_payload" >&2
    return 1
  fi
  install -m 0400 -- "$source_payload" "$snapshot_payload"
  install -m 0400 -- "$source_bundle" "$snapshot_bundle"
  local payload_sha bundle_sha
  payload_sha=$(hash_file "$snapshot_payload")
  bundle_sha=$(hash_file "$snapshot_bundle")
  timeout "$E001004_COMMAND_TIMEOUT_SECONDS" \
    cosign verify-blob --key "$trust_key" \
    --bundle "$snapshot_bundle" "$snapshot_payload"
  if [[ $(hash_file "$snapshot_payload") != "$payload_sha" ||
        $(hash_file "$snapshot_bundle") != "$bundle_sha" ]]; then
    echo "verified payload or bundle snapshot changed: $snapshot_name" >&2
    return 1
  fi
  chmod 600 "$SNAPSHOT_ROOT/verified-inputs.sha256"
  printf '%s  %s\n%s  %s\n' \
    "$payload_sha" "$snapshot_name" \
    "$bundle_sha" "$snapshot_name.sigstore.json" \
    >> "$SNAPSHOT_ROOT/verified-inputs.sha256"
  chmod 400 "$SNAPSHOT_ROOT/verified-inputs.sha256"
}

copy_and_verify_pair \
  "$artifact_root/revocation-targets.json" \
  trust/revocation-authority.pub revocation-targets.json
copy_and_verify_pair \
  "$artifact_root/entra-authorization-before.json" \
  trust/inventory-reader.pub entra-authorization-before.json
copy_and_verify_pair \
  "$artifact_root/entra-authorization-after.json" \
  trust/inventory-reader.pub entra-authorization-after.json

python incident_response/verify_authorization_revocation.py \
  --before "$SNAPSHOT_ROOT/entra-authorization-before.json" \
  --targets "$SNAPSHOT_ROOT/revocation-targets.json" \
  --after "$SNAPSHOT_ROOT/entra-authorization-after.json" \
  --output "$artifact_root/revocation-state-verification.json"
Compare exact before, target, and after sets
# File: incident_response/verify_authorization_revocation.py
from __future__ import annotations

import argparse
import hashlib
import json
from datetime import datetime
from pathlib import Path
from typing import Any


INVENTORY_FIELDS = {
    "schema_version",
    "control",
    "provider",
    "incident_id",
    "principal_id",
    "principal_type",
    "user_principal_name",
    "enumerated_classes",
    "delegated_grants",
    "app_role_assignments",
    "exported_at",
}
TARGET_FIELDS = {
    "schema_version",
    "control",
    "provider",
    "incident_id",
    "principal_id",
    "authorization_id",
    "before_inventory_sha256",
    "target_delegated_grant_ids",
    "target_app_role_assignment_ids",
}
DELEGATED_FIELDS = {
    "grant_id",
    "client_id",
    "resource_id",
    "scope",
    "consent_type",
    "principal_id",
}
APP_ROLE_FIELDS = {
    "assignment_id",
    "app_role_id",
    "resource_id",
    "principal_id",
    "created_at",
}
EXPECTED_CLASSES = {
    "delegated_oauth_grant",
    "user_app_role_assignment",
}


def reject_duplicate_keys(pairs):
    value = {}
    for key, item in pairs:
        if key in value:
            raise ValueError(f"duplicate signed JSON key: {key}")
        value[key] = item
    return value


def reject_nonfinite(value):
    raise ValueError(f"non-finite signed JSON value: {value}")


def load(path: Path) -> tuple[dict[str, Any], bytes]:
    raw = path.read_bytes()
    if not raw:
        raise ValueError(f"{path} is empty")
    try:
        value = json.loads(
            raw.decode("utf-8", errors="strict"),
            object_pairs_hook=reject_duplicate_keys,
            parse_constant=reject_nonfinite,
        )
    except (UnicodeDecodeError, json.JSONDecodeError) as exc:
        raise ValueError(f"{path} is not strict UTF-8 JSON") from exc
    if not isinstance(value, dict):
        raise ValueError(f"{path} must contain one JSON object")
    return value, raw


def sha256_bytes(raw: bytes) -> str:
    return hashlib.sha256(raw).hexdigest()


def require_sha256(value: object, field: str) -> str:
    if (
        not isinstance(value, str)
        or len(value) != 64
        or set(value) - set("0123456789abcdef")
        or value == "0" * 64
    ):
        raise ValueError(f"{field} must be a nonzero lowercase SHA-256 digest")
    return value


def exact_target_ids(value: object, field: str) -> set[str]:
    if (
        not isinstance(value, list)
        or value != sorted(set(value))
        or any(
            not isinstance(item, str) or not item or "*" in item
            for item in value
        )
    ):
        raise ValueError(f"{field} must be a sorted unique exact-ID list")
    return set(value)


def index_rows(
    document: dict[str, Any],
    collection: str,
    id_field: str,
    expected_fields: set[str],
) -> dict[str, dict[str, Any]]:
    rows = document.get(collection)
    if not isinstance(rows, list):
        raise ValueError(f"{collection} must be a complete array")
    indexed: dict[str, dict[str, Any]] = {}
    for row in rows:
        if not isinstance(row, dict) or set(row) != expected_fields:
            raise ValueError(f"{collection} row schema differs")
        row_id = row.get(id_field)
        if not isinstance(row_id, str) or not row_id:
            raise ValueError(f"{collection}.{id_field} is required")
        if row_id in indexed:
            raise ValueError(f"duplicate {collection} object {row_id}")
        if row.get("principal_id") != document["principal_id"]:
            raise ValueError(f"{collection} object is bound to another principal")
        indexed[row_id] = row
    return indexed


def parse_time(value: object, field: str) -> datetime:
    if not isinstance(value, str) or not value:
        raise ValueError(f"{field} is required")
    parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
    if parsed.tzinfo is None:
        raise ValueError(f"{field} must include a timezone")
    return parsed


def verify(
    before_path: Path,
    target_path: Path,
    after_path: Path,
) -> dict[str, Any]:
    before, before_bytes = load(before_path)
    targets, target_bytes = load(target_path)
    after, after_bytes = load(after_path)
    if (
        set(before) != INVENTORY_FIELDS
        or set(after) != INVENTORY_FIELDS
        or before.get("schema_version")
        != "aidefend.e001004.authorization-inventory.v1"
        or after.get("schema_version")
        != "aidefend.e001004.authorization-inventory.v1"
        or set(targets) != TARGET_FIELDS
        or targets.get("schema_version")
        != "aidefend.e001004.revocation-targets.v1"
    ):
        raise ValueError("inventory or target schema differs")
    authorization_id = targets.get("authorization_id")
    if (
        not isinstance(authorization_id, str)
        or not authorization_id
        or "*" in authorization_id
    ):
        raise ValueError("authorization_id must be an exact nonempty value")
    signed_before_digest = require_sha256(
        targets.get("before_inventory_sha256"),
        "targets.before_inventory_sha256",
    )
    common = ("control", "provider", "incident_id", "principal_id")
    if (
        before.get("control") != "AID-E-001.004"
        or any(before.get(field) != after.get(field) for field in common)
        or any(before.get(field) != targets.get(field) for field in common)
        or before.get("principal_type") != after.get("principal_type")
        or before.get("user_principal_name")
        != after.get("user_principal_name")
        or set(before.get("enumerated_classes", [])) != EXPECTED_CLASSES
        or set(after.get("enumerated_classes", [])) != EXPECTED_CLASSES
    ):
        raise ValueError("incident, provider, principal, or class binding differs")
    if parse_time(after["exported_at"], "after.exported_at") <= parse_time(
        before["exported_at"], "before.exported_at"
    ):
        raise ValueError("after inventory is not newer than before inventory")
    if signed_before_digest != sha256_bytes(before_bytes):
        raise ValueError("target manifest does not bind the before inventory")

    before_delegated = index_rows(
        before, "delegated_grants", "grant_id", DELEGATED_FIELDS
    )
    after_delegated = index_rows(
        after, "delegated_grants", "grant_id", DELEGATED_FIELDS
    )
    before_roles = index_rows(
        before, "app_role_assignments", "assignment_id", APP_ROLE_FIELDS
    )
    after_roles = index_rows(
        after, "app_role_assignments", "assignment_id", APP_ROLE_FIELDS
    )
    delegated_targets = exact_target_ids(
        targets["target_delegated_grant_ids"],
        "target_delegated_grant_ids",
    )
    role_targets = exact_target_ids(
        targets["target_app_role_assignment_ids"],
        "target_app_role_assignment_ids",
    )
    if not delegated_targets and not role_targets:
        raise ValueError("revocation target population is empty")
    if not delegated_targets.issubset(before_delegated):
        raise ValueError("delegated target was absent from the signed before set")
    if not role_targets.issubset(before_roles):
        raise ValueError("app-role target was absent from the signed before set")

    expected_delegated = set(before_delegated) - delegated_targets
    expected_roles = set(before_roles) - role_targets
    if set(after_delegated) != expected_delegated:
        raise RuntimeError("delegated after-set is not the exact target difference")
    if set(after_roles) != expected_roles:
        raise RuntimeError("app-role after-set is not the exact target difference")
    if any(
        after_delegated[item] != before_delegated[item]
        for item in expected_delegated
    ):
        raise RuntimeError("a non-target delegated grant changed")
    if any(after_roles[item] != before_roles[item] for item in expected_roles):
        raise RuntimeError("a non-target app-role assignment changed")

    return {
        "schema_version":
            "aidefend.e001004.revocation-state-verification.v1",
        "control": "AID-E-001.004",
        "provider": before["provider"],
        "incident_id": before["incident_id"],
        "principal_id": before["principal_id"],
        "authorization_id": targets["authorization_id"],
        "before_inventory_sha256": sha256_bytes(before_bytes),
        "target_manifest_sha256": sha256_bytes(target_bytes),
        "after_inventory_sha256": sha256_bytes(after_bytes),
        "verified_revoked_delegated_grant_ids":
            sorted(delegated_targets),
        "verified_revoked_app_role_assignment_ids":
            sorted(role_targets),
        "verified_unchanged_delegated_grant_ids":
            sorted(expected_delegated),
        "verified_unchanged_app_role_assignment_ids":
            sorted(expected_roles),
        "authoritative_state_result": "PASS",
    }


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--before", type=Path, required=True)
    parser.add_argument("--targets", type=Path, required=True)
    parser.add_argument("--after", type=Path, required=True)
    parser.add_argument("--output", type=Path, required=True)
    args = parser.parse_args()
    result = verify(args.before, args.targets, args.after)
    with args.output.open("x", encoding="utf-8", newline="\n") as handle:
        json.dump(result, handle, sort_keys=True, separators=(",", ":"))
        handle.write("\n")


if __name__ == "__main__":
    main()
Complete closure with provider behavior, not inventory alone

Run the after-inventory and verifier under a separately credentialed read-only identity after provider propagation. For each targeted path, run a provider-approved negative issuance or access probe: new delegated authorization, OBO or token-exchange issuance, and app-role protected-resource access must be denied according to the signed probe contract. Run an unrevoked clean control to distinguish correct denial from provider outage. Sign and retain the authoritative-state result, probe request digests, provider decision and correlation IDs, clean-control result, and timestamps. The set comparison is only state proof; E-001.004 closes after the applicable negative probes also pass.

Reuse monitoring is a Detect handoff. Activate a versioned rule keyed to the exact revoked principal, client, resource, grant, and assignment identifiers without storing reusable tokens. A monitoring rule or absence of alerts never substitutes for revocation readback.

Action: require exact target absence, exact non-target preservation, provider negative probes, and a clean positive control. Any new object, collateral deletion, stale inventory, issuance success, or provider outage blocks PASS.

Open-source tools

Commercial / hosted tools

Threat mappings

MITRE ATLAS

MAESTRO

OWASP LLM Top 10 2026

OWASP ML Top 10 2023

OWASP Top 10 for Agentic Applications 2026

NIST Adversarial Machine Learning 2025

Cisco Integrated AI Security and Safety Framework

Google Secure AI Framework 2.0 - Risks

Databricks AI Security Framework 3.0

AID-E-001.004: Delegated Grant & Connected-App Authorization Revocation | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version