diff --git a/.claude/skills/deploy-agent-aks-auid/.env.example b/.claude/skills/deploy-agent-aks-auid/.env.example new file mode 100644 index 0000000..1582d16 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/.env.example @@ -0,0 +1,32 @@ +# AUID-on-AKS — environment template (auth-sidecar pattern) +# Copy to .env and fill in. Do NOT commit .env. + +# === Tenant === +TENANT_ID= + +# === Blueprint + Agent Identity (from entra-agent-id-setup workflow) === +BLUEPRINT_APP_ID= +AGENT_IDENTITY_APP_ID= + +# === Agentic User (provisioned by scripts/01-provision-agentic-user.ps1) === +AGENT_USER_UPN= +AGENT_USER_OBJECT_ID= +AGENT_USER_MAIL_NICKNAME=digitalworker01 + +# === Weather Agent (separate app registration — REQUIRED for proper JWT +# signature verification; the AUID token is minted for THIS audience). === +# Run scripts/04-register-weather-app.ps1 to create + grant consent. +WEATHER_AGENT_APP_ID= +WEATHER_AGENT_APP_ID_URI=api://weather-agent +WEATHER_AGENT_SCOPE=Weather.Read + +# === Sidecar (in-pod) === +SIDECAR_URL=http://localhost:5000 + +# === In-cluster service URL (used by backend in AKS) === +WEATHER_AGENT_URL=http://weather-agent.auid.svc.cluster.local:8080 + +# === Local dev ports (only used outside AKS) === +BACKEND_PORT=7100 +WEATHER_AGENT_PORT=7200 +UI_PORT=7000 diff --git a/.claude/skills/deploy-agent-aks-auid/PERMISSIONS.md b/.claude/skills/deploy-agent-aks-auid/PERMISSIONS.md new file mode 100644 index 0000000..052e406 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/PERMISSIONS.md @@ -0,0 +1,102 @@ +# AUID Permissions & Scopes — Customer Checklist + +This is the **definitive list** of every Entra permission, scope, role assignment, app role, federated identity credential, and oauth2PermissionGrant required for the AUID flow to work end-to-end. The `scripts/00-preflight-check.ps1` script verifies each item programmatically and reports PASS / FAIL / WARN per row. **Run preflight first.** + +--- + +## 1. Admin operator — delegated scopes needed to run the demo + +The human who runs the setup scripts must sign in as a **Cloud Application Administrator** (or higher) and consent to these delegated Graph scopes on the well-known Microsoft Graph PowerShell client (`14d82eec-204b-4c2f-b7e8-296a70dab67e`): + +| Scope | Why | +|---|---| +| `Application.ReadWrite.All` | Read/create app registrations; mint Blueprint client secret if missing | +| `AppRoleAssignment.ReadWrite.All` | Grant the Blueprint SP the Graph app role `AgentIdUser.ReadWrite.IdentityParentedBy` | +| `DelegatedPermissionGrant.ReadWrite.All` | Create the `oauth2PermissionGrant` (AllPrincipals) that lets the Agentic User call Graph | +| `Directory.Read.All` | Resolve service principals by appId | +| `User.Read` | Identify the signed-in operator | + +The preflight script decodes the issued admin token and verifies every scope above is present in `scp`. Missing scopes → FAIL with a remediation hint. + +--- + +## 2. Blueprint app — what it needs in Entra + +| Item | Required? | Verified by preflight? | How to grant | +|---|---|---|---| +| App registration exists in the tenant | ✅ Yes | ✅ | Portal → App registrations → New registration | +| Service principal for the app exists | ✅ Yes | ✅ | Portal → Enterprise apps → New application → from app registration | +| Graph **app role** `AgentIdUser.ReadWrite.IdentityParentedBy` (roleId `4aa6e624-eee0-40ab-bdd8-f9639038a614`) granted **with admin consent** | ✅ Yes — this is the role that authorizes creation of `microsoft.graph.agentUser` objects parented to your Agent Identity | ✅ | `POST /v1.0/servicePrincipals/{bpSpId}/appRoleAssignments` with `{ principalId, resourceId: , appRoleId: 4aa6e624-... }` | +| At least one **non-expired client secret** | ✅ Yes (for steps 03.01 and 03.03) | ✅ (also warns if <14 days from expiry) | Portal → Certificates & secrets → New client secret, or `POST /applications/{id}/addPassword` | + +> **Why an app role and not a delegated scope?** Creating a `microsoft.graph.agentUser` parented to your Agent Identity is an **app-only** Graph operation. A delegated admin token — even Global Admin — gets `403 Authorization_RequestDenied`. The Blueprint app must hold the application permission and use client_credentials to call Graph. + +--- + +## 3. Agent Identity app — what it needs in Entra + +| Item | Required? | Verified by preflight? | How to grant | +|---|---|---|---| +| App registration exists | ✅ Yes | ✅ | Portal → App registrations → New registration (with `agentApplication` extension settings against the Blueprint) | +| Service principal exists | ✅ Yes | ✅ | Required so the Agentic User can hold oauth2PermissionGrants against it | +| **Federated Identity Credential** trusting the Blueprint app (for jwt-bearer in step 03.02) | ✅ Yes | ✅ (warns if zero FICs) | `POST /applications/{aiAppObjectId}/federatedIdentityCredentials` with issuer = Entra v2 tenant issuer, subject = Blueprint appId, audience = `api://AzureADTokenExchange` | + +--- + +## 4. Agentic User — delegated Graph permissions + +The `microsoft.graph.agentUser` itself does not have its own consent UI. We grant **delegated** Graph scopes for it via `oauth2PermissionGrants` **for AllPrincipals** on the Agent Identity service principal. Without this grant, the AUID token will be issued but `GET /v1.0/me` returns 403. + +| Item | Required? | Verified by preflight? | How to grant | +|---|---|---|---| +| `oauth2PermissionGrant` on Agent Identity SP → Graph SP, `consentType=AllPrincipals` | ✅ Yes | ✅ | `scripts/02-grant-agentic-user-consent.ps1` — or `POST /v1.0/oauth2PermissionGrants` with `clientId=`, `resourceId=`, `consentType=AllPrincipals`, `scope="User.Read"` | +| `User.Read` is in the granted scope string | ✅ Yes (for Graph `/me`) | ✅ | Same as above | + +> **Pitfall:** Do **not** use the browser admin-consent URL (`https://login.microsoftonline.com/{tenant}/adminconsent?...&scope=User.Read+GroupMember.Read.All`) — the consent prompt page splits multi-word scopes incorrectly and you get `AADSTS650053: scope 'GroupMember.Read' that doesn't exist`. Grant via Graph `oauth2PermissionGrants` directly. + +--- + +## 5. Dedicated Weather Agent app — REQUIRED for verifiable signatures + +> **Status changed:** in earlier revisions of this repo, this section was *optional* because the AUID token was minted for `https://graph.microsoft.com/.default` and the Weather Agent fell back to claim-only validation. The AKS manifests now perform **full RS256 JWKS signature verification** in the Weather Agent, so the AUID token must be minted for the **Weather Agent's own audience**. A separate Weather Agent app registration is therefore mandatory. + +`scripts/04-register-weather-app.ps1` automates everything in this section. Run it as part of Phase 1. + +| Item | Required? | Verified by preflight? | How to grant | +|---|---|---|---| +| A separate app registration for the Weather Agent | ✅ Yes | ❌ (created by `scripts/04-register-weather-app.ps1`) | Portal → App registrations → New registration, or run the script | +| `identifierUris = [api://]` | ✅ Yes | ❌ | `PATCH /applications/{id}` (script does this) | +| Exposed scope `Weather.Read` | ✅ Yes | ❌ | `PATCH /applications/{id}` adding to `api.oauth2PermissionScopes` (script does this) | +| Service principal for the Weather Agent app | ✅ Yes | ❌ | Same flow as Blueprint SP (script does this) | +| `oauth2PermissionGrant` on Agent Identity SP → Weather Agent SP (`consentType=AllPrincipals`, `scope="Weather.Read"`) | ✅ Yes | ❌ | `POST /v1.0/oauth2PermissionGrants` (script does this) | +| `WEATHER_AGENT_APP_ID` and `WEATHER_AGENT_APP_ID_URI` set in `/tmp/deploy-vars.sh` | ✅ Yes | ❌ | Copy from the script's printed output before running `deploy-aks-dev.sh` | + +When all of the above are present: +- The auth-sidecar's `DownstreamApis__weather__Scopes__0` env resolves to `api:///.default`. +- The issued AUID token has `aud=api://` and **no header `nonce`** (so it's verifiable by anyone holding the tenant JWKS). +- `weather-agent/app.py` performs full RS256 signature validation against `https://login.microsoftonline.com//discovery/v2.0/keys` plus claim checks (`iss`, `tid`, `aud`, `appid`, `idtyp=user`, `upn`, `exp`). + +If the Weather Agent app or its admin-consent grant is missing, the auth-sidecar surfaces it as `AADSTS65001` / `consent_required` when you hit `/api/step/03-auid-token`. + +--- + +## 6. Federated Identity Credential — what to verify after onboarding + +`agentApplication` setups normally create the FIC on the Agent Identity app trusting the Blueprint automatically. Confirm by listing FICs: + +```http +GET https://graph.microsoft.com/v1.0/applications/{agentIdAppObjectId}/federatedIdentityCredentials +``` + +You should see at least one credential whose `issuer` matches your Blueprint app's issuer and whose `audiences` includes `api://AzureADTokenExchange`. + +--- + +## 7. What the preflight script does NOT verify + +- **Conditional Access policies that block client_credentials or device-code on this tenant.** If 03.01 returns `AADSTS50079: Strong authentication is required` or similar, your CA policy needs a carve-out. +- **Network reachability** to `login.microsoftonline.com` and `graph.microsoft.com` from the host running the demo. +- **Time skew** on the demo host (tokens have `nbf`/`exp` — if your clock is >5 min off, validation will fail). +- **Whether the Agentic User itself is enabled and unblocked** (rare, but the Agentic User is a real user object subject to user lifecycle policies). + +These are the items to manually validate if preflight passes but the chain still fails. diff --git a/.claude/skills/deploy-agent-aks-auid/SKILL.md b/.claude/skills/deploy-agent-aks-auid/SKILL.md new file mode 100644 index 0000000..71986f1 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/SKILL.md @@ -0,0 +1,233 @@ +--- +name: deploy-agent-aks-auid +description: Provision and deploy the Agent ID User (AUID) demo on AKS using the Microsoft Entra SDK auth-sidecar. Use when the user mentions "AUID", "Agent ID User", "microsoft.graph.agentUser", "digital colleague identity", "AUID on AKS", or wants to demo "an agent acting as its own user" (the non-OBO complement to deploy-agent-aks-dev). The skill is fully self-contained: tenant-setup PowerShell scripts, AKS orchestrator + manifests, and the FastAPI broker / Weather Agent / UI source all live under this skill folder. The auth-sidecar (`mcr.microsoft.com/entra-sdk/auth-sidecar`) performs the full Blueprint → Agent ID → user_fic chain INSIDE the pod; the app code never calls login.microsoftonline.com. +--- + +# Deploy AUID demo (Agent ID User on AKS) + +## When to use this skill +Trigger when the user wants to demonstrate Agent ID User (AUID) — the mode where an Entra Agent Identity has its **own first-class user object** (`microsoft.graph.agentUser`) and the agent calls downstream services **as itself** (no human in the loop). + +This is the AUID analog of [`deploy-agent-aks-dev`](../deploy-agent-aks-dev/SKILL.md) (autonomous) and [`deploy-agent-aca-dev`](../deploy-agent-aca-dev/SKILL.md) (OBO). Same Workload Identity + auth-sidecar pattern, specialized for `grant_type=user_fic`. + +Do NOT use this skill for: +- **Autonomous Agent flows** (no user dimension) → use [`deploy-agent-aks-dev`](../deploy-agent-aks-dev/SKILL.md). +- **On-Behalf-Of flows** (real human signs in, agent acts on their behalf) → use [`deploy-agent-aks-dev`](../deploy-agent-aks-dev/SKILL.md) with the OBO scripts. +- **"Local dev only" demos.** The auth-sidecar requires Workload Identity (`SignedAssertionFilePath` from a projected SA token); running it locally would require shipping a `BLUEPRINT_CLIENT_SECRET` into the container, which is exactly what this architecture eliminates. AKS (or kind with WI add-ons) only. + +## Outcome +After completing this skill the customer will have: +- A `microsoft.graph.agentUser` provisioned in their tenant, parented to their existing Agent Identity app. +- A dedicated **Weather Agent** Entra app registration with an exposed scope, admin-consented for the Agent Identity → Weather Agent grant. Required so the AUID JWT can be **signature-verified** by the downstream service. +- An AKS cluster with OIDC issuer + Workload Identity enabled, an attached ACR with the three demo images, and a Federated Identity Credential on the Blueprint trusting `system:serviceaccount:auid:backend-sa`. +- The demo running at `http:///` with the four-step UI rendering the live AUID acquisition trace served by the `mcr.microsoft.com/entra-sdk/auth-sidecar` container co-located with the FastAPI broker pod. + +**Canonical assets:** [`scripts/`](./scripts/) (Phase 1 — Entra setup), [`deploy/aks/scripts/`](./deploy/aks/scripts/) and [`deploy/aks/manifests/`](./deploy/aks/manifests/) (Phase 2 — AKS deploy), [`backend/`](./backend/), [`weather-agent/`](./weather-agent/), [`ui/`](./ui/) (app code built into the three container images). Permissions/scopes reference: [`PERMISSIONS.md`](./PERMISSIONS.md). + +## Prerequisites +1. **Entra role** on the signing-in operator: `Global Administrator`, `Cloud Application Administrator`, or `Agent ID Administrator`. The preflight script verifies the operator's delegated Graph scopes; missing scopes are reported FAIL. +2. **Existing Blueprint + Agent Identity apps.** If the customer doesn't have these yet, run [`entra-agent-id-setup`](../entra-agent-id-setup/SKILL.md) first to mint them. +3. **Blueprint must hold the Graph application role** `AgentIdUser.ReadWrite.IdentityParentedBy` (roleId `4aa6e624-eee0-40ab-bdd8-f9639038a614`). Required so the Blueprint can create the `microsoft.graph.agentUser` parented to the Agent Identity. Preflight verifies this. +4. **Azure subscription** with permission to create a resource group, ACR, and AKS cluster. Quota for ~2 `Standard_D2s_v5` nodes. +5. `pwsh` 7.x, `az` CLI, and `kubectl`. All scripts are PowerShell — no bash required. + +> **Cross-tenant deployment** (Azure subscription in tenant A, Entra Agent ID objects in tenant B) is supported. Set `$env:SUBSCRIPTION_TENANT_ID` in your `deploy-vars.ps1`. Default behavior is single-tenant. + +## Architecture (what the SDK actually does for you) +``` +ServiceAccount auid/backend-sa + │ Workload Identity webhook projects an SA token at + │ /var/run/secrets/azure/tokens/azure-identity-token + ▼ +FIC on Blueprint app (subject = system:serviceaccount:auid:backend-sa, + audience = api://AzureADTokenExchange) + │ + ▼ +Auth sidecar (localhost:5000) + reads the SA token via SignedAssertionFilePath, runs the full + Blueprint FIC → Agent ID FIC → user_fic chain INTERNALLY + │ + ▼ +Backend container gets a fully-formed `Authorization: Bearer ` header + │ + ▼ +Weather Agent (separate Entra app) verifies signature against tenant JWKS, +checks aud / appid / idtyp=user / upn, then serves the request. +``` + +The backend code ([`backend/sidecar_client.py`](./backend/sidecar_client.py)) **never touches `login.microsoftonline.com`** — it only does a single GET to `http://localhost:5000/AuthorizationHeaderUnauthenticated/weather?AgentIdentity=...&AgentUsername=...`. + +## Pre-flight checklist (DO NOT SKIP) + +```powershell +pwsh -NoProfile -File .claude/skills/deploy-agent-aks-auid/scripts/00-preflight-check.ps1 +``` + +The script signs the operator in (device code) and produces a colored PASS / FAIL / WARN report for every permission, scope, app role, service principal, and federated identity credential required by the AUID flow. It exits non-zero if anything is FAIL, so you can wire it into CI. + +See [`PERMISSIONS.md`](./PERMISSIONS.md) for the complete list of what's checked and how to remediate each FAIL — broken down by: +- **Admin operator** (delegated Graph scopes needed to run the scripts) +- **Blueprint app** (Graph **app role** `AgentIdUser.ReadWrite.IdentityParentedBy` + SP) +- **Agent Identity app** (SP + Federated Identity Credential trusting the Blueprint) +- **Agentic User** (delegated `oauth2PermissionGrant` for `User.Read` AllPrincipals on Agent Identity SP → Graph SP) +- **Weather Agent app** (separate registration with exposed scope + admin-consent grant — **required**, no longer optional) + +If preflight fails, **do not run any later script** — talk through the FAIL rows first. Common blockers: +1. Blueprint SP missing app role `AgentIdUser.ReadWrite.IdentityParentedBy` → step 1.1 returns `403 Authorization_RequestDenied`. +2. Agent Identity app has no Federated Identity Credential trusting the Blueprint → sidecar token chain fails with `AADSTS700016` / `invalid_client`. +3. Admin's delegated token missing `AppRoleAssignment.ReadWrite.All` → can't grant the Blueprint app role programmatically. +4. Multi-scope browser admin-consent URL splitting `GroupMember.Read.All` → `AADSTS650053` — use step 1.2 script which posts to `oauth2PermissionGrants` directly. +5. Weather Agent app not registered or admin consent missing → sidecar logs `AADSTS65001` / `consent_required` when fetching the AUID token. + +--- + +## Phase 1 — One-time Entra setup (PowerShell, from your laptop) + +### Step 1.1 — Provision the Agentic User +```powershell +pwsh -NoProfile -File .claude/skills/deploy-agent-aks-auid/scripts/01-provision-agentic-user.ps1 ` + -TenantId ` + -BlueprintAppId ` + -AgentIdentityAppId +``` +Uses an app-only token from the Blueprint (with the `AgentIdUser.ReadWrite.IdentityParentedBy` app role) to POST `/v1.0/users` with `@odata.type=#microsoft.graph.agentUser` and `identityParentId=`. Prints `AGENT_USER_UPN` and `AGENT_USER_OBJECT_ID` — copy these for Phase 2. + +> Running this with a delegated admin token (even Global Admin) returns `403 Authorization_RequestDenied`. The Blueprint **must** hold the application role. + +### Step 1.2 — Grant the Agentic User delegated Graph access +```powershell +pwsh -NoProfile -File .claude/skills/deploy-agent-aks-auid/scripts/02-grant-agentic-user-consent.ps1 ` + -TenantId ` + -AgentIdentityAppId +``` +Grants `User.Read` for `AllPrincipals` on the Agent Identity service principal via `oauth2PermissionGrants`. We do this programmatically — **not** via the browser admin-consent URL, because the consent prompt page incorrectly splits `GroupMember.Read.All` into `GroupMember.Read` and fails with `AADSTS650053`. + +### Step 1.3 — Register the Weather Agent app (REQUIRED) +```powershell +pwsh -NoProfile -File .claude/skills/deploy-agent-aks-auid/scripts/04-register-weather-app.ps1 ` + -TenantId ` + -AgentIdentityAppId +``` +- Creates a separate Entra app registration for the Weather Agent. +- Sets `identifierUris = [api://]` and exposes a `Weather.Read` scope. +- Creates the SP and grants the Agent Identity SP an admin-consented `oauth2PermissionGrant` (AllPrincipals) for `Weather.Read` on the Weather SP. +- Prints `WEATHER_AGENT_APP_ID` and `WEATHER_AGENT_APP_ID_URI` — copy these for Phase 2. + +> **Why this is required** (it used to be optional): the previous revision asked the AUID token for `https://graph.microsoft.com/.default`. Graph tokens carry a `nonce` claim in the JWT header that prevents third parties from cryptographically verifying the signature. The Weather Agent now performs **full RS256 verification against the tenant JWKS**, so the AUID token must be issued for the Weather Agent's own audience. + +--- + +## Phase 2 — Deploy to AKS + +### Step 2.1 — Fill in deploy-vars +```powershell +cp .claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/deploy-vars.ps1.template ~/deploy-vars-auid.ps1 +# Edit ~/deploy-vars-auid.ps1 — set: +# TENANT_ID, SUBSCRIPTION_ID, RG, LOCATION, AKS_NAME, ACR_NAME (globally unique), +# BLUEPRINT_APP_ID, AGENT_IDENTITY_APP_ID, +# AGENT_USER_UPN, AGENT_USER_OBJECT_ID (from Step 1.1), +# WEATHER_AGENT_APP_ID, WEATHER_AGENT_APP_ID_URI (from Step 1.3) +$env:VARS_FILE = "$HOME/deploy-vars-auid.ps1" +``` + +### Step 2.2 — Run the orchestrator +```powershell +. $env:VARS_FILE +az login --tenant ($env:SUBSCRIPTION_TENANT_ID ?? $env:TENANT_ID) +az account set --subscription $env:SUBSCRIPTION_ID + +pwsh -NoProfile -File .claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/deploy-aks-dev.ps1 +``` + +The orchestrator does: +1. [`01-create-aks.ps1`](./deploy/aks/scripts/01-create-aks.ps1) — RG + ACR + AKS (OIDC issuer + Workload Identity on, attach-acr). Appends `OIDC_ISSUER` to deploy-vars. +2. [`02-build-and-push.ps1`](./deploy/aks/scripts/02-build-and-push.ps1) — `az acr build` for `backend`, `weather-agent`, `ui`. +3. [`03-federate-blueprint.ps1`](./deploy/aks/scripts/03-federate-blueprint.ps1) — adds the FIC `system:serviceaccount:auid:backend-sa` to the Blueprint app (audience `api://AzureADTokenExchange`). +4. [`04-apply-manifests.ps1`](./deploy/aks/scripts/04-apply-manifests.ps1) — PowerShell string substitution + `kubectl apply` for [`00-namespace`](./deploy/aks/manifests/00-namespace.yaml), [`10-serviceaccount`](./deploy/aks/manifests/10-serviceaccount.yaml), [`20-weather-agent`](./deploy/aks/manifests/20-weather-agent.yaml), [`30-ui`](./deploy/aks/manifests/30-ui.yaml) (LoadBalancer), [`40-backend`](./deploy/aks/manifests/40-backend.yaml) (broker + `mcr.microsoft.com/entra-sdk/auth-sidecar` co-located in the same pod). + +When the LB IP is assigned, open `http:///` and click through the 4-step demo. Step 3 calls the sidecar (which performs the full Blueprint→AgentID→user_fic chain internally and returns the AUID Authorization header); step 4 hits the Weather Agent and shows the validated AUID claims. + +--- + +## Phase 3 — Smoke-test the AUID acquisition + +```powershell +kubectl get pods -n auid + +# Sidecar logs — look for "Acquired token for downstream API 'weather'" +kubectl logs -n auid -l app=backend -c sidecar --tail=80 + +# Backend logs +kubectl logs -n auid -l app=backend -c backend --tail=80 + +# Hit the broker's step-03 endpoint from inside the pod: +kubectl exec -n auid deploy/backend -c backend -- ` + curl -s -X POST http://localhost:8080/api/step/03-auid-token +``` + +Expected: `"ok": true`, an `authorization_header_preview` like `Bearer eyJ...`, and the request showing `AgentIdentity=` + `AgentUsername=`. + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|--|--|--| +| Step 1.1 returns `403 Authorization_RequestDenied` | Trying to create `agentUser` with a delegated admin token | Blueprint SP must hold the **application** role `AgentIdUser.ReadWrite.IdentityParentedBy`. Grant via `POST /servicePrincipals/{bpSpId}/appRoleAssignments`. | +| Step 1.2 fails `AADSTS650053` (`GroupMember.Read doesn't exist`) | Multi-scope browser admin-consent URL splits scopes wrong | Use [`scripts/02-grant-agentic-user-consent.ps1`](./scripts/02-grant-agentic-user-consent.ps1) instead of the browser. | +| `deploy-aks-dev.ps1` exits at start with `ERROR: $WEATHER_AGENT_APP_ID unset` | Skipped Step 1.3 | Run [`scripts/04-register-weather-app.ps1`](./scripts/04-register-weather-app.ps1), copy the printed values into your `deploy-vars.ps1`, re-dot-source. | +| Sidecar logs `AADSTS700016` / `invalid_client` | Blueprint FIC for `system:serviceaccount:auid:backend-sa` not present | Re-run [`deploy/aks/scripts/03-federate-blueprint.ps1`](./deploy/aks/scripts/03-federate-blueprint.ps1). Confirm `OIDC_ISSUER` was appended to deploy-vars and re-sourced. | +| Sidecar logs `AADSTS65001` / `consent_required` | Agent Identity → Weather Agent admin consent missing | Re-run [`scripts/04-register-weather-app.ps1`](./scripts/04-register-weather-app.ps1), or grant via `POST /v1.0/oauth2PermissionGrants` (`clientId=`, `resourceId=`, `consentType=AllPrincipals`, `scope="Weather.Read"`). | +| `/api/step/03-auid-token` returns connection-refused | Sidecar container not running, or `SIDECAR_URL` env wrong | `kubectl describe pod` and check the `sidecar` container is Ready. Manifest expects `SIDECAR_URL=http://localhost:5000`. | +| Weather Agent returns `Signature verification failed` | AUID token was issued for Graph audience instead of Weather Agent | Verify `WEATHER_AGENT_APP_ID_URI` is set in the manifest env and that `DownstreamApis__weather__Scopes__0` resolves to `api:///.default` in the sidecar config. | +| `Connect-MgGraph -UseDeviceCode` device code never prints | PowerShell async pipe doesn't flush the prompt | Hit `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/devicecode` directly with `Invoke-RestMethod` to surface the code. | +| PS 5.1 fails to load Microsoft.Graph 2.x | Module needs PS7 | Use `pwsh`. | + +--- + +## Files in this skill + +### Phase 1 — Entra setup ([`scripts/`](./scripts/)) +- [`scripts/00-preflight-check.ps1`](./scripts/00-preflight-check.ps1) — verifies every Entra prerequisite, PASS/FAIL/WARN report. +- [`scripts/01-provision-agentic-user.ps1`](./scripts/01-provision-agentic-user.ps1) — creates the `microsoft.graph.agentUser` parented to the Agent Identity. +- [`scripts/02-grant-agentic-user-consent.ps1`](./scripts/02-grant-agentic-user-consent.ps1) — grants delegated `User.Read` for AllPrincipals (Agent Identity → Graph). +- [`scripts/04-register-weather-app.ps1`](./scripts/04-register-weather-app.ps1) — registers the Weather Agent app, exposes `Weather.Read`, grants admin consent (Agent Identity → Weather Agent). + +### Phase 2 — AKS deploy ([`deploy/aks/`](./deploy/aks/)) +- [`deploy/aks/scripts/deploy-vars.ps1.template`](./deploy/aks/scripts/deploy-vars.ps1.template) — variables file you copy and dot-source. +- [`deploy/aks/scripts/01-create-aks.ps1`](./deploy/aks/scripts/01-create-aks.ps1) — RG + ACR + AKS (OIDC + WI on, attach-acr). +- [`deploy/aks/scripts/02-build-and-push.ps1`](./deploy/aks/scripts/02-build-and-push.ps1) — `az acr build` for the three images. +- [`deploy/aks/scripts/03-federate-blueprint.ps1`](./deploy/aks/scripts/03-federate-blueprint.ps1) — adds the FIC on the Blueprint app. +- [`deploy/aks/scripts/04-apply-manifests.ps1`](./deploy/aks/scripts/04-apply-manifests.ps1) — PowerShell string substitution + `kubectl apply` for everything in `manifests/`. +- [`deploy/aks/scripts/deploy-aks-dev.ps1`](./deploy/aks/scripts/deploy-aks-dev.ps1) — one-shot orchestrator. +- [`deploy/aks/manifests/`](./deploy/aks/manifests/) — `00-namespace`, `10-serviceaccount`, `20-weather-agent`, `30-ui` (LB), `40-backend` (broker + auth-sidecar containers). + +### Application code (built into the three container images) +- [`backend/app.py`](./backend/app.py) — FastAPI broker exposing each step + a one-shot `/api/call-weather` endpoint. Calls only the sidecar — never `login.microsoftonline.com`. +- [`backend/sidecar_client.py`](./backend/sidecar_client.py) — thin async wrapper around `GET /AuthorizationHeaderUnauthenticated/?AgentIdentity=...&AgentUsername=...`. +- [`weather-agent/app.py`](./weather-agent/app.py) — downstream service; performs full RS256 signature + claim validation against the tenant JWKS. +- [`ui/index.html`](./ui/index.html), [`ui/nginx.conf`](./ui/nginx.conf) — single-page UI; nginx proxies `/api/*` to the backend Service. +- [`.env.example`](./.env.example) — local sample of the env vars; the AKS manifests source the real values from `/tmp/deploy-vars.sh` via `envsubst`. + +--- + +## Customer hand-off checklist + +- [ ] Customer ran [`scripts/00-preflight-check.ps1`](./scripts/00-preflight-check.ps1) and **all rows are PASS** (warnings may be acceptable — review with them). +- [ ] Tenant ID, Blueprint App ID, Agent Identity App ID confirmed with customer. +- [ ] Blueprint SP holds `AgentIdUser.ReadWrite.IdentityParentedBy` (app role). +- [ ] Agent Identity has a FIC trusting the Blueprint app. +- [ ] Agentic User provisioned and Agent Identity → Graph `User.Read` AllPrincipals grant exists. +- [ ] Weather Agent app registered, scope exposed, Agent Identity → Weather Agent admin-consent grant exists. +- [ ] `deploy-aks-dev.ps1` completed without error and `kubectl get pods -n auid` shows `backend` (2/2 — backend + sidecar), `weather-agent`, `ui` all `Running`. +- [ ] `kubectl logs -n auid -l app=backend -c sidecar` shows `Acquired token for downstream API 'weather'`. +- [ ] LB UI at `http:///` shows green PASS rows for steps 1–4 and a real weather response with the validated AUID claims. +- [ ] Customer understands the **OBO vs AUID** distinction. +- [ ] Customer understands that **local-only execution is not supported** and why (Workload Identity prerequisite). +- [ ] Customer has a copy of [`PERMISSIONS.md`](./PERMISSIONS.md) for ongoing reference. + +--- + +## Paired skills + +- **Teardown:** [`teardown-agent-aks-auid`](../teardown-agent-aks-auid/SKILL.md) — reverses this skill. DRY-RUN by default. Removes k8s namespace, RG, FIC, Weather Agent app, Agentic User, and (opt-in) Entra apps. diff --git a/.claude/skills/deploy-agent-aks-auid/backend/Dockerfile b/.claude/skills/deploy-agent-aks-auid/backend/Dockerfile new file mode 100644 index 0000000..844666e --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/backend/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV BACKEND_PORT=8080 +EXPOSE 8080 +CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/.claude/skills/deploy-agent-aks-auid/backend/app.py b/.claude/skills/deploy-agent-aks-auid/backend/app.py new file mode 100644 index 0000000..a74c10c --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/backend/app.py @@ -0,0 +1,271 @@ +""" +backend/app.py — FastAPI broker that demonstrates AUID acquisition via the +Microsoft Entra SDK auth-sidecar (mcr.microsoft.com/entra-sdk/auth-sidecar), +then calls a downstream Weather Agent with the resulting AUID bearer. + +Identity model on AKS: + + Kubernetes ServiceAccount auid/backend-sa + │ (Workload Identity webhook projects an SA token at + │ /var/run/secrets/azure/tokens/azure-identity-token) + ▼ + FIC on Blueprint app (audience api://AzureADTokenExchange, + subject system:serviceaccount:auid:backend-sa) + │ + ▼ + Auth sidecar (localhost:5000) holds the Blueprint credential as + SignedAssertionFilePath → uses it to run the user_fic grant for + the configured AGENT_USER_UPN against the WEATHER_AGENT scope. + +This app NEVER talks to login.microsoftonline.com directly. It only calls +http://localhost:5000/AuthorizationHeaderUnauthenticated/ with +AgentIdentity + AgentUsername. +""" +from __future__ import annotations + +import os +from typing import Any, Dict + +import httpx +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware + +from sidecar_client import SidecarClient + +load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env")) + +TENANT_ID = os.getenv("TENANT_ID", "") +BLUEPRINT_APP_ID = os.getenv("BLUEPRINT_APP_ID", "") +AGENT_IDENTITY_APP_ID = os.getenv("AGENT_IDENTITY_APP_ID", "") +AGENT_USER_UPN = os.getenv("AGENT_USER_UPN", "") +AGENT_USER_OBJECT_ID = os.getenv("AGENT_USER_OBJECT_ID", "") +WEATHER_AGENT_APP_ID = os.getenv("WEATHER_AGENT_APP_ID", "") +WEATHER_AGENT_APP_ID_URI = os.getenv("WEATHER_AGENT_APP_ID_URI", "") +WEATHER_AGENT_SCOPE = os.getenv("WEATHER_AGENT_SCOPE", "Weather.Read") +WEATHER_AGENT_URL = os.getenv( + "WEATHER_AGENT_URL", f"http://localhost:{os.getenv('WEATHER_AGENT_PORT', '7200')}" +) + +# Name MUST match the DownstreamApis____BaseUrl key in the sidecar env. +WEATHER_SVC_NAME = os.getenv("WEATHER_SVC_NAME", "weather") + +app = FastAPI(title="AUID-on-AKS — SDK Broker (auth-sidecar)") +app.add_middleware( + CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] +) + +sidecar = SidecarClient.from_env() + + +def _require_env() -> None: + missing = [ + k for k in ( + "TENANT_ID", "BLUEPRINT_APP_ID", "AGENT_IDENTITY_APP_ID", "AGENT_USER_UPN", + ) if not os.getenv(k) + ] + if missing: + raise HTTPException(500, f"Missing env vars: {missing}") + + +@app.get("/api/health") +async def health(): + weather_status = "offline" + try: + async with httpx.AsyncClient(timeout=2.0) as c: + r = await c.get(f"{WEATHER_AGENT_URL}/health") + if r.status_code == 200: + weather_status = "online" + except Exception: + pass + return { + "ok": True, + "config_loaded": bool(TENANT_ID and BLUEPRINT_APP_ID and AGENT_IDENTITY_APP_ID), + "sidecar_reachable": await sidecar.health(), + "sidecar_url": sidecar.base_url, + "weather_agent": weather_status, + } + + +@app.get("/api/config") +async def config(): + _require_env() + return { + "tenant_id": TENANT_ID, + "blueprint_app_id": BLUEPRINT_APP_ID, + "agent_identity_app_id": AGENT_IDENTITY_APP_ID, + "agent_user_upn": AGENT_USER_UPN, + "weather_agent_app_id": WEATHER_AGENT_APP_ID, + "weather_agent_url": WEATHER_AGENT_URL, + "sidecar_url": sidecar.base_url, + "downstream_svc_name": WEATHER_SVC_NAME, + "downstream_scope": ( + f"{WEATHER_AGENT_APP_ID_URI}/.default" if WEATHER_AGENT_APP_ID_URI else None + ), + } + + +# --------------------------------------------------------------------------- +# The 4 demo steps. With the SDK + sidecar, the app no longer sees the +# Blueprint FIC → Agent ID FIC → user_fic hops directly — they happen INSIDE +# the sidecar. These endpoints narrate the equivalent SDK actions so the +# UI can still show a 4-step story. +# --------------------------------------------------------------------------- + + +@app.post("/api/step/01-blueprint-fic") +async def step1_workload_identity(): + """Step 1 — Workload Identity badge: K8s mounts a signed SA token at + /var/run/secrets/azure/tokens/azure-identity-token. The sidecar reads it + via SignedAssertionFilePath and uses it as the Blueprint client credential.""" + _require_env() + token_path = "/var/run/secrets/azure/tokens/azure-identity-token" + badge_present = os.path.isfile(token_path) + badge_size = os.path.getsize(token_path) if badge_present else 0 + return { + "step": "01", + "description": "AKS Workload Identity → Blueprint credential (no secret)", + "request": {"reads_file": token_path}, + "response": { + "badge_mounted": badge_present, + "badge_size_bytes": badge_size, + "blueprint_app_id": BLUEPRINT_APP_ID, + "subject": f"system:serviceaccount:{os.getenv('POD_NAMESPACE', 'auid')}:" + f"{os.getenv('SERVICE_ACCOUNT', 'backend-sa')}", + "federated_to": "Blueprint app (audience api://AzureADTokenExchange)", + }, + "note": "Outside AKS this file won't exist — that's expected for local dev.", + } + + +@app.post("/api/step/02-agentid-fic") +async def step2_sidecar_config(): + """Step 2 — Auth-sidecar configured: the sidecar knows the Blueprint + client id + downstream API definitions. We don't see the per-hop tokens; + we trust the SDK to chain Blueprint FIC → Agent ID FIC internally.""" + _require_env() + return { + "step": "02", + "description": "Auth-sidecar configuration (Blueprint + downstream APIs)", + "request": { + "sidecar_image": "mcr.microsoft.com/entra-sdk/auth-sidecar", + "AzureAd__ClientId": BLUEPRINT_APP_ID, + "AzureAd__ClientCredentials__0__SourceType": "SignedAssertionFilePath", + f"DownstreamApis__{WEATHER_SVC_NAME}__BaseUrl": WEATHER_AGENT_URL, + f"DownstreamApis__{WEATHER_SVC_NAME}__Scopes__0": ( + f"{WEATHER_AGENT_APP_ID_URI}/.default" + if WEATHER_AGENT_APP_ID_URI else "(unset — set WEATHER_AGENT_APP_ID_URI)" + ), + }, + "response": { + "sidecar_reachable": await sidecar.health(), + "sidecar_url": sidecar.base_url, + }, + "note": ( + "The sidecar performs the Blueprint→AgentID FIC exchange (~03.01/03.02 of " + "the raw protocol) internally. The app never sees those intermediate tokens." + ), + } + + +@app.post("/api/step/03-auid-token") +async def step3_acquire_auid(): + """Step 3 — Acquire the AUID Authorization header from the sidecar. + The sidecar runs grant_type=user_fic with AgentUsername=.""" + _require_env() + if not WEATHER_AGENT_APP_ID_URI: + raise HTTPException( + 500, + "WEATHER_AGENT_APP_ID_URI not set — register the weather-agent app and " + "set its Application ID URI (e.g. api://).", + ) + result = await sidecar.get_auid_header( + service_name=WEATHER_SVC_NAME, + agent_identity=AGENT_IDENTITY_APP_ID, + agent_username=AGENT_USER_UPN, + agent_user_oid=AGENT_USER_OBJECT_ID or None, + ) + return { + "step": "03", + "description": "AUID token acquisition via sidecar (grant_type=user_fic)", + **result, + } + + +@app.post("/api/step/04-call-me") +async def step4_call_weather_default(): + """Step 4 — Use the AUID header to call the Weather Agent's /me-equivalent. + (We call /whoami which echoes the validated AUID claims back.)""" + _require_env() + res = await step3_acquire_auid() + if not res.get("ok"): + return { + "step": "04", + "description": "Call downstream as Agentic User", + "skipped": "no token from step 03", + "step03": res, + } + header = res["authorization_header"] + url = f"{WEATHER_AGENT_URL}/whoami" + async with httpx.AsyncClient(timeout=15) as c: + r = await c.get(url, headers={"Authorization": header}) + body: Any + try: + body = r.json() + except Exception: + body = {"_raw": r.text} + return { + "step": "04", + "description": "Call Weather Agent /whoami as Agentic User (no Graph involved)", + "request": {"url": url, "headers": {"Authorization": "Bearer "}}, + "response": {"status": r.status_code, "body": body}, + } + + +@app.get("/api/chain") +async def chain(): + """Walk the whole demo in one call.""" + s1 = await step1_workload_identity() + s2 = await step2_sidecar_config() + s3 = await step3_acquire_auid() + s4 = await step4_call_weather_default() + return {"01": s1, "02": s2, "03": s3, "04": s4} + + +@app.post("/api/call-weather") +async def call_weather(payload: dict): + """Acquire AUID + call the Weather Agent /weather endpoint for a city.""" + _require_env() + city = (payload or {}).get("city", "Dallas") + sc = await sidecar.get_auid_header( + service_name=WEATHER_SVC_NAME, + agent_identity=AGENT_IDENTITY_APP_ID, + agent_username=AGENT_USER_UPN, + agent_user_oid=AGENT_USER_OBJECT_ID or None, + ) + if not sc.get("ok"): + return {"auid_step": sc, "weather_call": {"skipped": "no token"}} + header = sc["authorization_header"] + url = f"{WEATHER_AGENT_URL}/weather" + async with httpx.AsyncClient(timeout=30) as c: + r = await c.get(url, params={"city": city}, headers={"Authorization": header}) + body: Any + try: + body = r.json() + except Exception: + body = {"_raw": r.text} + return { + "auid_step": sc, + "weather_call": { + "url": url, + "params": {"city": city}, + "status": r.status_code, + "body": body, + }, + } + + +if __name__ == "__main__": + import uvicorn + port = int(os.getenv("BACKEND_PORT", "7100")) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/.claude/skills/deploy-agent-aks-auid/backend/requirements.txt b/.claude/skills/deploy-agent-aks-auid/backend/requirements.txt new file mode 100644 index 0000000..f2ccda0 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/backend/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +httpx==0.27.2 +python-dotenv==1.0.1 diff --git a/.claude/skills/deploy-agent-aks-auid/backend/sidecar_client.py b/.claude/skills/deploy-agent-aks-auid/backend/sidecar_client.py new file mode 100644 index 0000000..a31b7d7 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/backend/sidecar_client.py @@ -0,0 +1,102 @@ +""" +sidecar_client.py — Thin async wrapper around the Microsoft Entra SDK +auth-sidecar HTTP API (mcr.microsoft.com/entra-sdk/auth-sidecar). + +The sidecar runs in the SAME pod as this backend, on localhost:5000. It +holds the Blueprint credential (Workload Identity → SignedAssertionFilePath) +and exposes: + + GET /AuthorizationHeaderUnauthenticated/ + ?AgentIdentity= + &AgentUsername= # AUID flow (grant_type=user_fic) + # or &AgentUserId= + # omit both for autonomous (app-only) flow + + GET /AuthorizationHeader/ # OBO with incoming user token (Bearer Tc) + ?AgentIdentity= + +This wrapper exposes only the AUID method — the whole purpose of this repo. +The token chain (Blueprint FIC → Agent ID FIC → user_fic) is performed +INSIDE the sidecar; this code never touches login.microsoftonline.com. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any, Dict, Optional + +import httpx + + +@dataclass +class SidecarClient: + base_url: str = "http://localhost:5000" + timeout: float = 30.0 + + @classmethod + def from_env(cls) -> "SidecarClient": + return cls(base_url=os.getenv("SIDECAR_URL", "http://localhost:5000")) + + async def get_auid_header( + self, + service_name: str, + agent_identity: str, + agent_username: Optional[str] = None, + agent_user_oid: Optional[str] = None, + ) -> Dict[str, Any]: + """Acquire an AUID Authorization header for the given downstream service. + + Returns a dict including the bearer header AND a redacted view of the + request — callers can show this in a UI panel to demonstrate "the SDK + does the user_fic chain for me". + """ + if not (agent_username or agent_user_oid): + raise ValueError("AUID requires agent_username (UPN) or agent_user_oid") + + url = f"{self.base_url}/AuthorizationHeaderUnauthenticated/{service_name}" + params: Dict[str, str] = {"AgentIdentity": agent_identity} + if agent_username: + params["AgentUsername"] = agent_username + if agent_user_oid: + params["AgentUserId"] = agent_user_oid + + async with httpx.AsyncClient(timeout=self.timeout) as c: + r = await c.get(url, params=params) + + # Surface non-2xx responses with the sidecar's error body for debugging. + if r.status_code >= 400: + try: + err_body = r.json() + except Exception: + err_body = {"_raw": r.text} + return { + "ok": False, + "request": {"url": url, "params": params}, + "response": {"status": r.status_code, "body": err_body}, + } + + body = r.json() + header = body.get("authorizationHeader", "") + token = header.split(" ", 1)[1] if header.startswith("Bearer ") else None + return { + "ok": True, + "request": {"url": url, "params": params}, + "response": { + "status": r.status_code, + "authorization_header_preview": ( + f"Bearer {token[:24]}...({len(token)} chars)" if token else header + ), + }, + "authorization_header": header, + "token": token, + } + + async def health(self) -> bool: + """Best-effort health probe. The sidecar always responds to GET / with 404, + which is fine — we only need to know the listener is reachable.""" + try: + async with httpx.AsyncClient(timeout=2.0) as c: + r = await c.get(f"{self.base_url}/") + return r.status_code < 500 + except Exception: + return False diff --git a/.claude/skills/deploy-agent-aks-auid/deploy/aks/manifests/00-namespace.yaml b/.claude/skills/deploy-agent-aks-auid/deploy/aks/manifests/00-namespace.yaml new file mode 100644 index 0000000..8396f2d --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/deploy/aks/manifests/00-namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: auid diff --git a/.claude/skills/deploy-agent-aks-auid/deploy/aks/manifests/10-serviceaccount.yaml b/.claude/skills/deploy-agent-aks-auid/deploy/aks/manifests/10-serviceaccount.yaml new file mode 100644 index 0000000..8fa53b1 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/deploy/aks/manifests/10-serviceaccount.yaml @@ -0,0 +1,14 @@ +# Workload-identity-enabled ServiceAccount. +# Federation is created by scripts/03-federate-blueprint.ps1: +# issuer = AKS cluster OIDC issuer +# subject = system:serviceaccount:auid:backend-sa +# audience = api://AzureADTokenExchange +# The Blueprint app trusts this subject directly. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: backend-sa + namespace: auid + annotations: + azure.workload.identity/client-id: "${BLUEPRINT_APP_ID}" + azure.workload.identity/tenant-id: "${TENANT_ID}" diff --git a/.claude/skills/deploy-agent-aks-auid/deploy/aks/manifests/20-weather-agent.yaml b/.claude/skills/deploy-agent-aks-auid/deploy/aks/manifests/20-weather-agent.yaml new file mode 100644 index 0000000..7f28392 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/deploy/aks/manifests/20-weather-agent.yaml @@ -0,0 +1,40 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: weather-agent + namespace: auid +spec: + replicas: 1 + selector: { matchLabels: { app: weather-agent } } + template: + metadata: + labels: { app: weather-agent } + spec: + containers: + - name: weather-agent + image: "${ACR_NAME}.azurecr.io/auid-aks/weather-agent:1.0.0" + ports: [{ containerPort: 8080 }] + env: + - { name: TENANT_ID, value: "${TENANT_ID}" } + - { name: AGENT_IDENTITY_APP_ID, value: "${AGENT_IDENTITY_APP_ID}" } + - { name: AGENT_USER_UPN, value: "${AGENT_USER_UPN}" } + - { name: WEATHER_AGENT_APP_ID, value: "${WEATHER_AGENT_APP_ID}" } + - { name: WEATHER_AGENT_APP_ID_URI, value: "${WEATHER_AGENT_APP_ID_URI}" } + resources: + requests: { cpu: "100m", memory: "128Mi" } + limits: { cpu: "500m", memory: "512Mi" } + readinessProbe: + httpGet: { path: /health, port: 8080 } + initialDelaySeconds: 5 + periodSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: weather-agent + namespace: auid +spec: + type: ClusterIP + selector: { app: weather-agent } + ports: + - { name: http, port: 8080, targetPort: 8080 } diff --git a/.claude/skills/deploy-agent-aks-auid/deploy/aks/manifests/30-ui.yaml b/.claude/skills/deploy-agent-aks-auid/deploy/aks/manifests/30-ui.yaml new file mode 100644 index 0000000..5a7e97d --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/deploy/aks/manifests/30-ui.yaml @@ -0,0 +1,35 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ui + namespace: auid +spec: + replicas: 1 + selector: { matchLabels: { app: ui } } + template: + metadata: + labels: { app: ui } + spec: + containers: + - name: ui + image: "${ACR_NAME}.azurecr.io/auid-aks/ui:1.0.0" + ports: [{ containerPort: 8080 }] + resources: + requests: { cpu: "50m", memory: "64Mi" } + limits: { cpu: "200m", memory: "128Mi" } + readinessProbe: + httpGet: { path: /, port: 8080 } + initialDelaySeconds: 3 + periodSeconds: 10 +--- +# Public LB — this is the single entrypoint for the demo. +apiVersion: v1 +kind: Service +metadata: + name: ui + namespace: auid +spec: + type: LoadBalancer + selector: { app: ui } + ports: + - { name: http, port: 80, targetPort: 8080 } diff --git a/.claude/skills/deploy-agent-aks-auid/deploy/aks/manifests/40-backend.yaml b/.claude/skills/deploy-agent-aks-auid/deploy/aks/manifests/40-backend.yaml new file mode 100644 index 0000000..4cfa5dc --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/deploy/aks/manifests/40-backend.yaml @@ -0,0 +1,80 @@ +# Backend pod = our FastAPI broker + the Microsoft auth-sidecar. +# Both containers share the pod's network namespace; the broker reaches the +# sidecar at http://localhost:5000. The sidecar is NEVER exposed via Service. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backend + namespace: auid +spec: + replicas: 1 + selector: { matchLabels: { app: backend } } + template: + metadata: + labels: + app: backend + # Required so the Azure Workload Identity mutating webhook injects + # AZURE_* env vars and the projected SA token volume. + azure.workload.identity/use: "true" + spec: + serviceAccountName: backend-sa + containers: + - name: backend + image: "${ACR_NAME}.azurecr.io/auid-aks/backend:1.0.0" + ports: [{ containerPort: 8080 }] + env: + - { name: TENANT_ID, value: "${TENANT_ID}" } + - { name: BLUEPRINT_APP_ID, value: "${BLUEPRINT_APP_ID}" } + - { name: AGENT_IDENTITY_APP_ID, value: "${AGENT_IDENTITY_APP_ID}" } + - { name: AGENT_USER_UPN, value: "${AGENT_USER_UPN}" } + - { name: AGENT_USER_OBJECT_ID, value: "${AGENT_USER_OBJECT_ID}" } + - { name: WEATHER_AGENT_APP_ID, value: "${WEATHER_AGENT_APP_ID}" } + - { name: WEATHER_AGENT_APP_ID_URI, value: "${WEATHER_AGENT_APP_ID_URI}" } + - { name: WEATHER_AGENT_URL, value: "http://weather-agent.auid.svc.cluster.local:8080" } + - { name: WEATHER_SVC_NAME, value: "weather" } + - { name: SIDECAR_URL, value: "http://localhost:5000" } + - { name: POD_NAMESPACE, value: "auid" } + - { name: SERVICE_ACCOUNT, value: "backend-sa" } + resources: + requests: { cpu: "100m", memory: "128Mi" } + limits: { cpu: "500m", memory: "512Mi" } + readinessProbe: + httpGet: { path: /api/health, port: 8080 } + initialDelaySeconds: 10 + periodSeconds: 10 + + - name: sidecar + image: mcr.microsoft.com/entra-sdk/auth-sidecar:1.0.0-azurelinux3.0-distroless + env: + - { name: AzureAd__Instance, value: "https://login.microsoftonline.com/" } + - { name: AzureAd__TenantId, value: "${TENANT_ID}" } + - { name: AzureAd__ClientId, value: "${BLUEPRINT_APP_ID}" } + # Workload Identity: the projected SA token IS the signed + # assertion the Blueprint app's FIC accepts. Read it from disk. + - { name: AzureAd__ClientCredentials__0__SourceType, + value: "SignedAssertionFilePath" } + - { name: AzureAd__ClientCredentials__0__SignedAssertionFileDiskPath, + value: "/var/run/secrets/azure/tokens/azure-identity-token" } + # The single downstream API definition used by AUID step 03. + # Service name "weather" must match WEATHER_SVC_NAME above and the + # path segment in /AuthorizationHeaderUnauthenticated/weather. + - { name: DownstreamApis__weather__BaseUrl, + value: "http://weather-agent.auid.svc.cluster.local:8080/" } + - { name: DownstreamApis__weather__Scopes__0, + value: "${WEATHER_AGENT_APP_ID_URI}/.default" } + - { name: ASPNETCORE_ENVIRONMENT, value: "Production" } + - { name: ASPNETCORE_URLS, value: "http://+:5000" } + resources: + requests: { cpu: "100m", memory: "128Mi" } + limits: { cpu: "500m", memory: "512Mi" } +--- +apiVersion: v1 +kind: Service +metadata: + name: backend + namespace: auid +spec: + type: ClusterIP + selector: { app: backend } + ports: + - { name: http, port: 8080, targetPort: 8080 } diff --git a/.claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/01-create-aks.ps1 b/.claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/01-create-aks.ps1 new file mode 100644 index 0000000..ae6e3e4 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/01-create-aks.ps1 @@ -0,0 +1,67 @@ +# Create RG, ACR, and AKS cluster with OIDC issuer + Workload Identity enabled. +# Appends OIDC_ISSUER back to the vars file (idempotent: replaces existing line). +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +foreach ($v in @('SUBSCRIPTION_ID','RG','LOCATION','AKS_NAME','ACR_NAME','NODE_VM_SIZE','NODE_COUNT','ACR_SKU')) { + if ([string]::IsNullOrEmpty([System.Environment]::GetEnvironmentVariable($v))) { + throw "$v is required. Source your deploy-vars.ps1 first." + } +} + +$varsFile = if ($env:VARS_FILE) { $env:VARS_FILE } else { + Join-Path ([System.IO.Path]::GetTempPath()) "deploy-vars.ps1" +} + +& az account set --subscription $env:SUBSCRIPTION_ID + +# Resource group (idempotent) +$rgExists = (& az group show -n $env:RG 2>$null) -ne $null +if (-not $rgExists) { + Write-Host "Creating resource group $env:RG..." + & az group create -n $env:RG -l $env:LOCATION -o none +} + +# ACR (idempotent) +$acrExists = (& az acr show -n $env:ACR_NAME -g $env:RG 2>$null) -ne $null +if (-not $acrExists) { + Write-Host "Creating ACR $env:ACR_NAME..." + & az acr create -n $env:ACR_NAME -g $env:RG --sku $env:ACR_SKU -o none +} + +# AKS cluster (idempotent) +$aksExists = (& az aks show -n $env:AKS_NAME -g $env:RG 2>$null) -ne $null +if (-not $aksExists) { + Write-Host "Creating AKS cluster $env:AKS_NAME..." + & az aks create ` + -n $env:AKS_NAME -g $env:RG -l $env:LOCATION ` + --node-count $env:NODE_COUNT --node-vm-size $env:NODE_VM_SIZE ` + --enable-oidc-issuer --enable-workload-identity ` + --attach-acr $env:ACR_NAME ` + --generate-ssh-keys -o none +} else { + Write-Host "AKS cluster $env:AKS_NAME already exists — ensuring OIDC + Workload Identity are enabled..." + & az aks update -n $env:AKS_NAME -g $env:RG ` + --enable-oidc-issuer --enable-workload-identity -o none 2>$null + & az aks update -n $env:AKS_NAME -g $env:RG --attach-acr $env:ACR_NAME -o none 2>$null +} + +& az aks get-credentials -n $env:AKS_NAME -g $env:RG --overwrite-existing + +$oidcIssuer = (& az aks show -n $env:AKS_NAME -g $env:RG --query oidcIssuerProfile.issuerUrl -o tsv).Trim() +$env:OIDC_ISSUER = $oidcIssuer + +# Persist OIDC_ISSUER in the vars file (replace existing line or append). +$newLine = "`$env:OIDC_ISSUER = `"$oidcIssuer`"" +if (Test-Path $varsFile) { + $text = Get-Content $varsFile -Raw + if ($text -match '(?m)^\$env:OIDC_ISSUER\s*=') { + $text = [regex]::Replace($text, '(?m)^\$env:OIDC_ISSUER\s*=.*', $newLine) + Set-Content -Path $varsFile -Value $text -NoNewline + } else { + Add-Content -Path $varsFile -Value $newLine + } +} else { + Set-Content -Path $varsFile -Value $newLine +} +Write-Host "appended: OIDC_ISSUER=$oidcIssuer" diff --git a/.claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/02-build-and-push.ps1 b/.claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/02-build-and-push.ps1 new file mode 100644 index 0000000..ae01a1c --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/02-build-and-push.ps1 @@ -0,0 +1,27 @@ +# Build and push backend, weather-agent, and ui images via `az acr build`. +# No local Docker required. +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +if (-not $env:ACR_NAME) { throw "ACR_NAME is required. Source your deploy-vars.ps1 first." } + +$scriptDir = $PSScriptRoot +$repoRoot = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ".." ".." "..")) + +Write-Host "[1/3] backend ($repoRoot/backend)" +& az acr build --registry $env:ACR_NAME ` + --image auid-aks/backend:1.0.0 ` + --platform linux/amd64 ` + (Join-Path $repoRoot "backend") + +Write-Host "[2/3] weather-agent ($repoRoot/weather-agent)" +& az acr build --registry $env:ACR_NAME ` + --image auid-aks/weather-agent:1.0.0 ` + --platform linux/amd64 ` + (Join-Path $repoRoot "weather-agent") + +Write-Host "[3/3] ui ($repoRoot/ui)" +& az acr build --registry $env:ACR_NAME ` + --image auid-aks/ui:1.0.0 ` + --platform linux/amd64 ` + (Join-Path $repoRoot "ui") diff --git a/.claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/03-federate-blueprint.ps1 b/.claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/03-federate-blueprint.ps1 new file mode 100644 index 0000000..5f2bff6 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/03-federate-blueprint.ps1 @@ -0,0 +1,39 @@ +# Federate the AKS KSA `auid/backend-sa` directly to the Blueprint app. +# Subject = system:serviceaccount:auid:backend-sa +# Audience = api://AzureADTokenExchange +param( + [Parameter(Mandatory=$true)] [string] $TenantId, + [Parameter(Mandatory=$true)] [string] $BlueprintAppId, + [Parameter(Mandatory=$true)] [string] $OidcIssuerUrl, + [string] $Namespace = "auid", + [string] $ServiceAccount = "backend-sa", + [string] $FicName = "aks-backend-sa" +) + +$ErrorActionPreference = "Stop" + +Import-Module Microsoft.Graph.Authentication -ErrorAction Stop +Connect-MgGraph -TenantId $TenantId -Scopes "AgentIdentityBlueprint.AddRemoveCreds.All" -NoWelcome | Out-Null + +$subject = "system:serviceaccount:$Namespace`:$ServiceAccount" +$body = @{ + name = $FicName + issuer = $OidcIssuerUrl + subject = $subject + audiences = @("api://AzureADTokenExchange") + description = "AKS KSA $subject (AUID demo)" +} | ConvertTo-Json -Depth 5 + +$uri = "https://graph.microsoft.com/beta/applications(appId='$BlueprintAppId')/federatedIdentityCredentials" + +try { + Invoke-MgGraphRequest -Method POST -Uri $uri -Body $body -ContentType "application/json" | Out-Null + Write-Host "Federated credential '$FicName' created on Blueprint $BlueprintAppId" + Write-Host " issuer : $OidcIssuerUrl" + Write-Host " subject : $subject" +} +catch { + if ($_.Exception.Message -match "already exists|FederatedIdentityCredential with the same") { + Write-Host "Federated credential '$FicName' already exists - skipping." + } else { throw } +} diff --git a/.claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/04-apply-manifests.ps1 b/.claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/04-apply-manifests.ps1 new file mode 100644 index 0000000..f8f4c13 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/04-apply-manifests.ps1 @@ -0,0 +1,58 @@ +# Render manifests via PowerShell string substitution and apply. +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +foreach ($v in @('TENANT_ID','BLUEPRINT_APP_ID','AGENT_IDENTITY_APP_ID','AGENT_USER_UPN', + 'WEATHER_AGENT_APP_ID','WEATHER_AGENT_APP_ID_URI','ACR_NAME')) { + if ([string]::IsNullOrEmpty([System.Environment]::GetEnvironmentVariable($v))) { + throw "Missing `$$v — source your deploy-vars.ps1 first." + } +} +if (-not $env:AGENT_USER_OBJECT_ID) { $env:AGENT_USER_OBJECT_ID = "" } + +$scriptDir = $PSScriptRoot +$manifestsDir = [System.IO.Path]::GetFullPath((Join-Path $scriptDir ".." "manifests")) +$outDir = Join-Path ([System.IO.Path]::GetTempPath()) "auid-aks-rendered" +New-Item -ItemType Directory -Force -Path $outDir | Out-Null + +# Substitution map — longer names before shorter prefixes to avoid partial replacements. +$subs = [ordered]@{ + '$AGENT_IDENTITY_APP_ID' = $env:AGENT_IDENTITY_APP_ID + '$WEATHER_AGENT_APP_ID_URI'= $env:WEATHER_AGENT_APP_ID_URI + '$WEATHER_AGENT_APP_ID' = $env:WEATHER_AGENT_APP_ID + '$AGENT_USER_OBJECT_ID' = $env:AGENT_USER_OBJECT_ID + '$BLUEPRINT_APP_ID' = $env:BLUEPRINT_APP_ID + '$AGENT_USER_UPN' = $env:AGENT_USER_UPN + '$TENANT_ID' = $env:TENANT_ID + '$ACR_NAME' = $env:ACR_NAME +} + +Get-ChildItem $manifestsDir -Filter "*.yaml" | Sort-Object Name | ForEach-Object { + $content = Get-Content $_.FullName -Raw + foreach ($kv in $subs.GetEnumerator()) { + $content = $content.Replace($kv.Key, $kv.Value) + } + Set-Content -Path (Join-Path $outDir $_.Name) -Value $content -NoNewline +} + +kubectl apply -f (Join-Path $outDir "00-namespace.yaml") +kubectl apply -f (Join-Path $outDir "10-serviceaccount.yaml") +kubectl apply -f (Join-Path $outDir "20-weather-agent.yaml") +kubectl apply -f (Join-Path $outDir "30-ui.yaml") +kubectl apply -f (Join-Path $outDir "40-backend.yaml") + +Write-Host "" +Write-Host "Waiting for rollouts..." +kubectl -n auid rollout status deploy/weather-agent --timeout=180s +kubectl -n auid rollout status deploy/backend --timeout=180s +kubectl -n auid rollout status deploy/ui --timeout=120s + +Write-Host "" +Write-Host "Waiting for LoadBalancer IP..." +$ip = $null +for ($i = 0; $i -lt 60; $i++) { + $ip = (kubectl -n auid get svc ui -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>$null).Trim() + if ($ip) { break } + Start-Sleep 5 +} +Write-Host "AUID demo UI: http://$($ip ?? '')/" diff --git a/.claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/deploy-aks-dev.ps1 b/.claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/deploy-aks-dev.ps1 new file mode 100644 index 0000000..5d4cf58 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/deploy-aks-dev.ps1 @@ -0,0 +1,89 @@ +# One-shot orchestrator. Dot-source your deploy-vars.ps1 first, or set +# $env:VARS_FILE to its path and this script will dot-source it for you. +# +# cp deploy/aks/scripts/deploy-vars.ps1.template ~/deploy-vars-auid.ps1 +# # Edit ~/deploy-vars-auid.ps1 +# $env:VARS_FILE = "$HOME/deploy-vars-auid.ps1" +# pwsh -NoProfile -File deploy/aks/scripts/deploy-aks-dev.ps1 +# +# Prerequisites: +# - Blueprint + Agent Identity already created (use the entra-agent-id-setup +# workflow). BLUEPRINT_APP_ID and AGENT_IDENTITY_APP_ID set in deploy-vars. +# - Agentic User provisioned (../../../scripts/01-provision-agentic-user.ps1). +# - Weather Agent app registered with exposed scope, Agent Identity granted +# admin consent for it (../../../scripts/04-register-weather-app.ps1). +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$scriptDir = $PSScriptRoot +$varsFile = if ($env:VARS_FILE) { $env:VARS_FILE } else { + throw "VARS_FILE not set. Point `$env:VARS_FILE to your deploy-vars.ps1 and re-run." +} +if (-not (Test-Path $varsFile)) { + throw "Vars file '$varsFile' not found. Copy deploy-vars.ps1.template, fill it in, and set `$env:VARS_FILE." +} +. $varsFile + +foreach ($v in @('TENANT_ID','SUBSCRIPTION_ID','RG','LOCATION','AKS_NAME','ACR_NAME', + 'NODE_COUNT','NODE_VM_SIZE','BLUEPRINT_APP_ID','AGENT_IDENTITY_APP_ID', + 'AGENT_USER_UPN','WEATHER_AGENT_APP_ID','WEATHER_AGENT_APP_ID_URI')) { + if ([string]::IsNullOrEmpty([System.Environment]::GetEnvironmentVariable($v))) { + throw "`$$v is unset. Edit $varsFile." + } +} +if (-not $env:SUBSCRIPTION_TENANT_ID) { $env:SUBSCRIPTION_TENANT_ID = $env:TENANT_ID } + +Write-Host "============================================================" +Write-Host " AUID-on-AKS deploy plan" +if ($env:SUBSCRIPTION_TENANT_ID -ne $env:TENANT_ID) { + Write-Host " *** CROSS-TENANT DEPLOY ***" + Write-Host " Entra tenant : $env:TENANT_ID" + Write-Host " Sub tenant : $env:SUBSCRIPTION_TENANT_ID" +} +Write-Host " Subscription : $env:SUBSCRIPTION_ID" +Write-Host " RG/Location : $env:RG / $env:LOCATION" +Write-Host " AKS / ACR : $env:AKS_NAME / $env:ACR_NAME" +Write-Host " Blueprint : $env:BLUEPRINT_APP_ID" +Write-Host " Agent ID : $env:AGENT_IDENTITY_APP_ID" +Write-Host " Agent User : $env:AGENT_USER_UPN" +Write-Host " Weather App : $env:WEATHER_AGENT_APP_ID ($env:WEATHER_AGENT_APP_ID_URI)" +Write-Host "============================================================" + +# Step 01: Create RG + ACR + AKS (writes OIDC_ISSUER back to varsFile). +& pwsh -NoProfile -File (Join-Path $scriptDir "01-create-aks.ps1") +if ($LASTEXITCODE -ne 0) { throw "01-create-aks.ps1 failed." } + +# Re-source to pick up OIDC_ISSUER written by step 01. +. $varsFile + +# Step 02: Build and push container images. +& pwsh -NoProfile -File (Join-Path $scriptDir "02-build-and-push.ps1") +if ($LASTEXITCODE -ne 0) { throw "02-build-and-push.ps1 failed." } + +# Step 03: Federate the KSA to the Blueprint app. +$ficName = $env:FIC_NAME ? $env:FIC_NAME : "aks-backend-sa" +& pwsh -NoProfile -File (Join-Path $scriptDir "03-federate-blueprint.ps1") ` + -TenantId $env:TENANT_ID ` + -BlueprintAppId $env:BLUEPRINT_APP_ID ` + -OidcIssuerUrl $env:OIDC_ISSUER ` + -FicName $ficName +if ($LASTEXITCODE -ne 0) { throw "03-federate-blueprint.ps1 failed." } + +# Step 04: Render manifests and apply to AKS. +& pwsh -NoProfile -File (Join-Path $scriptDir "04-apply-manifests.ps1") +if ($LASTEXITCODE -ne 0) { throw "04-apply-manifests.ps1 failed." } + +$lbIp = (kubectl get svc -n auid ui -o jsonpath='{.status.loadBalancer.ingress[0].ip}' 2>$null).Trim() +Write-Host "" +Write-Host "============================================================" +Write-Host " Done. AUID demo UI: http://$($lbIp ? $lbIp : '')/" +Write-Host "" +Write-Host " Verify:" +Write-Host " kubectl get pods -n auid" +Write-Host " kubectl logs -n auid -l app=backend -c sidecar --tail=50" +Write-Host " kubectl logs -n auid -l app=backend -c backend --tail=50" +Write-Host "" +Write-Host " Smoke-test the AUID acquisition from inside the cluster:" +Write-Host " kubectl exec -n auid deploy/backend -c backend -- ``" +Write-Host " curl -s -X POST http://localhost:8080/api/step/03-auid-token | Select-Object -First 600" +Write-Host "============================================================" diff --git a/.claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/deploy-vars.ps1.template b/.claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/deploy-vars.ps1.template new file mode 100644 index 0000000..d36a248 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/deploy/aks/scripts/deploy-vars.ps1.template @@ -0,0 +1,50 @@ +# Copy to your working directory, fill in values, then dot-source: +# . .\deploy-vars.ps1 +# Or set $env:VARS_FILE to the path and let deploy-aks-dev.ps1 dot-source it. + +# === Entra tenant — Blueprint, Agent Identity, Weather Agent app live here === +$env:TENANT_ID = "" + +# === Azure subscription tenant (defaults to TENANT_ID for single-tenant) === +if (-not $env:SUBSCRIPTION_TENANT_ID) { $env:SUBSCRIPTION_TENANT_ID = $env:TENANT_ID } + +# === Azure subscription / location / RG === +$env:SUBSCRIPTION_ID = "" +$env:RG = "rg-auid-aks-dev" +$env:LOCATION = "eastus2" + +# === AKS + ACR === +$env:AKS_NAME = "aks-auid-dev" +# ACR name: 5-50 chars, lowercase alphanumeric, GLOBALLY unique. Compute ONCE: +# $env:ACR_NAME = "acrauid" + (Get-Random -Minimum 1000 -Maximum 9999) +# Then paste the fixed value here; do not re-evaluate on every dot-source. +$env:ACR_NAME = "" +$env:ACR_SKU = "Basic" +$env:NODE_VM_SIZE = "Standard_D2s_v5" +$env:NODE_COUNT = "2" + +# === Entra Agent ID objects (from Phase 1: entra-agent-id-setup) === +$env:BLUEPRINT_APP_ID = "" +$env:AGENT_IDENTITY_APP_ID = "" + +# === Agentic User (provisioned via scripts/01-provision-agentic-user.ps1) === +$env:AGENT_USER_UPN = "" +$env:AGENT_USER_OBJECT_ID = "" + +# === Weather Agent app (created by scripts/04-register-weather-app.ps1) === +# This MUST be a separate Entra app registration with an exposed scope so the +# AUID token can be minted for ITS audience (enabling proper signature +# verification end-to-end — Graph tokens cannot be verified by 3rd parties). +$env:WEATHER_AGENT_APP_ID = "" +if (-not $env:WEATHER_AGENT_APP_ID_URI -and $env:WEATHER_AGENT_APP_ID) { + $env:WEATHER_AGENT_APP_ID_URI = "api://$env:WEATHER_AGENT_APP_ID" +} else { + $env:WEATHER_AGENT_APP_ID_URI = "api://weather-agent" +} + +# === FIC naming (rename when redeploying onto a recycled cluster) === +if (-not $env:FIC_NAME) { $env:FIC_NAME = "aks-backend-sa" } + +# === Auto-filled by scripts — do not edit === +$env:OIDC_ISSUER = "" # filled by 01-create-aks.ps1 +$env:LB_IP = "" # filled by 04-apply-manifests.ps1 diff --git a/.claude/skills/deploy-agent-aks-auid/scripts/00-preflight-check.ps1 b/.claude/skills/deploy-agent-aks-auid/scripts/00-preflight-check.ps1 new file mode 100644 index 0000000..521e44e --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/scripts/00-preflight-check.ps1 @@ -0,0 +1,293 @@ +# 00-preflight-check.ps1 +# +# Programmatic preflight that verifies the tenant is ready for the AUID flow. +# Run this BEFORE 01-provision-agentic-user.ps1. The script reports each +# required permission/scope/role as PASS / FAIL / WARN with remediation hints, +# and exits non-zero if anything is FAIL so CI/automation can gate on it. +# +# What it checks (in order): +# [A] .env values are present and non-empty +# [B] Admin can sign in (delegated) with the scopes needed to inspect tenant +# [C] Blueprint app + Blueprint SP exist +# [D] Blueprint SP has Graph app role AgentIdUser.ReadWrite.IdentityParentedBy +# (roleId 4aa6e624-eee0-40ab-bdd8-f9639038a614) assigned + admin consented +# [E] Blueprint app has at least one non-expired client secret +# [F] Agent Identity app + SP exist +# [G] Agent Identity app has a Federated Identity Credential trusting Blueprint +# [H] Microsoft Graph SP exists in tenant (it always should — sanity) +# [I] (Optional, after provisioning) Agentic User has User.Read for AllPrincipals +# +# Usage: +# pwsh ./scripts/00-preflight-check.ps1 +# pwsh ./scripts/00-preflight-check.ps1 -SkipAgenticUser # before provisioning + +[CmdletBinding()] +param( + [string]$EnvFile = "$PSScriptRoot/../.env", + [switch]$SkipAgenticUser +) + +$ErrorActionPreference = 'Stop' +$script:FAILS = 0 +$script:WARNS = 0 + +function Write-Result { + param([string]$Label, [string]$Status, [string]$Detail = "") + $color = switch ($Status) { + "PASS" { "Green" } + "FAIL" { "Red"; $script:FAILS++ } + "WARN" { "Yellow"; $script:WARNS++ } + default { "Gray" } + } + Write-Host (" [{0,-4}] " -f $Status) -ForegroundColor $color -NoNewline + Write-Host $Label -NoNewline + if ($Detail) { Write-Host " - $Detail" -ForegroundColor DarkGray } else { Write-Host "" } +} +function Section($t) { Write-Host ""; Write-Host "=== $t ===" -ForegroundColor Cyan } + +# ---- [A] Load .env ---- +Section "A. Local config (.env)" +if (-not (Test-Path $EnvFile)) { + Write-Result ".env file at $EnvFile" "FAIL" "Copy .env.example to .env and fill in values" + Write-Host "" + Write-Host "Aborting — fix the .env file and rerun." -ForegroundColor Red + exit 1 +} +$envv = @{} +Get-Content $EnvFile | ForEach-Object { + if ($_ -match '^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$') { $envv[$matches[1]] = $matches[2].Trim() } +} +$required = @{ + "TENANT_ID" = "Your Entra tenant ID (GUID)" + "BLUEPRINT_APP_ID" = "appId of the Blueprint app registration" + "AGENT_IDENTITY_APP_ID" = "appId of the Agent Identity app registration" +} +foreach ($k in $required.Keys) { + if ([string]::IsNullOrWhiteSpace($envv[$k])) { + Write-Result "$k present" "FAIL" $required[$k] + } else { + Write-Result "$k = $($envv[$k])" "PASS" + } +} +if (-not $envv.BLUEPRINT_CLIENT_SECRET) { + Write-Result "BLUEPRINT_CLIENT_SECRET present" "WARN" "Required to run scripts 01 and 03. Mint one in Entra portal or via 01-provision." +} else { + Write-Result "BLUEPRINT_CLIENT_SECRET present" "PASS" "(value masked)" +} + +if ($script:FAILS -gt 0) { + Write-Host "" + Write-Host "Aborting before admin sign-in — fix the .env file and rerun." -ForegroundColor Red + exit 1 +} + +$tenant = $envv.TENANT_ID +$blueprint = $envv.BLUEPRINT_APP_ID +$agentId = $envv.AGENT_IDENTITY_APP_ID + +# ---- [B] Admin sign-in (device code) ---- +Section "B. Admin delegated sign-in" +$clientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e" # Microsoft Graph PowerShell well-known +$scopes = "Application.ReadWrite.All AppRoleAssignment.ReadWrite.All DelegatedPermissionGrant.ReadWrite.All Directory.Read.All User.Read offline_access" +try { + $dc = Invoke-RestMethod -Method POST "https://login.microsoftonline.com/$tenant/oauth2/v2.0/devicecode" -Body @{client_id=$clientId; scope=$scopes} +} catch { + Write-Result "Reach Entra device-code endpoint" "FAIL" $_.Exception.Message + exit 1 +} +Write-Host "" +Write-Host " Sign in as a Cloud Application Administrator (or higher) of tenant $tenant" -ForegroundColor Yellow +Write-Host " Open: $($dc.verification_uri)" -ForegroundColor Yellow +Write-Host " Code: $($dc.user_code)" -ForegroundColor Yellow +Write-Host "" + +$token = $null +for ($i = 0; $i -lt 180; $i++) { + try { + $r = Invoke-RestMethod -Method POST "https://login.microsoftonline.com/$tenant/oauth2/v2.0/token" -Body @{ + grant_type = "urn:ietf:params:oauth:grant-type:device_code" + client_id = $clientId + device_code = $dc.device_code + } -ErrorAction Stop + $token = $r.access_token + break + } catch { + $err = $null + try { $err = ($_.ErrorDetails.Message | ConvertFrom-Json).error } catch {} + if ($err -eq "authorization_pending") { Start-Sleep -Seconds $dc.interval; continue } + Write-Result "Device-code sign-in" "FAIL" "$err" + exit 1 + } +} +if (-not $token) { Write-Result "Device-code sign-in" "FAIL" "Timed out"; exit 1 } +Write-Result "Admin signed in" "PASS" + +# Decode token, inspect granted scopes +$parts = $token.Split('.') +$pad = $parts[1].Replace('-','+').Replace('_','/'); while ($pad.Length % 4) { $pad += '=' } +$claims = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($pad)) | ConvertFrom-Json +$grantedScopes = $claims.scp -split ' ' + +$needScopes = @( + "Application.ReadWrite.All", + "AppRoleAssignment.ReadWrite.All", + "DelegatedPermissionGrant.ReadWrite.All", + "Directory.Read.All" +) +foreach ($s in $needScopes) { + if ($grantedScopes -contains $s) { + Write-Result "Admin token has scope: $s" "PASS" + } else { + Write-Result "Admin token has scope: $s" "FAIL" "Admin consent missing for this scope on the well-known Graph PowerShell client; ask a Global Admin to consent." + } +} + +$H = @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" } + +# ---- [C] Blueprint app + SP ---- +Section "C. Blueprint app registration" +$bpApp = $null; $bpSp = $null +try { + $r = Invoke-RestMethod -Method GET "https://graph.microsoft.com/v1.0/applications?`$filter=appId eq '$blueprint'" -Headers $H + $bpApp = $r.value | Select-Object -First 1 +} catch {} +if (-not $bpApp) { + Write-Result "Blueprint app exists (appId=$blueprint)" "FAIL" "App registration not found in tenant $tenant" +} else { + Write-Result "Blueprint app exists (appId=$blueprint)" "PASS" "displayName=$($bpApp.displayName)" +} +try { + $r = Invoke-RestMethod -Method GET "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$blueprint'" -Headers $H + $bpSp = $r.value | Select-Object -First 1 +} catch {} +if (-not $bpSp) { + Write-Result "Blueprint service principal exists" "FAIL" "Run: New-MgServicePrincipal -AppId $blueprint, or in portal: Enterprise Apps -> New application -> from app registration" +} else { + Write-Result "Blueprint service principal exists" "PASS" "spId=$($bpSp.id)" +} + +# ---- [D] Blueprint SP has Graph app role AgentIdUser.ReadWrite.IdentityParentedBy ---- +Section "D. Required Graph application permissions on Blueprint SP" +$graphSp = $null +try { + $r = Invoke-RestMethod -Method GET "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '00000003-0000-0000-c000-000000000000'" -Headers $H + $graphSp = $r.value | Select-Object -First 1 +} catch {} +if (-not $graphSp) { + Write-Result "Microsoft Graph SP found in tenant" "FAIL" "Highly unusual — this SP is normally always present." +} else { + Write-Result "Microsoft Graph SP found in tenant" "PASS" "spId=$($graphSp.id)" +} + +$requiredAppRoles = @{ + "AgentIdUser.ReadWrite.IdentityParentedBy" = "4aa6e624-eee0-40ab-bdd8-f9639038a614" +} +if ($bpSp -and $graphSp) { + $bpAssignments = (Invoke-RestMethod -Method GET "https://graph.microsoft.com/v1.0/servicePrincipals/$($bpSp.id)/appRoleAssignments" -Headers $H).value + foreach ($roleName in $requiredAppRoles.Keys) { + $roleId = $requiredAppRoles[$roleName] + $hit = $bpAssignments | Where-Object { $_.resourceId -eq $graphSp.id -and $_.appRoleId -eq $roleId } + if ($hit) { + Write-Result "Blueprint SP has Graph app role: $roleName" "PASS" + } else { + Write-Result "Blueprint SP has Graph app role: $roleName" "FAIL" @" +Grant it with: + POST https://graph.microsoft.com/v1.0/servicePrincipals/$($bpSp.id)/appRoleAssignments + { "principalId": "$($bpSp.id)", "resourceId": "$($graphSp.id)", "appRoleId": "$roleId" } +This is the role that allows the Blueprint to create microsoft.graph.agentUser objects. +"@ + } + } +} + +# ---- [E] Blueprint client secret ---- +Section "E. Blueprint client secret" +if ($bpApp) { + $now = Get-Date + $live = @($bpApp.passwordCredentials | Where-Object { $_.endDateTime -and ([DateTime]$_.endDateTime) -gt $now }) + if ($live.Count -eq 0) { + Write-Result "Blueprint has a non-expired client secret" "FAIL" "Mint one in portal (Certificates & secrets) or via Graph: POST /applications/$($bpApp.id)/addPassword" + } else { + $soonest = ($live | Sort-Object endDateTime)[0] + $daysLeft = [int](([DateTime]$soonest.endDateTime) - $now).TotalDays + if ($daysLeft -lt 14) { + Write-Result "Blueprint has a non-expired client secret" "WARN" "$($live.Count) secret(s), soonest expires in $daysLeft day(s) — rotate soon" + } else { + Write-Result "Blueprint has a non-expired client secret" "PASS" "$($live.Count) secret(s), soonest expires in $daysLeft day(s)" + } + } +} + +# ---- [F] Agent Identity app + SP ---- +Section "F. Agent Identity app registration" +$aiApp = $null; $aiSp = $null +try { + $r = Invoke-RestMethod -Method GET "https://graph.microsoft.com/v1.0/applications?`$filter=appId eq '$agentId'" -Headers $H + $aiApp = $r.value | Select-Object -First 1 +} catch {} +if (-not $aiApp) { + Write-Result "Agent Identity app exists (appId=$agentId)" "FAIL" "App registration not found" +} else { + Write-Result "Agent Identity app exists (appId=$agentId)" "PASS" "displayName=$($aiApp.displayName)" +} +try { + $r = Invoke-RestMethod -Method GET "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$agentId'" -Headers $H + $aiSp = $r.value | Select-Object -First 1 +} catch {} +if (-not $aiSp) { + Write-Result "Agent Identity service principal exists" "FAIL" "Required so delegated consent can be granted to the Agentic User." +} else { + Write-Result "Agent Identity service principal exists" "PASS" "spId=$($aiSp.id)" +} + +# ---- [G] FIC trusting the Blueprint ---- +Section "G. Federated Identity Credential on Agent Identity" +if ($aiApp) { + $fics = @() + try { $fics = (Invoke-RestMethod -Method GET "https://graph.microsoft.com/v1.0/applications/$($aiApp.id)/federatedIdentityCredentials" -Headers $H).value } catch {} + if (-not $fics -or $fics.Count -eq 0) { + Write-Result "Agent Identity has a Federated Identity Credential" "FAIL" "Required for step 03.02 (jwt-bearer with Blueprint FIC). Add a FIC on the Agent Identity app trusting the Blueprint as issuer." + } else { + Write-Result "Agent Identity has $($fics.Count) FIC(s)" "PASS" ("subjects: " + (($fics | ForEach-Object subject) -join ", ")) + } +} + +# ---- [I] Agentic User delegated consent (only if AGENT_USER_OBJECT_ID is set) ---- +if (-not $SkipAgenticUser -and $envv.AGENT_USER_OBJECT_ID) { + Section "I. Agentic User delegated Graph permissions" + if ($aiSp -and $graphSp) { + $grants = @() + try { + $grants = (Invoke-RestMethod -Method GET "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?`$filter=clientId eq '$($aiSp.id)' and resourceId eq '$($graphSp.id)'" -Headers $H).value + } catch {} + $allPrincipalsGrant = $grants | Where-Object { $_.consentType -eq "AllPrincipals" } | Select-Object -First 1 + if (-not $allPrincipalsGrant) { + Write-Result "Agent Identity SP -> Graph oauth2PermissionGrant (AllPrincipals)" "FAIL" "Run scripts/02-grant-agentic-user-consent.ps1 — this is the consent that lets the Agentic User call Graph." + } else { + $scp = $allPrincipalsGrant.scope + Write-Result "Agent Identity SP -> Graph oauth2PermissionGrant (AllPrincipals)" "PASS" "scope=$scp" + if ($scp -notmatch "\bUser\.Read\b") { + Write-Result "Granted scopes include User.Read" "WARN" "Add User.Read so the Agentic User can call /me" + } else { + Write-Result "Granted scopes include User.Read" "PASS" + } + } + } +} else { + Section "I. Agentic User delegated Graph permissions" + Write-Result "Agentic User check skipped" "WARN" "Either -SkipAgenticUser was passed, or AGENT_USER_OBJECT_ID is empty (Agentic User not yet provisioned)." +} + +# ---- Summary ---- +Section "Summary" +if ($script:FAILS -gt 0) { + Write-Host " $script:FAILS check(s) FAILED, $script:WARNS warning(s)." -ForegroundColor Red + Write-Host " Fix the failures above before running scripts/01-provision-agentic-user.ps1." -ForegroundColor Red + exit 1 +} elseif ($script:WARNS -gt 0) { + Write-Host " All checks PASSED with $script:WARNS warning(s) — review above." -ForegroundColor Yellow + exit 0 +} else { + Write-Host " All checks PASSED. You're ready to provision the Agentic User." -ForegroundColor Green + exit 0 +} diff --git a/.claude/skills/deploy-agent-aks-auid/scripts/01-provision-agentic-user.ps1 b/.claude/skills/deploy-agent-aks-auid/scripts/01-provision-agentic-user.ps1 new file mode 100644 index 0000000..058d385 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/scripts/01-provision-agentic-user.ps1 @@ -0,0 +1,107 @@ +# 01-provision-agentic-user.ps1 +# Creates an Agentic User attached to an existing Blueprint + Agent Identity. +# Lifts from: Connect_3P_agent_to_AgentID_using_HTTPs README step 02.02 +# +# Usage: +# ./01-provision-agentic-user.ps1 ` +# -TenantId "98430660-2a7e-4e6b-b49c-800a8ba8b657" ` +# -BlueprintAppId "4f6ca43e-337c-4617-958f-e517cf1a1858" ` +# -AgentIdentityAppId "2b32c2c2-3a5a-435e-b07e-8ef20564364f" ` +# -AgentUserDisplayName "[ai] Digital Worker 01 Agent ID User" ` +# -AgentUserMailNickname "digitalworker01" +# +# Requires: Microsoft.Graph module + AgentIdentity.ReadWrite.All / User.ReadWrite.All / Application.ReadWrite.All + +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$TenantId, + [Parameter(Mandatory=$true)][string]$BlueprintAppId, + [Parameter(Mandatory=$true)][string]$AgentIdentityAppId, + [string]$AgentUserDisplayName = "[ai] Digital Worker 01 Agent ID User", + [string]$AgentUserMailNickname = "digitalworker01", + [string]$EnvFile = "$PSScriptRoot/../.env" +) + +$ErrorActionPreference = 'Stop' + +# ---- Connect ---- +if (-not (Get-Module -ListAvailable Microsoft.Graph.Authentication)) { + throw "Microsoft.Graph module not installed. Run: Install-Module Microsoft.Graph -Scope CurrentUser" +} +Import-Module Microsoft.Graph.Authentication +Write-Host "Connecting to tenant $TenantId..." +Connect-MgGraph -TenantId $TenantId -UseDeviceCode -Scopes @( + "Application.ReadWrite.All", + "User.ReadWrite.All", + "AgentIdentity.ReadWrite.All", + "Directory.ReadWrite.All" +) -NoWelcome | Out-Null + +# ---- Resolve tenant verified domain (UPN needs to live on a verified domain) ---- +$org = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/organization?`$select=verifiedDomains" +$initialDomain = ($org.value[0].verifiedDomains | Where-Object { $_.isInitial -eq $true }).name +if (-not $initialDomain) { throw "Could not resolve initial verified domain for tenant." } +$upn = "$AgentUserMailNickname@$initialDomain" +Write-Host "Agentic User UPN will be: $upn" + +# ---- Resolve Agent Identity client ID (== appId; treat both as same) ---- +# The Agent Identity is the service principal of @odata.type microsoft.graph.agentIdentity. +# identityParentId on the agentUser must reference the Agent Identity *clientId* (== appId). +$agentIdentityClientId = $AgentIdentityAppId +Write-Host "Agent Identity clientId: $agentIdentityClientId" + +# ---- Check if Agentic User already exists ---- +$existing = $null +try { + $existing = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/users/$upn" +} catch { + if ($_.Exception.Message -notmatch '404|Request_ResourceNotFound|NotFound') { throw } +} +if ($existing) { + Write-Host "✅ Agentic User already exists: id=$($existing.id) upn=$($existing.userPrincipalName)" -ForegroundColor Green + $userId = $existing.id +} else { + Write-Host "Creating Agentic User..." + $body = @{ + '@odata.type' = 'microsoft.graph.agentUser' + displayName = $AgentUserDisplayName + userPrincipalName = $upn + mailNickname = $AgentUserMailNickname + accountEnabled = $true + identityParentId = $agentIdentityClientId + } | ConvertTo-Json -Depth 5 + $created = Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/beta/users" ` + -ContentType 'application/json' -Body $body + Write-Host "✅ Created Agentic User: id=$($created.id) upn=$($created.userPrincipalName)" -ForegroundColor Green + $userId = $created.id +} + +# ---- Read-back verification ---- +$verify = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/users/$userId`?`$select=id,displayName,userPrincipalName,identityParentId" +$verify | ConvertTo-Json -Depth 5 + +# ---- Persist to .env (idempotent) ---- +function Set-EnvKey { + param($Path, $Key, $Value) + if (-not (Test-Path $Path)) { + Copy-Item "$PSScriptRoot/../.env.example" $Path -Force + } + $lines = Get-Content $Path + if ($lines -match "^$Key=") { + $lines = $lines -replace "^$Key=.*","$Key=$Value" + } else { + $lines += "$Key=$Value" + } + Set-Content -Path $Path -Value $lines -Encoding UTF8 +} + +Set-EnvKey -Path $EnvFile -Key "AGENT_USER_UPN" -Value $upn +Set-EnvKey -Path $EnvFile -Key "AGENT_USER_OBJECT_ID" -Value $userId +Set-EnvKey -Path $EnvFile -Key "AGENT_USER_MAIL_NICKNAME" -Value $AgentUserMailNickname + +Write-Host "" +Write-Host "Wrote to ${EnvFile}:" +Write-Host " AGENT_USER_UPN=$upn" +Write-Host " AGENT_USER_OBJECT_ID=$userId" +Write-Host "" +Write-Host "Next: run ./02-grant-agentic-user-consent.ps1 to grant Graph permissions to this Agentic User." -ForegroundColor Cyan diff --git a/.claude/skills/deploy-agent-aks-auid/scripts/02-grant-agentic-user-consent.ps1 b/.claude/skills/deploy-agent-aks-auid/scripts/02-grant-agentic-user-consent.ps1 new file mode 100644 index 0000000..b80c2b2 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/scripts/02-grant-agentic-user-consent.ps1 @@ -0,0 +1,27 @@ +# 02-grant-agentic-user-consent.ps1 +# Prints the admin-consent URL for the Agent Identity, scoped to the permissions the Agentic User will exercise. +# Lifts from: Connect_3P_agent_to_AgentID_using_HTTPs README step 02.03 +# +# Usage: +# ./02-grant-agentic-user-consent.ps1 ` +# -TenantId "98430660-2a7e-4e6b-b49c-800a8ba8b657" ` +# -AgentIdentityAppId "2b32c2c2-3a5a-435e-b07e-8ef20564364f" + +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$TenantId, + [Parameter(Mandatory=$true)][string]$AgentIdentityAppId, + [string]$Scopes = "User.Read groupmember.read.all Chat.ReadWrite Calendars.ReadWrite Mail.ReadWrite Contacts.Read People.Read", + [string]$RedirectUri = "https://entra.microsoft.com/TokenAuthorize" +) + +$scopesEncoded = [System.Web.HttpUtility]::UrlEncode($Scopes) +$redirectEncoded = [System.Web.HttpUtility]::UrlEncode($RedirectUri) +$url = "https://login.microsoftonline.com/$TenantId/v2.0/adminconsent?client_id=$AgentIdentityAppId&scope=$scopesEncoded&redirect_uri=$redirectEncoded&state=auid-experiment" + +Write-Host "" +Write-Host "Open this URL in a browser, sign in as Cloud App Admin (or higher), and click 'Accept':" -ForegroundColor Cyan +Write-Host "" +Write-Host $url -ForegroundColor Yellow +Write-Host "" +Write-Host "After consent succeeds, run ./03-test-token-chain.ps1 to verify the AUID token chain works." -ForegroundColor Cyan diff --git a/.claude/skills/deploy-agent-aks-auid/scripts/04-register-weather-app.ps1 b/.claude/skills/deploy-agent-aks-auid/scripts/04-register-weather-app.ps1 new file mode 100644 index 0000000..7b14908 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/scripts/04-register-weather-app.ps1 @@ -0,0 +1,110 @@ +# Register the Weather Agent as its own Entra app registration with an exposed +# scope, then grant the Agent Identity admin consent for that scope. This is +# REQUIRED for the AUID demo: the AUID token must be issued for the Weather +# Agent's own audience so its JWT signature is verifiable against the tenant +# JWKS (Graph access tokens cannot be verified by 3rd parties). +# +# Output: writes WEATHER_AGENT_APP_ID + WEATHER_AGENT_APP_ID_URI to .env / vars file. +# +# Usage (from repo root): +# pwsh ./scripts/04-register-weather-app.ps1 ` +# -TenantId "" ` +# -AgentIdentityAppId "" ` +# -DisplayName "AUID Weather Agent (dev)" + +param( + [Parameter(Mandatory=$true)] [string] $TenantId, + [Parameter(Mandatory=$true)] [string] $AgentIdentityAppId, + [string] $DisplayName = "AUID Weather Agent", + [string] $ScopeName = "Weather.Read", + [string] $EnvFile = ".env" +) + +$ErrorActionPreference = "Stop" + +Import-Module Microsoft.Graph.Authentication -ErrorAction Stop +Connect-MgGraph -TenantId $TenantId -Scopes "Application.ReadWrite.All","DelegatedPermissionGrant.ReadWrite.All" -NoWelcome | Out-Null + +# 1. Create the Weather Agent app + SP if it doesn't exist. +$existing = az ad app list --display-name "$DisplayName" --query "[0]" -o json | ConvertFrom-Json +if ($existing) { + Write-Host "Weather Agent app '$DisplayName' already exists: $($existing.appId)" + $appId = $existing.appId + $objectId = $existing.id +} else { + $created = az ad app create --display-name "$DisplayName" --sign-in-audience "AzureADMyOrg" -o json | ConvertFrom-Json + $appId = $created.appId + $objectId = $created.id + Write-Host "Created Weather Agent app: $appId" +} + +# 2. Set Application ID URI = api:// +$idUri = "api://$appId" +az ad app update --id $appId --identifier-uris $idUri | Out-Null + +# 3. Add the Weather.Read scope (delegated) to api.oauth2PermissionScopes if missing. +$app = az ad app show --id $appId -o json | ConvertFrom-Json +$scopes = @($app.api.oauth2PermissionScopes) +if (-not ($scopes | Where-Object { $_.value -eq $ScopeName })) { + $newScope = @{ + id = [guid]::NewGuid().ToString() + adminConsentDescription = "Read weather as the agentic user." + adminConsentDisplayName = "Read weather" + isEnabled = $true + type = "User" + userConsentDescription = "Allow the agent to read weather on your behalf." + userConsentDisplayName = "Read weather" + value = $ScopeName + } + $scopes += $newScope + $body = @{ api = @{ oauth2PermissionScopes = $scopes } } | ConvertTo-Json -Depth 10 + $tmp = New-TemporaryFile + $body | Out-File -FilePath $tmp -Encoding utf8 + az rest --method PATCH ` + --url "https://graph.microsoft.com/v1.0/applications/$objectId" ` + --headers "Content-Type=application/json" ` + --body "@$tmp" | Out-Null + Remove-Item $tmp + Write-Host "Added scope $ScopeName to $appId" +} + +# 4. Ensure the Weather Agent SP exists (consent targets the SP, not the app). +$sp = az ad sp show --id $appId -o json 2>$null | ConvertFrom-Json +if (-not $sp) { + $sp = az ad sp create --id $appId -o json | ConvertFrom-Json + Write-Host "Created Weather Agent SP: $($sp.id)" +} + +# 5. Ensure the Agent Identity SP exists. +$agentSp = az ad sp show --id $AgentIdentityAppId -o json 2>$null | ConvertFrom-Json +if (-not $agentSp) { + Write-Error "Agent Identity SP for $AgentIdentityAppId not found. Run the entra-agent-id-setup workflow first." + exit 1 +} + +# 6. Grant Agent Identity → Weather Agent (delegated, AllPrincipals) admin consent. +$grantBody = @{ + clientId = $agentSp.id + consentType = "AllPrincipals" + resourceId = $sp.id + scope = $ScopeName +} | ConvertTo-Json +try { + Invoke-MgGraphRequest -Method POST ` + -Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants" ` + -Body $grantBody -ContentType "application/json" | Out-Null + Write-Host "Granted Agent → Weather Agent ($ScopeName) admin consent." +} catch { + if ($_.Exception.Message -match "already exists|conflict") { + Write-Host "Admin consent already in place - skipping." + } else { throw } +} + +# 7. Persist to .env / vars file (or just print for the operator). +Write-Host "" +Write-Host "============================================================" +Write-Host " WEATHER_AGENT_APP_ID = $appId" +Write-Host " WEATHER_AGENT_APP_ID_URI = $idUri" +Write-Host " WEATHER_AGENT_SCOPE = $ScopeName" +Write-Host "============================================================" +Write-Host "Add (or update) these in $EnvFile and your /tmp/deploy-vars.sh" diff --git a/.claude/skills/deploy-agent-aks-auid/ui/Dockerfile b/.claude/skills/deploy-agent-aks-auid/ui/Dockerfile new file mode 100644 index 0000000..716b0f8 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/ui/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:1.27-alpine +COPY index.html /usr/share/nginx/html/index.html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 8080 diff --git a/.claude/skills/deploy-agent-aks-auid/ui/index.html b/.claude/skills/deploy-agent-aks-auid/ui/index.html new file mode 100644 index 0000000..8971e7d --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/ui/index.html @@ -0,0 +1,512 @@ + + + + + + Powered by Microsoft Entra Agent ID — AUID + + + +
+ +
+
+
+

Powered by Microsoft Entra Agent ID

+
+ +
+ Tenant ID: + Agent ID: +
+ +
+
+
Execution Mode
+ + +
+
+
Identity Flow
+ + +
+
+ + + +
+
Backend
+
Weather Agent
+
Token Endpoint
+
Microsoft Graph
+
+ +
+
+ Hello — I'm the Weather Agent and I require an AUID token to answer. +

+ No human is in this flow. The Agentic User mints + its own access token via the Blueprint → Agent ID → AUID FIC chain, + then calls me. I validate the JWT signature against Entra JWKS, check + appid and idtyp=user, and return data scoped to that Agentic User. +

+ Try: "What is the weather in Dallas?" +
+
+ +
+ + +
+
+ + +
+
+
+

Agent Identity Flow

+
+
+
+
📋 READY
+
Ask a weather question — the full FIC chain (Blueprint → Agent ID → AUID → Weather call) will render here as it runs.
+
+
+
+
+ + + + diff --git a/.claude/skills/deploy-agent-aks-auid/ui/nginx.conf b/.claude/skills/deploy-agent-aks-auid/ui/nginx.conf new file mode 100644 index 0000000..0663ae3 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/ui/nginx.conf @@ -0,0 +1,19 @@ +server { + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Proxy /api/* to the backend Service (same namespace). + location /api/ { + proxy_pass http://backend.auid.svc.cluster.local:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 60s; + } + + location / { + try_files $uri /index.html; + } +} diff --git a/.claude/skills/deploy-agent-aks-auid/weather-agent/Dockerfile b/.claude/skills/deploy-agent-aks-auid/weather-agent/Dockerfile new file mode 100644 index 0000000..0b65a11 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/weather-agent/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV WEATHER_AGENT_PORT=8080 +EXPOSE 8080 +CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/.claude/skills/deploy-agent-aks-auid/weather-agent/app.py b/.claude/skills/deploy-agent-aks-auid/weather-agent/app.py new file mode 100644 index 0000000..370f3a9 --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/weather-agent/app.py @@ -0,0 +1,225 @@ +""" +weather-agent/app.py — Downstream API that validates AUID bearer tokens +WITH FULL SIGNATURE VERIFICATION against the Entra v2 JWKS for its own +audience (the WEATHER_AGENT_APP_ID), then returns weather data. + +This is the proper third-party API pattern. Because the AUID token is now +issued for `api://` (not Graph), the signature is verifiable by +us — no Graph-style nonce-rehashing problem. + +Claim contract enforced on every request: + • signature : valid against tenant JWKS, key matched by kid + • iss : https://login.microsoftonline.com//v2.0 + (or https://sts.windows.net// for v1 fallback) + • tid : == TENANT_ID + • aud : == WEATHER_AGENT_APP_ID (or app id URI) + • appid/azp : == AGENT_IDENTITY_APP_ID (the agent making the call) + • idtyp : "user" ← AUID hallmark + • upn : == expected agentic user (if AGENT_USER_UPN set) + • exp : not in the past +""" +from __future__ import annotations + +import os +import time +from typing import Any, Dict, List + +import httpx +from dotenv import load_dotenv +from fastapi import FastAPI, Header, HTTPException, Query +from fastapi.middleware.cors import CORSMiddleware +from jose import jwt +from jose.exceptions import JWTError + +load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env")) + +TENANT_ID = os.environ["TENANT_ID"] +WEATHER_AGENT_APP_ID = os.environ["WEATHER_AGENT_APP_ID"] +WEATHER_AGENT_APP_ID_URI = os.getenv("WEATHER_AGENT_APP_ID_URI", "") +EXPECTED_AGENT_APP_ID = os.environ["AGENT_IDENTITY_APP_ID"] +EXPECTED_AGENT_USER_UPN = os.getenv("AGENT_USER_UPN", "").lower() + +JWKS_URL = f"https://login.microsoftonline.com/{TENANT_ID}/discovery/v2.0/keys" +ISSUER_V2 = f"https://login.microsoftonline.com/{TENANT_ID}/v2.0" +ISSUER_V1 = f"https://sts.windows.net/{TENANT_ID}/" + +ACCEPTED_AUDIENCES: List[str] = [WEATHER_AGENT_APP_ID] +if WEATHER_AGENT_APP_ID_URI: + ACCEPTED_AUDIENCES.append(WEATHER_AGENT_APP_ID_URI) + +app = FastAPI(title="Weather Agent (AUID-validating, signature-verifying)") +app.add_middleware( + CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"] +) + +_jwks_cache: Dict[str, Any] = {} +_jwks_fetched_at: float = 0.0 +_JWKS_TTL = 3600.0 + + +async def _jwks() -> Dict[str, Any]: + global _jwks_fetched_at + if not _jwks_cache or (time.time() - _jwks_fetched_at) > _JWKS_TTL: + async with httpx.AsyncClient(timeout=15) as c: + r = await c.get(JWKS_URL) + r.raise_for_status() + _jwks_cache.clear() + _jwks_cache.update(r.json()) + _jwks_fetched_at = time.time() + return _jwks_cache + + +def _find_key(jwks: Dict[str, Any], kid: str) -> Dict[str, Any]: + for k in jwks.get("keys", []): + if k.get("kid") == kid: + return k + raise HTTPException(401, f"Signing key kid={kid} not in JWKS") + + +async def _validate(token: str) -> Dict[str, Any]: + # 1) Parse the header to find kid + try: + unverified_header = jwt.get_unverified_header(token) + except JWTError as e: + raise HTTPException(401, f"Token header parse failed: {e}") + kid = unverified_header.get("kid") + if not kid: + raise HTTPException(401, "Token header missing kid") + + # 2) Fetch JWKS and locate the signing key + jwks = await _jwks() + key = _find_key(jwks, kid) + + # 3) Verify signature + standard claims (iss, aud, exp). Accept v1 or v2 issuer. + last_error: Exception | None = None + claims: Dict[str, Any] | None = None + for issuer in (ISSUER_V2, ISSUER_V1): + try: + claims = jwt.decode( + token, + key, + algorithms=[key.get("alg", "RS256")], + audience=ACCEPTED_AUDIENCES, + issuer=issuer, + options={"verify_at_hash": False}, + ) + break + except JWTError as e: + last_error = e + claims = None + if claims is None: + raise HTTPException(401, f"Signature/claim validation failed: {last_error}") + + # 4) AUID-specific claim checks (not covered by jose.decode) + if claims.get("tid") != TENANT_ID: + raise HTTPException(401, f"Unexpected tid {claims.get('tid')}") + appid = claims.get("appid") or claims.get("azp") + if appid != EXPECTED_AGENT_APP_ID: + raise HTTPException( + 403, f"Unexpected appid/azp {appid}; expected {EXPECTED_AGENT_APP_ID}" + ) + if claims.get("idtyp") != "user": + raise HTTPException( + 403, f"Expected idtyp=user (AUID hallmark), got {claims.get('idtyp')}" + ) + if EXPECTED_AGENT_USER_UPN: + token_upn = str(claims.get("upn", "")).lower() + if token_upn != EXPECTED_AGENT_USER_UPN: + raise HTTPException( + 403, + f"Unexpected Agentic User upn {token_upn}; expected {EXPECTED_AGENT_USER_UPN}", + ) + if claims.get("exp", 0) < int(time.time()): + raise HTTPException(401, "Token expired") + return claims + + +def _bearer(authorization: str | None) -> str: + if not authorization or not authorization.lower().startswith("bearer "): + raise HTTPException(401, "Missing Bearer token") + return authorization.split(" ", 1)[1] + + +@app.get("/health") +async def health(): + return { + "ok": True, + "expected_agent_app_id": EXPECTED_AGENT_APP_ID, + "expected_audiences": ACCEPTED_AUDIENCES, + "issuer_v2": ISSUER_V2, + "jwks_url": JWKS_URL, + } + + +@app.get("/whoami") +async def whoami(authorization: str | None = Header(None)): + """Echo back the validated AUID claims. Useful for the demo's step 04.""" + claims = await _validate(_bearer(authorization)) + return { + "auth": _auth_summary(claims), + "note": ( + "Signature verified against tenant JWKS. Audience matches this API. " + "idtyp=user + appid= confirms an AUID (not app-only, not a human)." + ), + } + + +@app.get("/weather") +async def weather( + city: str = Query("Dallas"), + authorization: str | None = Header(None), +): + claims = await _validate(_bearer(authorization)) + + geocode_url = "https://geocoding-api.open-meteo.com/v1/search" + async with httpx.AsyncClient(timeout=15) as c: + g = (await c.get(geocode_url, params={"name": city, "count": 1})).json() + if not g.get("results"): + raise HTTPException(404, f"City '{city}' not found") + lat, lon = g["results"][0]["latitude"], g["results"][0]["longitude"] + w = (await c.get( + "https://api.open-meteo.com/v1/forecast", + params={ + "latitude": lat, + "longitude": lon, + "current": "temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code", + }, + )).json() + cur = w["current"] + return { + "weather": { + "city": city, + "temperature_c": cur["temperature_2m"], + "humidity_pct": cur["relative_humidity_2m"], + "wind_kph": cur["wind_speed_10m"], + "as_of": cur["time"], + "source": "Open-Meteo", + }, + "auth": _auth_summary(claims), + "note": ( + "AUID token verified with FULL signature check against tenant JWKS — " + "the token's audience is this Weather Agent's app registration, not Graph." + ), + } + + +def _auth_summary(claims: Dict[str, Any]) -> Dict[str, Any]: + return { + "flow": "AUID (Agent ID User) — grant_type=user_fic via sidecar", + "agentic_user_upn": claims.get("upn"), + "agentic_user_oid": claims.get("oid"), + "agent_identity_app_id": claims.get("appid") or claims.get("azp"), + "tenant_id": claims.get("tid"), + "idtyp": claims.get("idtyp"), + "iss": claims.get("iss"), + "aud": claims.get("aud"), + "scopes": claims.get("scp"), + "validated_with": "signature (JWKS) + iss + aud + tid + appid + idtyp + upn", + "jwks_url": JWKS_URL, + } + + +if __name__ == "__main__": + import uvicorn + port = int(os.getenv("WEATHER_AGENT_PORT", "7200")) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/.claude/skills/deploy-agent-aks-auid/weather-agent/requirements.txt b/.claude/skills/deploy-agent-aks-auid/weather-agent/requirements.txt new file mode 100644 index 0000000..0b824bb --- /dev/null +++ b/.claude/skills/deploy-agent-aks-auid/weather-agent/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +httpx==0.27.2 +python-dotenv==1.0.1 +python-jose[cryptography]==3.3.0 diff --git a/.claude/skills/teardown-agent-aks-auid/SKILL.md b/.claude/skills/teardown-agent-aks-auid/SKILL.md new file mode 100644 index 0000000..6143262 --- /dev/null +++ b/.claude/skills/teardown-agent-aks-auid/SKILL.md @@ -0,0 +1,67 @@ +--- +name: teardown-agent-aks-auid +description: 'AI-led teardown of the Entra Agent ID User (AUID) demo deployed to AKS by deploy-agent-aks-auid. Removes the k8s namespace, resource group (AKS + ACR), FIC on the Blueprint, Agentic User object, Weather Agent app registration, OAuth grants, and optionally the Blueprint and Agent Identity. Safe by default: -DryRun is $true, -DeleteEntra is $false. Cross-tenant aware. Pairs with deploy-agent-aks-auid.' +--- + +# Teardown — Entra Agent ID User (AUID) Demo on AKS (AI-Led) + +Reverses the [`deploy-agent-aks-auid`](../deploy-agent-aks-auid/SKILL.md) skill. Removes every object the deploy and Entra setup scripts create. + +**Paired with:** [`deploy-agent-aks-auid`](../deploy-agent-aks-auid/SKILL.md). + +## What this removes + +| Object | Script that created it | Condition | +|---|---|---| +| k8s `auid` namespace (graceful pod termination) | `04-apply-manifests.ps1` | always | +| FIC on Blueprint (`aks-backend-sa`) | `03-federate-blueprint.ps1` | always | +| OAuth consent grants on Agent Identity SP | `02-grant-agentic-user-consent.ps1` | always | +| Weather Agent app registration + SP | `04-register-weather-app.ps1` | always | +| Resource group (AKS + ACR + LB + PVCs) | `01-create-aks.ps1` | always | +| Agentic User (`microsoft.graph.agentUser`) | `01-provision-agentic-user.ps1` | `-DeleteEntra` | +| Agent Identity app | `entra-agent-id-setup` | `-DeleteEntra` | +| Blueprint app | `entra-agent-id-setup` | `-DeleteEntra` (prompted again) | + +## Safety posture + +1. **Dry-run by default** (`-DryRun` is `$true`). Must pass `-DryRun:$false` to actually delete. +2. **Entra objects are opt-in** (`-DeleteEntra`). Default keeps Blueprint, Agent Identity, Agentic User. +3. **Blueprint re-confirmed** — even with `-DeleteEntra`, the orchestrator prompts again before deleting the Blueprint because Blueprints are frequently shared. +4. **Confirm tenant + subscription** with the user before running. Wrong-tenant teardowns are unrecoverable. + +## Prerequisites + +1. Your `deploy-vars.ps1` (at minimum: `SUBSCRIPTION_ID`, `RG`, `TENANT_ID`, `BLUEPRINT_APP_ID`; also `AGENT_IDENTITY_APP_ID`, `WEATHER_AGENT_APP_ID`, `AGENT_USER_UPN` if cleaning those). +2. `az` logged into both tenants for cross-tenant deploys. +3. `pwsh` 7.4+ with `Microsoft.Graph.Authentication` (`Install-Module Microsoft.Graph.Authentication -Scope CurrentUser`). +4. **Graph roles**: `Application.ReadWrite.OwnedBy` (FIC + Weather Agent app); `User.ReadWrite.All` (Agentic User delete); `Application.ReadWrite.All` if using `-DeleteEntra`. + +## One-Shot Orchestrator + +```powershell +$env:VARS_FILE = "$HOME/deploy-vars-auid.ps1" + +# Dry run — prints all steps, deletes nothing +pwsh -NoProfile -File .claude/skills/teardown-agent-aks-auid/scripts/teardown-aks-auid.ps1 + +# Real teardown — k8s namespace + RG + FIC + OAuth grants + Weather Agent app +pwsh -NoProfile -File .claude/skills/teardown-agent-aks-auid/scripts/teardown-aks-auid.ps1 -DryRun:$false + +# Full teardown — everything above + Agentic User + Agent Identity + Blueprint (each prompted) +pwsh -NoProfile -File .claude/skills/teardown-agent-aks-auid/scripts/teardown-aks-auid.ps1 -DryRun:$false -DeleteEntra + +# Just remove the FIC (no RG touch) +pwsh -NoProfile -File .claude/skills/teardown-agent-aks-auid/scripts/teardown-aks-auid.ps1 -FicOnly +``` + +## Cross-tenant teardown + +| Step | Tenant used | +|---|---| +| Revoke OAuth grants, delete FIC, delete Weather Agent app, delete Agentic User | `$TENANT_ID` (Entra objects) | +| Delete RG | `$SUBSCRIPTION_TENANT_ID` (Azure subscription) | + +## References + +- [`deploy-agent-aks-auid`](../deploy-agent-aks-auid/SKILL.md) — the deploy skill this reverses +- [`deploy-agent-aks-auid/PERMISSIONS.md`](../deploy-agent-aks-auid/PERMISSIONS.md) — full Graph scope reference diff --git a/.claude/skills/teardown-agent-aks-auid/scripts/teardown-aks-auid.ps1 b/.claude/skills/teardown-agent-aks-auid/scripts/teardown-aks-auid.ps1 new file mode 100644 index 0000000..e947bb7 --- /dev/null +++ b/.claude/skills/teardown-agent-aks-auid/scripts/teardown-aks-auid.ps1 @@ -0,0 +1,322 @@ +# teardown-aks-auid.ps1 — complete teardown of the deploy-agent-aks-auid skill. +# +# Removes every object the AUID demo setup creates: +# - k8s `auid` namespace +# - OAuth consent grants on Agent Identity SP +# - FIC on Blueprint +# - Weather Agent app registration + SP +# - Resource group (AKS + ACR + LB + PVCs) +# - Agentic User (`microsoft.graph.agentUser`) — opt-in via -DeleteEntra +# - Agent Identity app — opt-in via -DeleteEntra +# - Blueprint app — opt-in via -DeleteEntra (re-prompted) +# +# Safe by default: -DryRun is $true, -DeleteEntra is $false. +# +# Usage: +# pwsh -NoProfile -File teardown-aks-auid.ps1 # dry-run +# pwsh -NoProfile -File teardown-aks-auid.ps1 -DryRun:$false # real, RG + FIC + Weather app +# pwsh -NoProfile -File teardown-aks-auid.ps1 -DryRun:$false -DeleteEntra # full +# pwsh -NoProfile -File teardown-aks-auid.ps1 -FicOnly # FIC only, no RG touch +# +# Exit codes: +# 0 — completed (or dry-run completed) +# 1 — missing required vars / preflight failed +# 2 — user aborted at confirmation prompt + +param( + [string] $VarsFile = "", + [switch] $DryRun = $true, + [switch] $DeleteEntra = $false, + [switch] $FicOnly = $false +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# Resolve vars file. +if (-not $VarsFile) { + $VarsFile = if ($env:VARS_FILE) { $env:VARS_FILE } else { + Join-Path ([System.IO.Path]::GetTempPath()) "deploy-vars.ps1" + } +} +if (-not (Test-Path $VarsFile)) { + Write-Error "ERROR: '$VarsFile' not found. Set -VarsFile or `$env:VARS_FILE." + exit 1 +} +. $VarsFile + +if (-not $env:SUBSCRIPTION_TENANT_ID) { $env:SUBSCRIPTION_TENANT_ID = $env:TENANT_ID } +if (-not $env:FIC_NAME) { $env:FIC_NAME = "aks-backend-sa" } + +# Validate required vars. +if (-not $env:TENANT_ID) { Write-Error "TENANT_ID required in $VarsFile"; exit 1 } +if (-not $env:BLUEPRINT_APP_ID) { Write-Error "BLUEPRINT_APP_ID required in $VarsFile"; exit 1 } +if (-not $FicOnly) { + if (-not $env:SUBSCRIPTION_ID) { Write-Error "SUBSCRIPTION_ID required in $VarsFile"; exit 1 } + if (-not $env:RG) { Write-Error "RG required in $VarsFile"; exit 1 } +} + +function Invoke-Step([string]$description, [scriptblock]$action) { + if ($DryRun) { + Write-Host "DRY-RUN: $description" + } else { + Write-Host "+ $description" + & $action + } +} + +function Confirm-Step([string]$prompt) { + if ($DryRun) { + Write-Host "DRY-RUN: would prompt '$prompt' — assuming yes" + return $true + } + $ans = Read-Host "$prompt [y/N]" + return $ans -eq 'y' -or $ans -eq 'Y' +} + +# ---------------------------------------------------------------------- +# Print teardown plan. +# ---------------------------------------------------------------------- +Write-Host "============================================================" +Write-Host "Teardown plan (AKS / Entra Agent ID User demo)" +if (-not $FicOnly) { + Write-Host " Subscription tenant: $env:SUBSCRIPTION_TENANT_ID" + Write-Host " Subscription: $env:SUBSCRIPTION_ID" + Write-Host " Resource group: $env:RG (WILL be deleted)" +} +Write-Host " Entra tenant: $env:TENANT_ID" +Write-Host " Blueprint app: $env:BLUEPRINT_APP_ID" +Write-Host " └─ FIC to remove: $env:FIC_NAME" +if ($env:WEATHER_AGENT_APP_ID) { + Write-Host " Weather Agent app: $env:WEATHER_AGENT_APP_ID (WILL be deleted)" +} +if ($env:AGENT_USER_UPN) { + Write-Host " Agentic User: $env:AGENT_USER_UPN (deleted only with -DeleteEntra)" +} +Write-Host " Delete Entra apps: $DeleteEntra" +Write-Host " Dry run: $DryRun" +Write-Host " FIC-only mode: $FicOnly" +Write-Host "============================================================" +if (-not (Confirm-Step "Proceed?")) { Write-Host "Aborted."; exit 2 } + +Import-Module Microsoft.Graph.Authentication -ErrorAction Stop + +# ---------------------------------------------------------------------- +# FIC-only fast path. +# ---------------------------------------------------------------------- +if ($FicOnly) { + Write-Host "" + Write-Host "Step F — Delete FIC '$env:FIC_NAME' from Blueprint $env:BLUEPRINT_APP_ID" + Connect-MgGraph -TenantId $env:TENANT_ID -Scopes "Application.ReadWrite.OwnedBy" -NoWelcome | Out-Null + try { + $bpApp = Invoke-MgGraphRequest -Method GET ` + -Uri "https://graph.microsoft.com/v1.0/applications(appId='$($env:BLUEPRINT_APP_ID)')?`$select=id" + $fics = Invoke-MgGraphRequest -Method GET ` + -Uri "https://graph.microsoft.com/v1.0/applications/$($bpApp.id)/federatedIdentityCredentials" + $fic = $fics.value | Where-Object { $_.name -eq $env:FIC_NAME } | Select-Object -First 1 + if (-not $fic) { + Write-Host " (FIC '$env:FIC_NAME' not present — nothing to do)"; exit 0 + } + Invoke-Step "DELETE FIC $($fic.id) ('$env:FIC_NAME') from Blueprint $env:BLUEPRINT_APP_ID" { + Invoke-MgGraphRequest -Method DELETE ` + -Uri "https://graph.microsoft.com/v1.0/applications/$($bpApp.id)/federatedIdentityCredentials/$($fic.id)" + } + Write-Host " FIC removed." + } catch { + if ($_.Exception.Message -match '404|Request_ResourceNotFound|NotFound') { + Write-Host " (Blueprint app not found — nothing to do)" + } else { throw } + } + exit 0 +} + +# ---------------------------------------------------------------------- +# Step 1: Revoke OAuth consent grants on Agent Identity SP. +# ---------------------------------------------------------------------- +if ($env:AGENT_IDENTITY_APP_ID) { + Write-Host "" + Write-Host "Step 1 — Revoke OAuth consent grants on Agent Identity SP ($env:AGENT_IDENTITY_APP_ID)" + try { + Connect-MgGraph -TenantId $env:TENANT_ID -Scopes "DelegatedPermissionGrant.ReadWrite.All","Application.Read.All" -NoWelcome | Out-Null + $agentSp = Invoke-MgGraphRequest -Method GET ` + -Uri "https://graph.microsoft.com/v1.0/servicePrincipals(appId='$($env:AGENT_IDENTITY_APP_ID)')?`$select=id" ` + -ErrorAction SilentlyContinue + if ($agentSp) { + $grants = (Invoke-MgGraphRequest -Method GET ` + -Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?`$filter=clientId eq '$($agentSp.id)'").value + if ($grants -and $grants.Count -gt 0) { + foreach ($g in $grants) { + Invoke-Step "DELETE oauth2PermissionGrant $($g.id)" { + Invoke-MgGraphRequest -Method DELETE ` + -Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$($g.id)" + } + } + } else { + Write-Host " (no grants found)" + } + } else { + Write-Host " (Agent Identity SP not found — skipping)" + } + } catch { + Write-Host " WARN: $($_.Exception.Message) — continuing" + } +} else { + Write-Host "" + Write-Host "Step 1 — Skipping OAuth grant revocation (AGENT_IDENTITY_APP_ID not set)" +} + +# ---------------------------------------------------------------------- +# Step 2: Delete FIC on Blueprint. +# ---------------------------------------------------------------------- +Write-Host "" +Write-Host "Step 2 — Delete FIC '$env:FIC_NAME' on Blueprint $env:BLUEPRINT_APP_ID" +try { + Connect-MgGraph -TenantId $env:TENANT_ID -Scopes "Application.ReadWrite.OwnedBy" -NoWelcome | Out-Null + $bpApp = Invoke-MgGraphRequest -Method GET ` + -Uri "https://graph.microsoft.com/v1.0/applications(appId='$($env:BLUEPRINT_APP_ID)')?`$select=id" + $fics = Invoke-MgGraphRequest -Method GET ` + -Uri "https://graph.microsoft.com/v1.0/applications/$($bpApp.id)/federatedIdentityCredentials" + $fic = $fics.value | Where-Object { $_.name -eq $env:FIC_NAME } | Select-Object -First 1 + if ($fic) { + Invoke-Step "DELETE FIC $($fic.id) ('$env:FIC_NAME') from Blueprint $env:BLUEPRINT_APP_ID" { + Invoke-MgGraphRequest -Method DELETE ` + -Uri "https://graph.microsoft.com/v1.0/applications/$($bpApp.id)/federatedIdentityCredentials/$($fic.id)" + } + Write-Host " FIC removed." + } else { + Write-Host " (FIC '$env:FIC_NAME' not present — skipping)" + } +} catch { + if ($_.Exception.Message -match '404|Request_ResourceNotFound|NotFound') { + Write-Host " (Blueprint app not found — skipping FIC delete)" + } else { + Write-Host " WARN: $($_.Exception.Message) — continuing" + } +} + +# ---------------------------------------------------------------------- +# Step 3: Delete Weather Agent app registration. +# ---------------------------------------------------------------------- +if ($env:WEATHER_AGENT_APP_ID) { + Write-Host "" + Write-Host "Step 3 — Delete Weather Agent app registration ($env:WEATHER_AGENT_APP_ID)" + try { + Connect-MgGraph -TenantId $env:TENANT_ID -Scopes "Application.ReadWrite.OwnedBy" -NoWelcome | Out-Null + Invoke-Step "az ad app delete --id $env:WEATHER_AGENT_APP_ID" { + & az ad app delete --id $env:WEATHER_AGENT_APP_ID 2>$null + } + Write-Host " Weather Agent app removed." + } catch { + Write-Host " WARN: $($_.Exception.Message) — continuing" + } +} else { + Write-Host "" + Write-Host "Step 3 — Skipping Weather Agent app deletion (WEATHER_AGENT_APP_ID not set)" +} + +# ---------------------------------------------------------------------- +# Step 4: Delete the `auid` namespace (graceful k8s cleanup). +# ---------------------------------------------------------------------- +Write-Host "" +Write-Host "Step 4 — Delete k8s namespace 'auid' (graceful pod termination before RG delete)" +if (-not $DryRun) { + $nsExists = kubectl get namespace auid 2>$null + if ($nsExists) { + Write-Host "+ kubectl delete namespace auid --timeout=120s" + kubectl delete namespace auid --timeout=120s 2>$null | Out-Null + Write-Host " Namespace deleted." + } else { + Write-Host " (namespace 'auid' not found or cluster not reachable — skipping)" + } +} else { + Write-Host "DRY-RUN: kubectl delete namespace auid --timeout=120s" +} + +# ---------------------------------------------------------------------- +# Step 5: Delete resource group (AKS + ACR + LB + PVCs). +# ---------------------------------------------------------------------- +Write-Host "" +Write-Host "Step 5 — Delete resource group $env:RG (in sub $env:SUBSCRIPTION_ID)" +if (-not $DryRun) { + & az account set --subscription $env:SUBSCRIPTION_ID + $rgExists = (& az group exists --name $env:RG) -eq "true" + if ($rgExists) { + Write-Host "+ az group delete --name $env:RG --yes --no-wait" + & az group delete --name $env:RG --yes --no-wait + Write-Host " Deletion initiated (--no-wait). Check status: az group show -n $env:RG" + } else { + Write-Host " (RG does not exist — skipping)" + } +} else { + Write-Host "DRY-RUN: az account set --subscription $env:SUBSCRIPTION_ID" + Write-Host "DRY-RUN: az group delete --name $env:RG --yes --no-wait" +} + +# ---------------------------------------------------------------------- +# Step 6: Entra cleanup (opt-in). +# ---------------------------------------------------------------------- +if ($DeleteEntra) { + Write-Host "" + Write-Host "Step 6 — Delete Entra objects (in tenant $env:TENANT_ID)" + + if ($env:AGENT_USER_UPN -and (Confirm-Step "Delete Agentic User ($env:AGENT_USER_UPN)?")) { + Invoke-Step "DELETE agentUser $env:AGENT_USER_UPN via Graph beta" { + Connect-MgGraph -TenantId $env:TENANT_ID -Scopes "User.ReadWrite.All" -NoWelcome | Out-Null + try { + $user = Invoke-MgGraphRequest -Method GET ` + -Uri "https://graph.microsoft.com/beta/users/$($env:AGENT_USER_UPN)?`$select=id" ` + -ErrorAction SilentlyContinue + if ($user) { + Invoke-MgGraphRequest -Method DELETE ` + -Uri "https://graph.microsoft.com/beta/users/$($user.id)" + Write-Host " Agentic User deleted." + } else { + Write-Host " (Agentic User not found — skipping)" + } + } catch { + Write-Host " WARN: $($_.Exception.Message)" + } + } + } + + if ($env:AGENT_IDENTITY_APP_ID -and (Confirm-Step "Delete Agent Identity app ($env:AGENT_IDENTITY_APP_ID)?")) { + Invoke-Step "az ad app delete --id $env:AGENT_IDENTITY_APP_ID" { + & az ad app delete --id $env:AGENT_IDENTITY_APP_ID 2>$null + } + } + + Write-Host "" + Write-Host " *** Blueprint ($env:BLUEPRINT_APP_ID) is often SHARED across agents. ***" + if (Confirm-Step "Are you SURE you want to delete the Blueprint?") { + Invoke-Step "az ad app delete --id $env:BLUEPRINT_APP_ID" { + & az ad app delete --id $env:BLUEPRINT_APP_ID 2>$null + } + } +} else { + Write-Host "" + Write-Host "Step 6 — Skipping Entra app deletion (-DeleteEntra not specified)" +} + +# ---------------------------------------------------------------------- +# Step 7: Verify. +# ---------------------------------------------------------------------- +Write-Host "" +Write-Host "Step 7 — Verify" +if ($DryRun) { + Write-Host "DRY-RUN: skipping verification" +} else { + $rgRemains = (& az group exists --name $env:RG 2>$null) + Write-Host " RG exists? $rgRemains" + if ($env:BLUEPRINT_APP_ID) { + $ficRemain = & az ad app federated-credential list --id $env:BLUEPRINT_APP_ID ` + --query "[?name=='$env:FIC_NAME'] | length(@)" -o tsv 2>$null + Write-Host " FICs named '$env:FIC_NAME' remaining on Blueprint: $($ficRemain ?? '?')" + } + if ($env:WEATHER_AGENT_APP_ID) { + $weatherCheck = & az ad app show --id $env:WEATHER_AGENT_APP_ID 2>&1 | Select-Object -First 1 + Write-Host " Weather Agent app: $weatherCheck" + } +} + +Write-Host "" +Write-Host "Done. If -DryRun, re-run with -DryRun:`$false to actually delete."