From 9026bcae72acae80ee26e099eaec4d34e26a4f10 Mon Sep 17 00:00:00 2001 From: "B. Brandon Werner" Date: Tue, 1 Sep 2026 09:04:46 -0700 Subject: [PATCH] docs: move planned work to GitHub issues Create a prioritized issue backlog for executive demo recovery, prototype hardening, and federation expansion. Remove superseded roadmap documents and replace actionable TODOs with issue links while preserving durable architecture and platform learnings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 71cc0af5-8229-461b-ae4c-54131b1bd6c7 --- CONTRIBUTING.md | 7 + GITHUB-FEDERATION-HOWTO.md | 2 +- GOOGLE-FEDERATION-HOWTO.md | 9 +- docs/architecture/admin-governance-layer.md | 17 +- .../next-github-actions-agent-federation.md | 281 ------ .../next-google-cloud-agent-federation.md | 860 ------------------ .../008-opa-complementary-not-replacement.md | 2 +- ...010-conditional-access-admin-governance.md | 14 +- .../Conditional-Access-Learnings.md | 13 +- .../GitHub-Actions-Federation.md | 2 +- .../Google-Cloud-Federation.md | 4 +- docs/runbooks/hard-won-learnings.md | 8 +- infra/modules/spire-server-vm.bicep | 2 +- mkdocs.yml | 3 - src/admin-control-plane/ca_evaluator.py | 7 +- src/budget-approval/ca_evaluator.py | 7 +- src/budget-backend/ca_evaluator.py | 7 +- src/budget-report/ca_evaluator.py | 7 +- src/demo-agent/ca_evaluator.py | 7 +- src/employee-menus/ca_evaluator.py | 7 +- src/shared/ca_evaluator.py | 7 +- src/spiffe-proxy/AGENTS.md | 2 +- src/spiffe-proxy/internal/ca/graph_client.go | 4 +- src/spiffe-proxy/internal/ca/policy_cache.go | 2 +- 24 files changed, 72 insertions(+), 1209 deletions(-) delete mode 100644 docs/architecture/next-github-actions-agent-federation.md delete mode 100644 docs/architecture/next-google-cloud-agent-federation.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a63a87..8ea2d17 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,6 +24,13 @@ instructions when prompted. 3. Update documentation when behavior, setup, deployment, or public APIs change. 4. Run targeted validation for the changed area before opening a pull request. +Track planned work, deferred decisions, and implementation checklists in +[GitHub Issues](https://github.com/microsoft/identity-spiffe/issues). Keep +repository documentation focused on current architecture, accepted decisions, +operational runbooks, reference material, and verified platform learnings. +Link to an issue instead of adding an actionable `TODO` to documentation or +source comments. + Useful validation commands: ```bash diff --git a/GITHUB-FEDERATION-HOWTO.md b/GITHUB-FEDERATION-HOWTO.md index 345f7e3..4f8ba9f 100644 --- a/GITHUB-FEDERATION-HOWTO.md +++ b/GITHUB-FEDERATION-HOWTO.md @@ -6,8 +6,8 @@ One command (`./deploy.sh --new --github`) deploys the full stack end-to-end. **References:** -- Architecture: [`docs/architecture/next-github-actions-agent-federation.md`](docs/architecture/next-github-actions-agent-federation.md) - Platform learnings: [`docs/platform-learnings/GitHub-Actions-Federation.md`](docs/platform-learnings/GitHub-Actions-Federation.md) +- Productization and validation backlog: [#34](https://github.com/microsoft/identity-spiffe/issues/34) --- diff --git a/GOOGLE-FEDERATION-HOWTO.md b/GOOGLE-FEDERATION-HOWTO.md index 279bb28..6ac8474 100644 --- a/GOOGLE-FEDERATION-HOWTO.md +++ b/GOOGLE-FEDERATION-HOWTO.md @@ -6,7 +6,8 @@ One command (`./deploy.sh --new --google`) deploys the full stack end-to-end. **References:** -- Architecture: [`docs/architecture/next-google-cloud-agent-federation.md`](docs/architecture/next-google-cloud-agent-federation.md) +- Platform learnings: [`docs/platform-learnings/Google-Cloud-Federation.md`](docs/platform-learnings/Google-Cloud-Federation.md) +- Productization and validation backlog: [#35](https://github.com/microsoft/identity-spiffe/issues/35) --- @@ -139,7 +140,8 @@ SPIFFE SVID from trust domain gcp.aim.microsoft.com (3) JWT present with required roles, (4) RBAC policy permits path+method ``` -For the full architecture, see [`docs/architecture/next-google-cloud-agent-federation.md`](docs/architecture/next-google-cloud-agent-federation.md). +For the identity and transport architecture, see +[`docs/platform-learnings/Google-Cloud-Federation.md`](docs/platform-learnings/Google-Cloud-Federation.md). --- @@ -234,7 +236,8 @@ Exchange trust bundles on both SPIRE servers and add a `federatesWith` entry on ## See Also -- [`docs/architecture/next-google-cloud-agent-federation.md`](docs/architecture/next-google-cloud-agent-federation.md) — architecture decision record +- [#35](https://github.com/microsoft/identity-spiffe/issues/35) — productization and validation backlog +- [`docs/platform-learnings/Google-Cloud-Federation.md`](docs/platform-learnings/Google-Cloud-Federation.md) — identity and transport learnings - [`docs/runbooks/hard-won-learnings.md`](docs/runbooks/hard-won-learnings.md) — #29 (container env nuke), #30 (FIC numeric ID), #31 (MSAL FIC gap), #32 (proxy-only mode) - Platform learnings: Google, AWS, ServiceNow — see `docs/runbooks/` - [`scripts/add-google-agent.sh`](scripts/add-google-agent.sh) — Entra identity provisioning diff --git a/docs/architecture/admin-governance-layer.md b/docs/architecture/admin-governance-layer.md index a4f4401..afddfee 100644 --- a/docs/architecture/admin-governance-layer.md +++ b/docs/architecture/admin-governance-layer.md @@ -110,7 +110,10 @@ The repo now demonstrates two different Layer 4 stories: - **BudgetBackend data plane:** risk-level blocking enforced through the SPIFFE sidecar / admin-control-plane path. - **Direct A2A app path:** Entra-backed custom-attribute tag matching enforced at the target app after JWT validation. -Today, the direct A2A demo is still **post-token** deny, not guaranteed **token issuance** deny. The CA policy in the tenant is currently report-only, and the live blocking behavior is produced by synced Entra attribute state plus target-side enforcement. +Today, the direct A2A demo is still **post-token** deny, not guaranteed +**token issuance** deny. The deployment provisions an enabled high-risk Agent +Identity CA policy, while attribute matching is produced by synced Entra +attribute state plus target-side enforcement. This distinction matters when debugging the portal: - `JWT VALIDATED: Yes` + `403 CA DENIED` means the app/data-plane governance layer blocked the request after token issuance. @@ -297,13 +300,13 @@ CA evaluation results appear in **Entra sign-in logs** — the same logs admins - [What If Evaluation API](https://learn.microsoft.com/en-us/graph/api/conditionalaccessroot-evaluate) enables programmatic pre-deployment validation and CI/CD integration - [CA optimization agent](https://learn.microsoft.com/en-us/entra/security-copilot/conditional-access-agent-optimization) scans for unprotected agent identities and recommends policies -## Implementation Phases +## Current Implementation Boundary -| Phase | Scope | Timeline | Cost | -|---|---|---|---| -| 1 | Token-time CA on agent tokens | **In production** (1K+ customers) | Minimal — register as Entra Agent ID constructs | -| 2 | Data-plane CA in sidecar | Q1 FY27 | Moderate — evaluation client, caching, claims challenge | -| 3 | Unified visibility + What If | Q2 FY27 | Moderate — portal integration, bidirectional policy awareness | +The repository combines real token-time Agent Identity CA with prototype +post-token risk and tag enforcement. Generic application and service-principal +filter evaluation, claims-challenge integration, and unified What If +visibility are not complete. That work is tracked in +[#31](https://github.com/microsoft/identity-spiffe/issues/31). ## Related diff --git a/docs/architecture/next-github-actions-agent-federation.md b/docs/architecture/next-github-actions-agent-federation.md deleted file mode 100644 index 34808d3..0000000 --- a/docs/architecture/next-github-actions-agent-federation.md +++ /dev/null @@ -1,281 +0,0 @@ -# Next: GitHub Actions Agent via Entra + SPIFFE Federation - -**Status:** Reference design -**Mode:** Scoped implementation guide -**Target:** One GitHub Actions caller visible in the existing portal -**Supersedes:** Earlier broad cross-cloud federation assumptions -**Last updated:** 2026-04-18 - -## Executive Summary - -Add one GitHub Actions-hosted caller agent, `github-budget-reader`, that behaves like the existing Google-hosted caller but runs on a self-hosted GitHub Actions runner in Azure. - -It must prove two identity planes at the same time: - -1. **Entra identity plane** - - GitHub Actions OIDC token is exchanged into an **Entra Agent Identity** token through a **Blueprint-level Federated Identity Credential (FIC)**. - - The caller stays secretless. -2. **SPIFFE transport plane** - - The self-hosted runner presents a **SPIFFE ID** over mTLS through the existing SPIRE mesh (same trust domain as Azure agents — no federation needed since the runner is in-VNet). - -The portal must discover and render this GitHub caller through the **existing dynamic agent path**, with full GitHub provenance visibility (repo, workflow, ref, SHA, run ID). - -**Key differentiator:** Identity Research for Agent Management Using SPIFFE eliminates the FIC scaling problem. Azure limits you to 20 FICs per app registration. Identity Research for Agent Management Using SPIFFE solves this: one flexible FIC on the Agent Identity Blueprint trusts the GitHub org, and Identity Research for Agent Management Using SPIFFE's RBAC layer handles per-repo/per-workflow authorization. No per-repo FICs needed. This is deployment governance as identity policy. - -## Architecture - -``` -GitHub Actions (self-hosted runner on Azure VM) -┌─────────────────────────────────────────────┐ -│ GitHub Actions Workflow │ -│ ┌──────────────┐ ┌────────────────────┐ │ -│ │ identity- │ │ budget-check step │ │ -│ │ spiffe-auth │ │ │ │ -│ │ (OIDC→Entra) │ │ (curl via proxy) │ │ -│ └──────────────┘ └────────┬───────────┘ │ -│ ┌───────────────┐ │ │ -│ │ spiffe-proxy │←──────────┘ mTLS │ -│ │ (egress) │ │ -│ └───────┬───────┘ │ -│ ┌───────┴───────┐ │ -│ │ SPIRE Agent │ │ -│ └───────┬───────┘ │ -└──────────┼──────────────────────────────────┘ - │ (same Azure VNet — no VPN needed) - ▼ -┌──────────────────┐ ┌─────────────────────┐ -│ SPIRE Server │ │ budget-backend │ -│ (Azure VM) │ │ ┌──────┐┌───────┐ │ -│ td: aim.ms.com │ │ │Proxy ││ Agent │ │ -└──────────────────┘ │ │:8443 ││:8000 │ │ - │ └──────┘└───────┘ │ - └─────────────────────┘ -``` - -**Key difference from Google:** No VPN, no SPIRE federation, no separate trust domain. The self-hosted runner is an Azure VM in the same VNet, enrolled in the same SPIRE server. Simpler infrastructure, same identity model. - -## Implementation Phases - -### Phase 1: Core Identity (Vertical Slice) - -Add `GitHubOIDCProvider` to the credential provider strategy pattern. - -| File | Change | -|---|---| -| `src/shared/entra_token_exchange.py` | New `GitHubOIDCProvider` class. `TOKEN_SOURCE=github_oidc`. Fetches OIDC token from `ACTIONS_ID_TOKEN_REQUEST_URL`. Returns as Hop 0 assertion. | -| `src/shared/test_credential_providers.py` | Unit tests: provider selection, OIDC fetch success/failure, fail-closed on missing env vars, two-hop exchange with GitHub assertion. | - -**GitHub OIDC token retrieval:** -```bash -curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ - "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" -``` - -Hop 1 (FIC exchange) and Hop 2 (Entra agent token) are unchanged from the existing pipeline. - -### Phase 2: Provisioning - -| Script | Purpose | -|---|---| -| `scripts/add-github-agent.sh` | One-time setup: create Agent Identity, flexible FIC on Blueprint, assign `Budget.Read`, SPIFFE allow list, portal registration. | -| `scripts/add-github-repo.sh` | Per-repo onboarding: creates Agent Identity + RBAC entry + portal metadata only. No additional FIC needed. | - -**FIC is created ONCE on the Blueprint (wildcard).** Per-repo onboarding does NOT create additional FICs. - -**Flexible FIC shape (Graph API):** - -Flexible FICs use `claimsMatchingExpression`, NOT `subject` (they are mutually exclusive). Created via `az rest` or Graph API: - -```json -{ - "name": "github-actions-federation", - "issuer": "https://token.actions.githubusercontent.com", - "claimsMatchingExpression": { - "value": "claims['sub'] matches 'repo:microsoft/*'", - "languageVersion": 1 - }, - "audiences": ["api://AzureADTokenExchange"] -} -``` - -| Example Subject | Allowed? | Why | -|---|---|---| -| `repo:microsoft/identity-spiffe:ref:refs/heads/main` | ✅ | Matches `microsoft/*` | -| `repo:microsoft/infrastructure:environment:Production` | ✅ | Matches `microsoft/*` | -| `repo:attacker/malicious-repo:ref:refs/heads/main` | ❌ | Not in `microsoft/` org | -| Forked PR from external org | ❌ | Subject uses fork's org | - -### Phase 3: Infrastructure + Runner Bootstrap - -- Self-hosted runner VM provisioned in Azure VNet (no VPN needed) -- GitHub Actions runner installed, registered, and running as systemd service -- SPIRE agent installed on runner VM (same pattern as GCE) -- spiffe-proxy egress sidecar as persistent systemd service -- Bicep module: `infra/modules/github-runner-vm.bicep` -- `deploy.sh --github` flag wires the full flow -- GitHub org runner group configured with allowed repos and `identity-spiffe-runner` label -- Runner setup script adapted from `setup-gce-agent.sh` (strip GCE-specific sections) -- **Demo gate:** One workflow on one repo calls budget-backend through all 5 layers with static RBAC policy - -### Phase 4: RBAC Engine Extension + Provenance - -This phase extends the spiffe-proxy RBAC engine to support request-scoped tag evaluation. This is new Go code. - -- **`engine.go`**: Extend `Evaluate()` to accept request-scoped metadata (from Entra JWT custom claims). Tags keyed by request context, not just SPIFFE ID. -- **`validator.go`**: Parse custom Entra claims (GitHub provenance: repo, workflow, ref, sha, run_id) from the bearer token. -- **`access.go`**: Add GitHub provenance fields to audit log schema. -- **`policy.go`**: Allow per-SPIFFE-ID policy to reference request-scoped tags (e.g., `github.repo`, `github.workflow_ref`). -- **Portal**: Display GitHub provenance in agent identity view. -- **Entra custom claims**: Configure on Agent Identity app registration via Graph API. GitHub OIDC claims embedded during FIC exchange. -- **Note:** Layer 2 (SPIFFE ID in `federated_policies`) authorizes the *runner*. Layer 4b (request-scoped tags) authorizes the *repo/workflow*. These are different granularities by design. - -### Phase 5: Per-Repo Onboarding + Packaging (gated on Phase 4) - -- `scripts/add-github-repo.sh` — per-repo onboarding (Agent Identity + RBAC tag rules + portal metadata) -- Reusable GitHub Action: `.github/actions/identity-spiffe-auth/action.yml` -- `docs/platform-learnings/GitHub-Actions-Federation.md` -- Demo workflow: `.github/workflows/identity-spiffe-budget-check.yml` -- Update `README.md` and `CLAUDE.md` - -## GitHub OIDC Claim Trust Matrix - -Not all GitHub provenance attributes are equally trustworthy. RBAC policy should only make security decisions on **signed** claims. - -| Attribute | Source | Trust Level | Available In | RBAC Tag | -|---|---|---|---|---| -| `repository` | Signed OIDC claim | **Signed** | All events | `github.repo` | -| `repository_owner` | Signed OIDC claim | **Signed** | All events | `github.org` | -| `ref` | Signed OIDC claim | **Signed** | push, workflow_dispatch | `github.ref` | -| `sha` | Signed OIDC claim | **Signed** | All events | `github.sha` | -| `workflow` | Signed OIDC claim | **Signed** | All events | `github.workflow` | -| `job_workflow_ref` | Signed OIDC claim | **Signed** | All events | `github.workflow_ref` | -| `environment` | Signed OIDC claim | **Signed** | If configured | `github.environment` | -| `run_id` | Signed OIDC claim | **Signed** | All events | `github.run_id` | -| `runner_environment` | Signed OIDC claim | **Signed** | All events | `github.runner_env` | -| `actor` | Signed OIDC claim | **Locally observed** | All events | Informational only | -| `artifact_digest` | Not in OIDC token | **Derived** | Post-build only | Future (v2) | - -**Rule:** MVP RBAC policies use only **Signed** claims for authorization. Locally observed and derived claims are logged for portal visibility but not enforced. - -**Canonical claim for workflow enforcement:** Use `job_workflow_ref` (stable, includes path) for RBAC policy matching. Use `workflow` (display name) for portal display only. - -**Forked PRs:** GitHub OIDC tokens for forked PR workflows use `ref: refs/pull/N/merge` and the fork's repo as subject. The FIC wildcard `repo:microsoft/*` will NOT match forks from external orgs. This is correct security behavior. - -## Provenance Tag Transport - -GitHub OIDC claims travel inside the Entra token as custom claims, NOT as HTTP headers. This prevents spoofing by malicious workflows. - -1. **FIC exchange:** During `claimsMatchingExpression` evaluation, Azure FIC verifies the GitHub JWT. The signed claims are trustworthy because the FIC exchange succeeded. -2. **Custom Entra claims:** GitHub provenance (repo, workflow_ref, ref, sha, run_id) is configured as custom claims on the Agent Identity app registration via Graph API. These claims are embedded in the Entra access token during FIC exchange. -3. **Token parsing:** spiffe-proxy's `validator.go` extracts custom claims from the Entra JWT during Layer 3 validation. -4. **RBAC consumption:** The extracted claims are passed to `Evaluate()` as request-scoped tags. RBAC policy matches `github.*` tag rules against these values. -5. **Audit logging:** `access.go` logs GitHub provenance fields alongside SPIFFE ID and Entra identity. -6. **Portal display:** Portal reads provenance from audit logs / admin-CP discovery. - -## Runtime Sequence - -``` -1. Runner VM boots → SPIRE agent starts → attests to SPIRE server -2. spiffe-proxy starts on runner → obtains SVID from SPIRE agent -3. GitHub Actions workflow starts on runner -4. identity-spiffe-auth action step: - a. Fetches OIDC token from $ACTIONS_ID_TOKEN_REQUEST_URL - (audience: api://AzureADTokenExchange) - b. GitHubOIDCProvider exchanges OIDC token → Entra agent token - via FIC (Hop 0→1) - c. Entra agent token acquired for Budget.Read scope (Hop 2) -5. Workflow calls budget-backend via spiffe-proxy egress: - a. spiffe-proxy presents runner's SVID (mTLS) — Layer 1 - b. spiffe-proxy on budget-backend verifies SPIFFE ID - against federated_policies — Layer 2 - c. Entra JWT validated, scopes checked — Layer 3 - d. CA risk evaluation — Layer 4a - e. Tag-based auth (github.repo, github.workflow, github.ref) — Layer 4b -6. budget-backend responds with budget data -7. Portal logs: SPIFFE ID, Entra identity, GitHub provenance, all 5 decisions -``` - -The runner VM runs spiffe-proxy as a persistent systemd service (same as GCE pattern). The GitHub Action does NOT start spiffe-proxy — it's already running. - -## Minimum Workflow YAML - -```yaml -name: Identity Research for Agent Management Using SPIFFE Budget Check -on: workflow_dispatch -permissions: - id-token: write # Required for GitHub OIDC token - contents: read -jobs: - check-budget: - runs-on: [self-hosted, identity-spiffe-runner] - steps: - - uses: microsoft/identity-spiffe/.github/actions/identity-spiffe-auth@main - with: - spiffe-endpoint: ${{ vars.SPIFFE_ENDPOINT }} - - run: | - # spiffe-proxy egress handles mTLS — call via localhost - curl -s -H "Authorization: Bearer $SPIFFE_TOKEN" \ - http://localhost:8080/budget/remaining -``` - -## Lifecycle Management - -| Action | Script/Command | What It Does | -|---|---|---| -| **Onboard repo** | `add-github-repo.sh --repo org/repo` | Creates Agent Identity, RBAC entry, portal metadata. FIC already exists (wildcard). | -| **Rotate runner** | `scripts/reattest.sh` (existing) | Re-attests SPIRE agent on runner VM. New SVID issued. | -| **Revoke repo access** | `add-github-repo.sh --repo org/repo --revoke` | Removes Agent Identity, RBAC entry, portal metadata. | -| **Emergency deny** | Edit `spiffe-rbac-policy.yaml` — remove federated entry | Immediately blocks all GitHub callers at RBAC layer. | -| **Decommission runner** | Delete VM + remove SPIRE entry | Runner SPIFFE ID becomes invalid. mTLS calls fail. | -| **FIC compromise** | Delete FIC on Blueprint via Azure Portal/CLI | All GitHub OIDC → Entra exchanges fail immediately. | - -## Key Files Changed - -| File | What it does | -|---|---| -| `src/shared/entra_token_exchange.py` | `GitHubOIDCProvider` — `TOKEN_SOURCE=github_oidc`. Fetches OIDC from Actions env, returns as Hop 0 assertion. | -| `src/shared/test_credential_providers.py` | Unit tests for GitHub provider: selection, fetch, fail-closed, two-hop exchange. | -| `src/spiffe-proxy/internal/rbac/engine.go` | Extend `Evaluate()` to accept request-scoped metadata for tag matching. | -| `src/spiffe-proxy/internal/rbac/engine_test.go` | Tests for request-scoped tag evaluation in RBAC engine. | -| `src/spiffe-proxy/internal/oauth/validator.go` | Parse custom Entra claims (GitHub provenance) from bearer tokens. | -| `src/spiffe-proxy/internal/logging/access.go` | Add GitHub provenance fields to audit log schema. | -| `src/spiffe-proxy/config/spiffe-rbac-policy.yaml` | `federated_policies` entry for `github-budget-reader` with `github.*` tag rules. | -| `scripts/add-github-agent.sh` | One-time GitHub agent provisioning (Agent Identity, FIC, SPIFFE, portal). | -| `scripts/add-github-repo.sh` | Per-repo lightweight onboarding (Agent Identity + RBAC tag rules + portal only). | -| `scripts/lib/federation-helpers.sh` | Shared provisioning functions extracted from Google + GitHub scripts. | -| `infra/modules/github-runner-vm.bicep` | Self-hosted runner VM with SPIRE agent + GitHub Actions runner. | -| `deploy.sh` | `--github` flag for integrated deployment. | -| `.github/actions/identity-spiffe-auth/action.yml` | Reusable composite action for OIDC → Identity Research for Agent Management Using SPIFFE auth. | -| `.github/workflows/identity-spiffe-budget-check.yml` | Demo workflow. | -| `portal/app/routers/api.py` | GitHub provenance display in agent identity view. | -| `docs/platform-learnings/GitHub-Actions-Federation.md` | Platform learnings doc. | -| `scripts/current-deployment.sh` | Deployment status dashboard (shows all resources, identities, cross-cloud agents). | - -## Success Criteria - -1. GitHub Actions workflow successfully calls `budget-backend` through all 5 security layers -2. Portal shows GitHub provenance (repo, workflow, ref, run_id) in the agent identity view -3. Selectively disabling each layer produces the correct rejection (same as Google demo) -4. `deploy.sh --github` provisions the full stack from scratch -5. `add-github-repo.sh` onboards a new repo in under 60 seconds -6. `identity-spiffe-auth` GitHub Action works in any workflow on the approved runner pool -7. Platform learnings doc captures FIC scaling solution and hard-won learnings - -## Deferred Decisions - -1. **Idempotency and rollback in provisioning scripts.** Scripts should check-before-create and handle partial failures gracefully, same as `add-google-agent.sh`. Exact rollback order TBD during implementation. -2. **Runner pool GitHub org configuration.** The approved runner pool requires GitHub org settings (runner group, allowed repos, labels). This is org admin setup, not Identity Research for Agent Management Using SPIFFE code. Document in platform learnings. -3. **Runner trust vs repo trust separation.** Runner pool bootstrap (SPIRE enrollment, SPIFFE allow list) is done once. Repo onboarding (Agent Identity, RBAC, portal) is per-repo. These are separate tracks in `deploy.sh --github`. -4. **Demo agent naming.** Should be a new `github-budget-reader` app (like `google-budget-reader`) or reuse existing budget demo flow. TBD. - -## What Generalizes - -The GitHub federation validates that Identity Research for Agent Management Using SPIFFE's cross-cloud identity model works beyond Google: - -1. **Credential provider strategy pattern** — `TOKEN_SOURCE` selects the provider. Adding a new platform means adding one class. -2. **FIC on Blueprint** — the trust relationship lives on the Agent Identity Blueprint, not per-app. Scales to any number of repos/workflows. -3. **Federated RBAC policies** — same `federated_policies` section in spiffe-proxy, different trust domain / SPIFFE ID. -4. **Portal external agent store** — same `hosting_platform` field, different value. -5. **Provisioning script pattern** — `add-{platform}-agent.sh` follows the same 6-step structure. - -If this works, AWS (`--aws`) and ServiceNow (`--servicenow`) follow the same pattern. The stubs are already in `deploy.sh`. diff --git a/docs/architecture/next-google-cloud-agent-federation.md b/docs/architecture/next-google-cloud-agent-federation.md deleted file mode 100644 index 196d863..0000000 --- a/docs/architecture/next-google-cloud-agent-federation.md +++ /dev/null @@ -1,860 +0,0 @@ -# Next: Google Cloud Agent via Entra + SPIFFE Federation - -**Status:** Complete — Deployed and Validated -**Mode:** Hold Scope -**Target:** One Google-hosted read-only caller visible in the existing portal -**Supersedes:** Earlier broad cross-cloud federation assumptions and Azure-only caller assumptions -**Last updated:** 2026-04-03 - -## Executive Summary - -Add one Google-hosted caller agent, `google-budget-reader`, that behaves like the existing onboarded callers but runs on GCP. - -It must prove two different identity planes at the same time: - -1. **Entra identity plane** - - Google-native workload identity is exchanged into an **Entra Agent Identity** token through a **Blueprint-level Federated Identity Credential (FIC)**. - - The caller stays secretless. -2. **SPIFFE transport plane** - - The Google workload presents a **Google trust-domain SPIFFE ID** over mTLS to the Azure `budget-backend` sidecar through **SPIFFE federation**. - -The portal must discover and render this Google caller through the **existing dynamic agent path**, not through a special-case UI. - -This is the complete story for this feature. Not a platform rewrite. Not a generic multi-cloud agent framework. One real Google caller, one Azure target, same policy plane, same portal. - -## Implementation Status - -> **For the next engineer or AI picking this up:** Read this section first. It tells you exactly what's done, what's deployed, and what to do next. - -### What Is Complete (Phase 1 — all 4 workstreams) - -Phase 1 is **100% complete** with all unit tests passing. The live Azure environment runs `feature/crosscloud`. - -| Workstream | Status | Commits | Tests | -|---|---|---|---| -| **A: Entra Provisioning** | ✅ Done | `f353f144` | Manual (script output verified) | -| **B: Credential Provider Strategy Pattern** | ✅ Done | `0087d80d` | 17 Python tests passing | -| **C: RBAC Schema Extension** | ✅ Done | `f353f144` | 14 Go tests passing | -| **D: Portal External-Agent Storage** | ✅ Done | `f353f144`, `650422af` | 11 Python tests passing | -| **Admin-CP Discovery** | ✅ Done | `f353f144` | Functional (integrated) | - -**Key files changed:** - -| File | What it does | -|---|---| -| `src/shared/entra_token_exchange.py` | `CredentialProvider` ABC → `AzureMIProvider` + `GoogleOIDCProvider`. `TOKEN_SOURCE` env var selects provider. `get_upstream_assertion(**kwargs)` for future providers. | -| `src/shared/test_credential_providers.py` | 17 unit tests: provider selection, Google metadata success/fail-closed (unreachable, timeout, error, empty), two-hop exchange with Google assertion, FIC mismatch, CA block, missing env vars. | -| `src/spiffe-proxy/internal/rbac/policy.go` | `FederatedPolicies []CallerPolicy` + per-entry `TrustDomain`. Validates separately from domestic policies. Exact `spiffe_id` only (no prefix for foreign domains). | -| `src/spiffe-proxy/internal/rbac/engine.go` | `findCallerPolicy()` searches `FederatedPolicies` after `Policies`. Logged when federated match found. | -| `src/spiffe-proxy/internal/rbac/engine_test.go` | 14 Go tests: happy/deny path, domestic isolation, unknown foreign ID, YAML parsing, missing trust_domain, prefix rejection, trust-domain mismatch, jwt_only, CA kill-switch, env enrichment. | -| `src/spiffe-proxy/config/spiffe-rbac-policy.yaml` | `federated_policies` section with `google-budget-reader` placeholder (OIDs need replacing with real values from provisioning script output). | -| `portal/app/storage/external_agent.py` | `BlobExternalAgentStore` (Azure Blob, ETag concurrency) + `FileExternalAgentStore` (local/dev). | -| `portal/app/storage/base.py` | `ExternalAgentStore` ABC with `list_agents`, `put_agent`, `delete_agent`, `healthcheck`. | -| `portal/app/routers/api.py` | `GET/PUT/DELETE /api/external-agents/{name}` + `POST /api/refresh-agents` (admin-only, cloud-mode only). | -| `portal/app/schemas/api.py` | `ExternalAgentEntry` Pydantic model: name, invoke_url, display_name, transport, hosting_platform. | -| `portal/app/settings.py` | External agent merge logic: overlays admin-CP discovery with external-agent store by name. | -| `portal/app/container.py` | `external_agent_store` wired into DI container. | -| `portal/tests/test_external_agents.py` | 11 tests: PUT/GET/DELETE, overwrite, delete nonexistent, empty store, corrupt file, filtered entries, healthcheck, multiple agents, transport field, blob retry on 412. | -| `src/admin-control-plane/app.py` | `/admin/agents` reads `federated_policies` from sidecar, returns federated callers with `role: federated-caller` and `hosting_platform` inferred from trust domain. | -| `scripts/add-google-agent.sh` | 6-step provisioning: create Entra Agent Identity, Blueprint FIC (Google issuer, numeric SA ID), mTLS allow list update, portal external-agent registration. Prints `federated_policies` YAML stanza. | - -### Full Test Suite Results (as of 2026-04-03) - -``` - Passed Failed Skipped -Go unit tests (3 packages) 91 0 2 pkgs (private tunnelpb dep) -Python credential providers 17 0 0 -Python JWT validator 14 0 0 -Python agent apps 16 0 0 -Portal tests (5 files) 34 0 0 -Portal policy merge 5 1 0 (pre-existing, unrelated) -───────────────────────────────────────────────────────────── -TOTAL 177 1 2 Go pkgs -``` - -The 1 failure is a pre-existing test bug in `portal/test_policy_merge.py` (expects a wildcard deny rule; the engine correctly uses `default_action: deny` instead). Not related to cross-cloud work. - -### E2E QA Guidance - -After changing the token exchange provider path, run `./deploy.sh --skip-provision` and `python3 scripts/test_agents.py` against a disposable environment to confirm the refactored token path works with real Azure managed identity tokens. - -**Key thing to verify:** Agent logs should show `"Acquiring upstream assertion via AzureMIProvider..."` instead of the old `"Acquiring MI token for token exchange..."`. If you see the old message, deploy.sh didn't copy the updated `src/shared/entra_token_exchange.py`. - -### Phase 0, 2, 3 — All Complete - -| Phase | What | Status | -|---|---|---| -| **Phase 0** | VNet-integrate ACA | ✅ Deployed | -| **Phase 0** | Azure VPN Gateway + GCP Cloud VPN | ✅ Deployed | -| **Phase 0** | GCE VM provisioned | ✅ Deployed | -| **Phase 0** | TCP connectivity verified | ✅ Validated | -| **Phase 2** | Demo-agent on GCE with TOKEN_SOURCE=google_oidc | ✅ Deployed | -| **Phase 2** | SPIRE bundle federation (Azure ↔ GCP) | ✅ Active | -| **Phase 2** | spiffe-proxy on GCE (extracted from Docker) | ✅ Running | -| **Phase 3** | Validation matrix (6/6 scenarios) | ✅ All passed | - -### How to Deploy - -The cross-cloud Google agent is deployed via a single flag: - -```bash -azd env set GCP_PROJECT -./deploy.sh --new --google -``` - -This runs 13 Google-specific steps (G1-G13) after the Azure deploy completes. -See GOOGLE-FEDERATION-HOWTO.md for details and troubleshooting. - -### Phase 3 Validation Results (2026-04-04) - -| # | Scenario | Result | -|---|---|---| -| 1 | Portal shows google-budget-reader | ✅ | -| 2 | GET /budget/read → 200 | ✅ | -| 3 | POST /budget/submit → 403 (RBAC deny) | ✅ | -| 4 | Remove from mTLS → blocked | ✅ | -| 5 | Risk=HIGH → 403 (CA enforcement) | ✅ | -| 6 | Risk reset → 200 (recovery) | ✅ | - -### Cross-Platform Notes for Future Implementers - -Three platform learnings docs exist under `docs/platform-learnings/`: - -- **Google** — `gcp_iit` attestation, FIC numeric subject, TOFU model -- **AWS** — `aws_iid` attestation, Cognito OIDC intermediary (EC2 has no native OIDC), IMDSv2 hop limit -- **ServiceNow** — SaaS vs MID Server, no SPIFFE layer, Agent User secretless pattern, per-instance issuers - -The Google implementation was designed to not close off these platforms. Key extensibility points: - -- `CredentialProvider.get_upstream_assertion(**kwargs)` — accepts credentials for ServiceNow -- `federated_policies` schema — `spiffe_id` can be made optional with `jwt_only: true` for ServiceNow Model A -- Portal external-agent store — `transport` field distinguishes `spiffe` from `https_only` - -## Architecture Overview - -This section explains the end-to-end call flow for a Google Cloud agent calling the Azure budget-backend. Two independent identity planes run in parallel — both must succeed for the request to go through. - -### System Architecture Diagram - -The system spans two clouds connected by an IPsec VPN tunnel. The Google agent runs on a GCE VM with its own SPIRE server. The Azure budget-backend runs on Container Apps with a SPIFFE ingress proxy that enforces four security layers. - -```mermaid -graph TB - subgraph GCP["☁️ Google Cloud Platform"] - direction TB - META["🔑 GCE Metadata Server\n(Google OIDC Tokens)"] - subgraph GCEVM["GCE VM — google-budget-reader"] - direction LR - APP["🐍 Demo Agent\n(Python FastAPI)\nTOKEN_SOURCE=google_oidc"] - EPROXY["🔒 spiffe-proxy\n(egress mode)"] - GAGENT["🪪 SPIRE Agent\n(gcp_iit attestation)"] - end - GSPIRE["🏛️ GCP SPIRE Server\ntrust domain:\ngcp.aim.microsoft.com"] - end - - subgraph VPN["🔗 IPsec VPN Tunnel\n10.200.0.0/16 ↔ 10.128.0.0/20"] - TUNNEL["Azure VPN Gateway ↔ GCP Cloud VPN"] - end - - subgraph AZURE["☁️ Microsoft Azure"] - direction TB - ENTRA["🆔 Microsoft Entra ID\nAgent Identity Blueprint\n+ Conditional Access"] - subgraph ACA["Container Apps Environment (VNet-integrated)"] - subgraph BB["budget-backend"] - IPROXY["🔒 spiffe-proxy\n(ingress mode)\n━━━━━━━━━━━━━━━\nL1: mTLS allow list\nL2: RBAC path+method\nL3: JWT validation\nL4: CA risk+governance"] - BACKEND["🐍 Budget Backend\n(Python FastAPI)"] - end - end - ASPIRE["🏛️ Azure SPIRE Server VM\ntrust domain:\naim.microsoft.com"] - PORTAL["🖥️ Identity Research for Agent Management Using SPIFFE Portal\n(shows google-budget-reader)"] - end - - APP -->|"Hop 0: Get Google\nOIDC token"| META - APP -->|"Hop 1: Google JWT →\nBlueprint token T1"| ENTRA - APP -->|"Hop 2: T1 →\nAgent Identity T2\n(Budget.Read role)"| ENTRA - APP -->|"HTTP + T2 JWT"| EPROXY - GAGENT -->|"Get SVID"| GSPIRE - EPROXY -->|"gRPC + mTLS\n(Google SVID)"| TUNNEL - TUNNEL -->|"Private route\nto :8443"| IPROXY - IPROXY -->|"4 layers pass\n→ forward"| BACKEND - GSPIRE <-->|"Bundle Federation\n(5-min refresh)"| ASPIRE - PORTAL -.->|"Discovers via\n/admin/agents"| BB - - style GCP fill:#E8F5E9,stroke:#2E7D32,stroke-width:2px - style AZURE fill:#E3F2FD,stroke:#1565C0,stroke-width:2px - style VPN fill:#FFF3E0,stroke:#EF6C00,stroke-width:2px - style GCEVM fill:#C8E6C9,stroke:#388E3C - style BB fill:#BBDEFB,stroke:#1565C0 - style ACA fill:#E3F2FD,stroke:#1976D2 -``` - -### Request Flow (Sequence Diagram) - -This diagram shows the complete step-by-step flow when the Google agent makes a single request to `GET /budget/read`. It's divided into four phases: - -1. **Identity plane (green)** — The 3-hop secretless token exchange. The Google agent gets a native OIDC token from the GCE metadata server, exchanges it for an Entra Blueprint token, then exchanges that for an Agent Identity token with the `Budget.Read` role. - -2. **Transport plane (blue)** — The SPIFFE mTLS tunnel. The egress proxy on the GCE VM gets a Google-domain SVID certificate, opens an encrypted gRPC tunnel through the IPsec VPN to the Azure ingress proxy. - -3. **Enforcement (pink)** — The Azure ingress proxy runs four checks in sequence: is this SPIFFE ID in the mTLS allow list? Does RBAC allow this path and method? Is the JWT valid with the right roles? Is the agent risk level acceptable? - -4. **Response (green)** — If all four layers pass, the request reaches the budget-backend app and the 200 OK flows back. - -```mermaid -sequenceDiagram - autonumber - participant App as 🐍 Google Agent
(GCE VM) - participant Meta as 🔑 GCE Metadata
Server - participant Entra as 🆔 Microsoft
Entra ID - participant EProxy as 🔒 Egress Proxy
(GCE VM) - participant GSPIRE as 🏛️ GCP SPIRE
Server - participant VPN as 🔗 VPN Tunnel - participant ASPIRE as 🏛️ Azure SPIRE
Server - participant IProxy as 🔒 Ingress Proxy
(budget-backend) - participant Backend as 🐍 Budget
Backend App - - Note over GSPIRE, ASPIRE: ⬇️ ONE-TIME SETUP: SPIRE Bundle Federation - GSPIRE->>ASPIRE: Exchange trust bundle (every 5 min) - ASPIRE->>GSPIRE: Exchange trust bundle (every 5 min) - Note over GSPIRE, ASPIRE: Now both sides trust each other's certificates ✅ - - Note over App, Backend: ⬇️ EVERY REQUEST: Token Exchange + mTLS Call - - rect rgb(232, 245, 233) - Note right of App: IDENTITY PLANE
(Who are you?) - App->>Meta: Hop 0: Give me a Google OIDC token
(audience: api://AzureADTokenExchange) - Meta-->>App: Google-signed JWT
(sub = service account numeric ID) - - App->>Entra: Hop 1: Here's my Google JWT,
give me a Blueprint token - Entra-->>App: Blueprint token (T1)
"You're a registered caller" - - App->>Entra: Hop 2: Here's T1,
give me an Agent Identity token - Entra-->>App: Agent Identity token (T2)
with Budget.Read role ✅ - end - - rect rgb(227, 242, 253) - Note right of App: TRANSPORT PLANE
(Are you allowed to connect?) - App->>EProxy: HTTP request + T2 JWT attached - EProxy->>GSPIRE: Get my SPIFFE certificate (SVID) - GSPIRE-->>EProxy: SVID for gcp.aim.microsoft.com - - EProxy->>VPN: gRPC + mTLS tunnel
(encrypted with Google SVID) - VPN->>IProxy: Forward to budget-backend:8443
(private VNet route) - end - - rect rgb(252, 228, 236) - Note right of IProxy: ENFORCEMENT
(4-layer check) - IProxy->>IProxy: L1: Is this SPIFFE ID in mTLS allow list? - Note right of IProxy: ✅ google-budget-reader is allowed - IProxy->>IProxy: L2: RBAC — is GET /budget/read allowed? - Note right of IProxy: ✅ Rule says allow - IProxy->>IProxy: L3: Is the JWT valid with Budget.Read role? - Note right of IProxy: ✅ T2 has Budget.Read - IProxy->>IProxy: L4: Is agent risk level OK? State enabled? - Note right of IProxy: ✅ Risk=low, State=enabled - end - - rect rgb(232, 245, 233) - IProxy->>Backend: Forward request (all 4 layers passed) - Backend-->>IProxy: 200 OK — budget data - IProxy-->>VPN: Response - VPN-->>EProxy: Response - EProxy-->>App: 200 OK — budget data ✅ - end -``` - -### Dual Identity Plane Design - -The key architectural insight is that the Google agent must prove its identity through **two completely independent systems**: - -| Plane | What it proves | Technology | Failure mode | -|---|---|---|---| -| **Identity** (Entra) | "I am `google-budget-reader` with `Budget.Read` role" | Google OIDC → Entra FIC → Agent Identity token (3 hops, secretless) | Token acquisition failure → 401 | -| **Transport** (SPIFFE) | "I am allowed to connect to budget-backend over mTLS" | SPIFFE SVID from `gcp.aim.microsoft.com`, verified via bundle federation | mTLS handshake failure → connection refused | - -Neither plane can substitute for the other: - -- A valid Entra token without a valid SPIFFE certificate → connection refused at Layer 1 (mTLS) -- A valid SPIFFE certificate without a valid Entra token → rejected at Layer 3 (JWT validation) -- Both valid but wrong role → rejected at Layer 2 (RBAC) -- Both valid but agent risk is high → rejected at Layer 4 (CA governance) - -This is the same four-layer enforcement that Azure-hosted agents go through. The Google agent gets no special treatment — it must satisfy every layer, just like `budget-report` and `budget-approval`. - -## Why This Is The Right Problem - -The real job is not "do something with Google Cloud." The real job is to prove that Identity Research for Agent Management Using SPIFFE is not an Azure-only demo. - -The user-visible outcome is concrete: - -- a Google-hosted agent appears in the portal below `BudgetApproval` -- the portal can invoke it like any other caller -- it can call `GET /budget/read` -- it is denied on `/budget/submit` -- it uses Entra Agent Identity for token proof -- it uses SPIFFE federation for transport proof -- it stores no long-lived client secret or certificate - -If we do nothing, the cross-cloud story remains a slide. That is a real product gap, not a hypothetical one. - -## Success Criteria - -1. `google-budget-reader` appears in the existing portal flow without a bespoke UI path. -2. The portal can execute a live request through the Google caller's `/call-backend-raw` endpoint. -3. `google-budget-reader -> GET /budget/read` returns `200`. -4. `google-budget-reader -> POST /budget/submit` returns `403` from the RBAC layer. -5. The caller acquires its Entra token without any stored client secret or client certificate. -6. The Azure target sees a **Google trust-domain SPIFFE ID** at the transport layer. -7. Risk, `agent_state`, and tag enforcement remain fail-closed for the Google caller. - -## Not In Scope - -- A generic provider-neutral external agent registry -- Cloud Run support -- GKE support -- Bidirectional Azure <-> Google caller/target support -- A new portal tab or a second management plane -- Full Terraform automation for the Google side in this pass -- Reworking the existing Azure agent topology - -## Hard Constraints In The Current Repo - -These are not theory. They fall directly out of the current code and docs. - -### 1. `/admin/agents` is Azure-only for URLs today - -`src/admin-control-plane/app.py` currently derives every agent URL from `CONTAINER_APP_ENV_DNS_SUFFIX`. - -That works only for Azure Container Apps. A Google-hosted caller needs an explicit externally reachable invoke URL or the portal can render the agent but fail at live execution. - -### 2. The portal execute path needs a caller URL - -`portal/app/clients/agent_invoker.py` calls `caller_url + /call-backend-raw` and `caller_url + /call-agent`. - -That means discovery must return a real public HTTPS base URL for the Google caller. This is not optional. - -### 3. RBAC currently rejects foreign trust domains - -`src/spiffe-proxy/internal/rbac/policy.go` validates caller SPIFFE IDs against the policy's local `trust_domain`. - -That blocks a federated Google SPIFFE ID before evaluation starts. Cross-cloud SPIFFE support therefore requires a deliberate policy/schema change, not just a YAML update. - -### 4. ACA public FQDN routing breaks the current mTLS tunnel - -`docs/runbooks/hard-won-learnings.md` already records the core problem: using the Container App FQDN triggers TLS termination and breaks the sidecar's end-to-end mTLS assumptions. - -For the Google caller to reach Azure over SPIFFE, it must reach the Azure sidecar's raw `8443` path **without** Azure TLS termination in the middle. - -### 5. Security paths must still fail closed - -Missing Graph data, bad FIC claims, unreachable bundle endpoints, broken risk lookup, or invalid JWTs cannot degrade to "safe enough." The repo standard is deny unless identity is positively proven. - -## Chosen Architecture - -### 1. Runtime Choice: GCE VM, Not Cloud Run, For The First Pass - -Use a single GCE VM for the Google side. - -Why: - -- SPIRE's `gcp_iit` node attestor lines up with GCE instance identity tokens. -- We need local sidecar topology: app + `spiffe-proxy` + SPIRE agent. -- We need raw network control for the outbound mTLS tunnel. -- We need a stable public HTTPS endpoint for the portal's `caller_url`. -- This avoids turning the feature into "also decide the right GCP compute substrate." - -Hold-scope decision: **one GCE VM is enough** for the first proof. - -### 2. Identity Plane: Google OIDC -> Blueprint Token -> Agent Token - -The existing repo already uses the two-hop Agent Identity flow: - -```text -upstream credential -> Blueprint exchange token (T1) -> Agent Identity token (T2) -``` - -For Azure-hosted callers, the upstream credential is a Managed Identity token. - -For the Google caller, replace only the first hop input. - -```text -Google service account on GCE - -> metadata server issues Google-signed ID token - aud = api://AzureADTokenExchange - -Google ID token - -> Entra token endpoint - client_id = - scope = api://AzureADTokenExchange/.default - fmi_path = - client_assertion = - -> T1 (Blueprint exchange token) - -T1 - -> Entra token endpoint - client_id = - scope = api:///.default - client_assertion = - -> T2 (Entra Agent token used on the request) -``` - -### Blueprint Credential Shape - -Add a new Blueprint-level FIC for the Google caller: - -- `issuer = https://accounts.google.com` -- `subject = ` -- `audiences = ["api://AzureADTokenExchange"]` - -Important: the FIC belongs on the **Blueprint**, not the per-agent identity. That matches the current repo's Agent Identity model. - -### 3. Transport Plane: Google SPIFFE Trust Domain Federated Into Azure - -The Entra token proves who the agent is in the Microsoft auth plane. - -It does **not** prove transport identity. That is a separate plane. - -Use a Google trust domain for the caller, while keeping the same Entra identity path in the SPIFFE ID structure: - -```text -spiffe://gcp.aim.microsoft.com/ests/bp//aid/ -``` - -That gives us: - -- `gcp.aim.microsoft.com` = transport issuer domain -- `/ests/bp/.../aid/...` = same Entra identity bridge already used in Azure - -The transport path is: - -```text -Portal - -> https://google-budget-reader./call-backend-raw - -> local spiffe-proxy :8080 on GCE - -> gRPC + mTLS tunnel with Google SPIFFE SVID - -> Azure-side ingress proxy on budget-backend :8443 - -> Layer 1 mTLS allow list - -> Layer 4 CA checks - -> Layer 2 RBAC - -> Layer 3 JWT validation - -> budget-backend app :8000 -``` - -### 4. Network Requirement: Raw 8443 Reachability Comes First - -This is the highest-risk part of the plan. - -The Google side must reach the Azure ingress proxy in a way that **preserves** the sidecar's end-to-end TLS handshake. - -**Engineering review decision:** The current ACA environment is **not** VNet-integrated. Even with a VPN to the Azure VNet, ACA internal DNS (`budget-backend`) is only resolvable inside the ACA environment. VNet integration of ACA is a prerequisite, not optional. - -The approach is: - -1. **VNet-integrate the Azure Container Apps environment** (modify `infra/modules/` Bicep to place ACA in a VNet subnet). Verify existing Azure agents still work after integration. -2. **Provision VPN connectivity** between the Azure VNet and GCP VPC (Azure VPN Gateway + GCP Cloud VPN). -3. Resolve `budget-backend` from the Google VM via the VNet-internal path. -4. Keep `TARGET_HOST=budget-backend` on the Google egress proxy so the tunnel does not route through ACA public TLS termination. - -This should be treated as a **Phase 0 spike** before any token plumbing or portal work. Phase 0 is larger than originally scoped because ACA VNet integration is real infrastructure work, not just connectivity testing. - -If Phase 0 fails, the team has learned something important: full cross-cloud SPIFFE to ACA is blocked by network topology, not by missing YAML. - -### 5. Discovery Plane: Separate Authorization From Discovery Metadata - -Do **not** add static env vars for external agent URLs. - -The repo already learned why that fails. - -**Engineering review decision:** Do **not** put `invoke_url` in the RBAC policy YAML. The Go sidecar ignores it, so it is management metadata that does not belong in the authorization config. Instead, use two separate data sources: - -1. **RBAC policy YAML** (`federated_policies` section) defines authorization: SPIFFE ID, rules, CA settings. The Go sidecar uses this. -2. **Portal external-agent storage** (blob-backed, API-managed) defines discovery metadata: `invoke_url`, display name. The portal uses this. - -The portal merges both sources by agent name at startup. Admin-CP returns agents from RBAC policy via `/admin/agents` (including `google-budget-reader`, no URL). The portal overlays `invoke_url` from its own storage. - -The RBAC policy entry for the Google caller lives in the new `federated_policies` section: - -```yaml -federated_policies: - - spiffe_id: spiffe://gcp.aim.microsoft.com/ests/bp//aid/ - trust_domain: gcp.aim.microsoft.com - name: google-budget-reader - entra_agent_id: - description: Google-hosted read-only caller - ca: - agent_state: enabled - agent_tag: finance - rules: - - path: /budget/read - methods: ["GET"] - action: allow - require_jwt: true - required_roles: ["Budget.Read"] - - path: /budget/submit - methods: ["*"] - action: deny -``` - -The portal's external-agent storage entry: - -```json -{ - "name": "google-budget-reader", - "invoke_url": "https://google-budget-reader.", - "display_name": "Google Budget Reader" -} -``` - -Design intent: - -- RBAC YAML stays pure authorization config. No management metadata leaks into the sidecar. -- Portal manages external agent config the same way it manages policy configs (blob storage, API-driven). -- Azure-hosted callers: `/admin/agents` still derives URL from ACA DNS. No external-agent entry needed. -- External callers: portal reads `invoke_url` from external-agent storage, merges with admin-CP discovery. -- A new `/api/refresh-agents` admin-only endpoint enables re-discovery without portal restart. - -### 6. Trust-Domain Safety: Separate Federated Policies Section - -Do **not** silently relax policy validation for all callers. - -**Engineering review decision:** Instead of a per-policy `allow_foreign_trust_domain` boolean flag, use a separate `federated_policies` section in the RBAC YAML. This keeps the existing `Validate()` code untouched for domestic policies. - -Schema extension: - -- Add optional `TrustDomain` field to `CallerPolicy` struct (used only in `federated_policies` section). -- Add `FederatedPolicies []CallerPolicy` to the `Policy` struct. -- Validate `federated_policies` entries separately: require per-entry `trust_domain`, validate SPIFFE syntax, skip the global trust-domain prefix check. -- Extend `findCallerPolicy()` to search `FederatedPolicies` after `Policies`. -- Existing `Policies` validation is completely unchanged (regression-safe). - -For this feature, use an **exact** `spiffe_id`, not a foreign `spiffe_id_prefix`. - -Reason: the goal is one known Google caller, not authorization for an entire foreign trust domain subtree. - -## Minimal Change Set That Still Achieves The Goal - -This plan is not tiny, but it is still the smallest complete version of the feature. - -### New Or Changed Repo Areas - -1. `src/shared/entra_token_exchange.py` - - Refactor to a strategy pattern: `CredentialProvider` base class with `get_upstream_assertion()`. - - `AzureMIProvider`: wraps existing `ManagedIdentityCredential.get_token()`. - - `GoogleOIDCProvider`: calls GCE metadata server. **Must fail closed** if metadata server is unreachable or returns an error. - - Selection via `TOKEN_SOURCE` env var: `azure_mi` (default) | `google_oidc`. - - Two-hop exchange code stays shared: provider gives upstream assertion, same Hop 1, same Hop 2. - - **Cross-platform note:** `get_upstream_assertion()` should accept `**kwargs` for optional credentials. AWS (via Cognito) and ServiceNow may require stored credentials that Google and Azure do not. Don't hardcode the interface as secretless-only. See `docs/platform-learnings/ServiceNow-Federation.md`. - -2. `src/spiffe-proxy/internal/rbac/policy.go` - - Add optional `TrustDomain` field to `CallerPolicy` struct. - - Add `FederatedPolicies []CallerPolicy` to the `Policy` struct. - - Validate `federated_policies` entries separately (per-entry trust domain check, not global prefix check). - - Existing `Policies` validation unchanged. - - **Cross-platform note:** Consider making `spiffe_id` optional (not required) in `federated_policies` entries. ServiceNow platform agents have no SPIFFE transport layer and would need to match by JWT claims or agent name instead. Adding a `jwt_only: bool` field now avoids a schema migration later. See `docs/platform-learnings/ServiceNow-Federation.md`. - -3. `src/spiffe-proxy/internal/rbac/engine.go` - - Extend `findCallerPolicy()` to search `FederatedPolicies` after `Policies`. - -4. `src/spiffe-proxy/config/spiffe-rbac-policy.yaml` - - Add `federated_policies` section with the Google caller entry. - -5. `src/admin-control-plane/app.py` - - Read `federated_policies` from the `/mgmt/policy` response. - - Return federated agents in `/admin/agents` with SPIFFE ID, name, role, but no URL (portal overlays from its own storage). - - Continue ACA DNS derivation as the fallback for Azure agents. - -6. `src/demo-agent/` (reuse, not a new `src/google-budget-reader/`) - - Add `TOKEN_SOURCE` env var support. `google_oidc` selects `GoogleOIDCProvider`. - - Same caller contract, same proxy config, same CA evaluator. - -7. `portal/app/storage/` (new, follows existing blob pattern) - - External-agent config store: blob-backed storage for `invoke_url` and display metadata. - - Portal API: `PUT /api/external-agents` (admin-only), `GET /api/external-agents`. - - Merge logic in `settings.py`: join admin-CP agents + external-agent storage by name. - - New `/api/refresh-agents` admin-only endpoint for re-discovery without restart. - - **Cross-platform note:** Include a `transport` field in the external-agent schema (`spiffe` | `https_only`). ServiceNow platform agents use HTTPS without mTLS, so the portal needs to know whether to expect Layer 1 enforcement results. Default to `spiffe` for Google/AWS. - -8. `scripts/add-google-agent.sh` (new) - - Create or resolve the Google caller Agent Identity under the existing Blueprint. - - Create the Google FIC on the Blueprint using the Google service account **numeric unique ID** (NOT email — see Implementation Gotchas). - - Add Google SPIFFE ID to budget-backend mTLS allow list via `PUT /mtls-policy`. - - Register `invoke_url` in portal external-agent storage via portal API. - - Emit the exact foreign SPIFFE ID and the `federated_policies` stanza. - -9. `infra/modules/` (modified) - - VNet-integrate the ACA environment (place in a VNet subnet). - - Add VPN Gateway for cross-cloud connectivity. - -### What We Explicitly Avoid - -- No new portal frontend surface for "external agents" -- No `invoke_url` or management metadata in the RBAC policy YAML -- No `allow_foreign_trust_domain` boolean flag (use `federated_policies` section instead) -- No new `src/google-budget-reader/` directory (reuse `src/demo-agent/` with `TOKEN_SOURCE` param) -- No generic abstraction for every future cloud provider in this pass -- No new management proxy beyond the current portal -> admin-control-plane -> sidecar contract - -## Implementation Sequence - -### Phase 0: Infrastructure Prerequisites (blocks all other phases) - -**Status:** Not started. This is the next thing to do. - -Goal: VNet-integrate ACA, provision GCE, establish private connectivity, prove raw 8443 reachability. - -Steps: - -1. Modify `infra/modules/` Bicep to place ACA environment in a VNet subnet. Verify existing Azure agents still work. -2. Provision GCE VM: caller app + spiffe-proxy + SPIRE agent + SPIRE server (single VM for PoC). Public IP + firewall rules for portal `invoke_url` access (HTTPS 443). -3. Establish VPN tunnel: Azure VPN Gateway + GCP Cloud VPN. Private connectivity between Azure VNet and GCP VPC. -4. Validate: TCP connectivity from GCE VM to budget-backend:8443 via VNet private IP. No ACA public FQDN in the path. - -Exit criteria: - -- ACA VNet-integrated, existing agents functional -- TCP connectivity exists from GCE VM to budget-backend:8443 via private path -- No public ACA FQDN is in the tunnel path - -### Phase 1: Identity + Schema (4 parallel workstreams after Phase 0) - -> **✅ COMPLETE.** All 4 workstreams implemented and tested. See Implementation Status above for details. - -**Workstream A:** Entra Provisioning — ✅ Done (`scripts/add-google-agent.sh`) - -Goal: create the Google caller Agent Identity, FIC, and register with mTLS + portal. - -- `scripts/add-google-agent.sh`: Create Agent Identity under existing Blueprint, create Blueprint-level FIC (Google issuer), add Google SPIFFE ID to mTLS allow list via `PUT /mtls-policy`, register `invoke_url` in portal external-agent storage. - -Exit criteria: Google metadata token can complete Hop 1. Hop 2 returns an Entra Agent token. mTLS allow list contains Google SPIFFE ID. - -**Workstream B:** Credential Provider Strategy Pattern (parallel with A) — ✅ Done - -Goal: refactor token exchange to support Google OIDC as an upstream assertion source. - -- Refactor `src/shared/entra_token_exchange.py` to strategy pattern. -- `GoogleOIDCProvider` calls GCE metadata server, fails closed if unreachable. -- Selection via `TOKEN_SOURCE` env var. - -Exit criteria: Unit tests pass for both `AzureMIProvider` and `GoogleOIDCProvider`. ✅ 17 tests passing. - -**Workstream C:** RBAC Schema Extension (parallel with A and B) — ✅ Done - -Goal: add `federated_policies` to policy schema. - -- Extend `CallerPolicy` with per-entry `TrustDomain` field. -- Add `FederatedPolicies` to `Policy` struct. -- Validate separately, extend `findCallerPolicy()` to search both slices. -- Add `federated_policies` entry in `spiffe-rbac-policy.yaml`. - -Exit criteria: Go unit tests pass for parsing, validation, and matching of federated policies. ✅ 14 tests passing. - -**Workstream D:** Portal External-Agent Storage (parallel with A, B, C) — ✅ Done - -Goal: add blob-backed external-agent config storage and agent refresh. - -- New storage model in `portal/app/storage/`. -- Portal API: `PUT/GET /api/external-agents` (admin-only). -- Merge logic in `settings.py`: join admin-CP agents + external-agent storage by name. -- New `/api/refresh-agents` admin-only endpoint. - -Exit criteria: Python unit tests pass for storage CRUD, merge logic, and refresh endpoint. ✅ 11 tests passing. - -### Phase 2: Google Runtime + Federation (depends on Phase 0 + Workstreams B, C) - -**Status:** Not started. Blocked by Phase 0. - -Goal: run one Google caller stack on GCE, establish SPIFFE bundle federation. - -Steps: - -1. Deploy `src/demo-agent/` on GCE with `TOKEN_SOURCE=google_oidc`. Configure spiffe-proxy egress to point at budget-backend private IP:8443. Set up HTTPS reverse proxy (nginx/caddy) for public `invoke_url` endpoint on :443. -2. Verify standalone token exchange from GCE: Google metadata -> Hop 1 -> Hop 2. Verify fail-closed on FIC mismatch. -3. Configure SPIRE bundle federation: both servers exchange bundle endpoint URLs, 5-minute explicit refresh interval, mutual trust. -4. Update admin-control-plane: read `federated_policies` from `/mgmt/policy`, return federated agents in `/admin/agents`. - -Exit criteria: - -- Google caller receives a Google trust-domain SVID -- Azure side accepts that foreign SPIFFE ID when explicitly allow-listed -- `/admin/agents` returns `google-budget-reader` with SPIFFE ID and name - -### Phase 3: Validation Matrix - -**Status:** Not started. Blocked by Phase 2. - -Goal: prove the full happy path and the main deny paths. - -All 8 integration tests from the Validation Matrix section below must pass. - -## Failure Modes And Expected Behavior - -| Failure | Expected Result | Why | -|---|---|---| -| Google metadata token unavailable | caller returns `401 token_acquisition_failed` | no secret fallback | -| Google FIC subject or issuer mismatch | Hop 1 fails, request denied | identity not proven | -| Blueprint -> Agent exchange fails | request denied | agent token not proven | -| SPIFFE bundle endpoint unreachable | mTLS handshake fails | transport identity not proven | -| Google path routes through ACA public TLS termination | mTLS tunnel fails | sidecar cert exchange broken | -| Policy omits `invoke_url` for external agent | portal cannot execute caller | discovery incomplete | -| Portal external-agent store unavailable | fall back to cached, degrade gracefully | fail closed on writes, cached on reads | -| Graph/risk lookup unavailable | deny at CA layer | fail closed | -| Google caller removed from mTLS allow list | connection rejected before RBAC | Layer 1 still authoritative | - -## Validation Matrix - -1. Portal shows `google-budget-reader` as a new caller. -2. `google-budget-reader -> GET /budget/read` returns `200`. -3. `google-budget-reader -> POST /budget/submit` returns `403`. -4. Removing the Google SPIFFE ID from the mTLS allow list blocks the connection. -5. Setting the Google caller risk to `high` returns `403`. -6. Setting `ca.agent_state: disabled` returns `403`. -7. Breaking the Google FIC subject causes token acquisition failure. -8. Breaking bundle federation causes transport failure. - -## Lessons Learned Applied Up Front - -1. **Do not hardcode dynamic URLs in env vars.** External invoke URLs must flow through live discovery data. -2. **Do not treat token federation and SPIFFE federation as the same thing.** They solve different trust problems. -3. **Do not assume ACA public FQDNs preserve the sidecar tunnel.** The repo already knows they do not. -4. **Do not use `azd deploy ` on sidecar-bearing Azure services.** Re-attestation rules still apply. -5. **Do not interpret missing Graph or governance data as safe.** External callers do not get a lower bar. - -## Implementation Gotchas - -These are concrete debugging-savers for whoever implements this plan. - -### FIC Subject Must Be Numeric Unique ID, Not Email - -When creating the Federated Identity Credential on the Blueprint for the Google caller, the `subject` field **must** be the GCP service account's **numeric unique ID**, not the email address. GCP identity tokens use `sub: "100330114984..."` (numeric). Using the email (e.g., `gcp-agent@project.iam.gserviceaccount.com`) produces `AADSTS70021: No matching federated identity record found`. - -Resolve the correct value with: -```bash -gcloud iam service-accounts describe $GCP_SA_EMAIL --format 'value(uniqueId)' -``` - -### MSAL Python Does Not Support FIC Token Exchange - -MSAL Python lacks the `WithClientAssertion` callback needed for Federated Identity Credential flows. The `GoogleOIDCProvider` must use raw HTTP requests to the Entra token endpoint, not MSAL. This is compatible with our strategy pattern design. The existing `entra_token_exchange.py` already uses raw HTTP for the two-hop exchange, so no new pattern is introduced. - -### Use `az vm run-command create`, Not `az vm run-command invoke` - -The legacy `az vm run-command invoke` has no server-side timeout and is single-slot. A stuck command permanently blocks the VM. Always use `az vm run-command create --timeout-in-seconds ` when running commands on the SPIRE Server VM. - -See hard-won-learnings.md #20 and `scripts/lib/azure-helpers.sh` for the safe `azure_vm_run()` wrapper. - -### No `proxy-only` CONTAINER_MODE Exists - -The spiffe-proxy entrypoint currently supports only `server` and `agent-proxy` modes. For the GCP SPIRE federation plan (Option B), the GCP-side proxy needs to connect to an **external** SPIRE Agent socket rather than starting its own. This will require either: -- Adding a `proxy-only` mode to `entrypoint.sh` that skips SPIRE Agent startup and uses `WORKLOAD_API_ADDR` to connect to an existing socket, OR -- Running `agent-proxy` mode but pointing it at the GCP SPIRE Server instead of the Azure one (viable if using join tokens on the GCP side, but we chose `gcp_iit` attestation via a separate SPIRE Agent). - -This is a code gap that must be addressed in Phase 2. - -### Container App Updates Can Nuke Other Containers - -Never use `az containerapp update --set-env-vars` to change a single env var. Azure may reset other containers in the revision to placeholder images. Always: -1. Export full Container App YAML: `az containerapp show -n -g -o yaml > /tmp/app.yaml` -2. Edit the YAML (update env vars, remove `revisionSuffix` or set unique value) -3. Reimport: `az containerapp update -n -g --yaml /tmp/app.yaml` - -See hard-won-learnings.md for the full context. - -## Open Questions - -1. ~~What is the cleanest private network path from GCE to the Azure sidecar's raw `8443` listener?~~ **Resolved:** VNet-integrate ACA + VPN Gateway to GCP. -2. Is one GCE VM enough operationally for the Google SPIRE server in the PoC, or does the team want it split before implementation? -3. Should the portal eventually show a `hosting_platform` badge, or is the `google-*` naming convention enough for this pass? - -## Approval Requested - -Approve this plan if these decisions stand: - -1. **Use GCE for the first Google caller.** -2. **Use a separate `federated_policies` section in RBAC YAML for foreign trust domains** (not a per-policy `allow_foreign_trust_domain` flag). -3. **Use portal external-agent storage (blob, API-managed) for `invoke_url`** (not in RBAC YAML). -4. **Reuse `src/demo-agent/` with `TOKEN_SOURCE` param** (not a new agent directory). -5. **Use strategy pattern for credential providers** (`CredentialProvider` base with `AzureMIProvider` + `GoogleOIDCProvider`). -6. **VNet-integrate ACA + VPN to GCP** as the Phase 0 prerequisite for raw 8443 reachability. -7. **Treat Phase 0 as larger than originally scoped** (ACA VNet integration is real infra work). - -If approved, the next step is implementation planning against this exact scope, not reopening the feature definition. - -## Engineering Review Decisions - -The following decisions were made during the engineering review (April 2, 2026): - -| # | Original Plan | Review Decision | Rationale | -|---|---|---|---| -| 1 | "Add private network connectivity" (underspecified) | VNet-integrate ACA + VPN to GCP | ACA not VNet-integrated today. Internal DNS unreachable without it. | -| 2 | `allow_foreign_trust_domain` boolean per CallerPolicy | Separate `federated_policies` YAML section | Cleaner separation. Existing Validate() untouched. | -| 3 | `invoke_url` in RBAC policy YAML | Portal external-agent storage (blob, API-managed) | Keeps RBAC YAML as pure auth config. Sidecar ignores management metadata. | -| 4 | New `src/google-budget-reader/` OR reuse demo-agent | Reuse demo-agent with `TOKEN_SOURCE` env var | DRY. Same caller contract, swap upstream credential only. | -| 5 | "Make the first hop pluggable" (underspecified) | Strategy pattern: `CredentialProvider` base class | Extensible for future providers. `AzureMIProvider` + `GoogleOIDCProvider`. | -| 6 | mTLS allow list population not specified | Script-driven via `add-google-agent.sh` calling `PUT /mtls-policy` | Consistent with how deploy.sh handles Azure agents. | -| 7 | Portal caches at startup (restart required) | Accept restart + add `/api/refresh-agents` endpoint | Pragmatic for PoC, operational improvement for ongoing use. | -| 8 | SPIRE bundle refresh interval not specified | 5-minute explicit config (not relying on default) | Visible and documented. | - -## Test Plan - -### Unit Tests (18 paths) - -**Go (add to existing test files in `src/spiffe-proxy/internal/rbac/`):** - -1. `policy_test.go`: `federated_policies` YAML parses correctly -2. `policy_test.go`: `Validate` accepts valid foreign trust domain in federated section -3. `policy_test.go`: `Validate` rejects missing `trust_domain` on federated entry -4. `policy_test.go`: `Validate` still rejects foreign domain in regular `policies` (regression) -5. `policy_test.go`: `FederatedPolicies` field is optional (existing YAML still works) -6. `engine_test.go`: `findCallerPolicy` matches in `federated_policies` -7. `engine_test.go`: `findCallerPolicy` prefers `policies` over `federated_policies` on overlap -8. `engine_test.go`: `findCallerPolicy` returns nil when no match in either slice -9. `engine_test.go`: Full `Evaluate` with federated caller, both allow and deny paths - -**Python (new test files):** - -10. `test_credential_providers.py`: `GoogleOIDCProvider` success (mocked GCE metadata) -11. `test_credential_providers.py`: `GoogleOIDCProvider` metadata server unreachable -> fail closed -12. `test_credential_providers.py`: `GoogleOIDCProvider` metadata error response -> fail closed -13. `test_credential_providers.py`: `AzureMIProvider` refactored path still works -14. `test_entra_token_exchange.py`: Two-hop exchange with Google assertion (mocked) -15. `test_entra_token_exchange.py`: FIC mismatch -> clear error propagation -16. `test_external_agents.py`: Portal external-agent storage PUT/GET -17. `test_external_agents.py`: Merge logic (admin-CP agents + external storage by name) -18. `test_external_agents.py`: Refresh endpoint re-discovers and merges - -### Integration / E2E Tests (8 scenarios from Validation Matrix) - -1. Portal shows `google-budget-reader` as a caller -2. `google-budget-reader -> GET /budget/read` -> `200` -3. `google-budget-reader -> POST /budget/submit` -> `403` -4. Remove Google SPIFFE ID from mTLS allow list -> connection rejected -5. Set Google caller risk to `high` -> `403` -6. Set `ca.agent_state: disabled` -> `403` -7. Break Google FIC subject -> token acquisition failure -8. Break bundle federation -> transport failure - -## Worktree Parallelization Strategy - -| Step | Modules Touched | Depends On | -|---|---|---| -| Phase 0: Infra | `infra/`, `scripts/` | — | -| Workstream B: Token exchange | `src/shared/` | — | -| Workstream C: RBAC schema | `src/spiffe-proxy/` | — | -| Workstream D: Portal storage | `portal/` | — | -| Workstream A: Entra provisioning | `scripts/` | Phase 0 | -| Phase 2 steps 1-2: GCE runtime | `src/demo-agent/`, GCE | Phase 0, Workstream B | -| Phase 2 step 3: SPIFFE federation | SPIRE config | Phase 0, Phase 2 step 1 | -| Phase 2 step 4: Admin-CP update | `src/admin-control-plane/` | Workstream C | -| Phase 3: Validation | `tests/` | All above | - -**Parallel lanes:** - -- **Lane A:** Phase 0 infrastructure (ACA VNet + GCE + VPN) -- **Lane B:** Workstream B credential provider strategy pattern (parallel with A) -- **Lane C:** Workstream C RBAC `federated_policies` schema (parallel with A, B) -- **Lane D:** Workstream D portal external-agent storage (parallel with A, B, C) -- **Sequential after A completes:** Workstream A (Entra provisioning), GCE runtime, SPIFFE federation -- **Sequential after C completes:** Admin-CP update -- **Final:** Phase 3 validation after all lanes merge - -4 parallel lanes, 3 sequential phases. diff --git a/docs/decisions/008-opa-complementary-not-replacement.md b/docs/decisions/008-opa-complementary-not-replacement.md index c59f994..e0b1152 100644 --- a/docs/decisions/008-opa-complementary-not-replacement.md +++ b/docs/decisions/008-opa-complementary-not-replacement.md @@ -34,5 +34,5 @@ A Kubernetes-native review raised OPA/Gatekeeper as a policy enforcement option. ## Related - ADR-001 (Sidecar, Not Gateway) — the runtime enforcement model -- Dunloe integration (future work) — the strategic PDP direction +- Dunloe remains outside the current implementation scope - `What_is_Microsoft_Entra_Authorization_Dunloe.pdf` — Dunloe product documentation diff --git a/docs/decisions/010-conditional-access-admin-governance.md b/docs/decisions/010-conditional-access-admin-governance.md index 194213c..c7b92cd 100644 --- a/docs/decisions/010-conditional-access-admin-governance.md +++ b/docs/decisions/010-conditional-access-admin-governance.md @@ -1,6 +1,6 @@ # ADR-010: Conditional Access as the Admin Governance Layer -**Status:** Proposed +**Status:** Accepted **Date:** March 2026 **Deciders:** Project contributors @@ -72,13 +72,15 @@ Agent identity as CA subject is **already deployed to 1,000+ customers.** This i - The [CA optimization agent](https://learn.microsoft.com/en-us/entra/security-copilot/conditional-access-agent-optimization) already scans for agent coverage gaps — 73% of customers using it have improved their Zero Trust posture - The [What If tool](https://learn.microsoft.com/en-us/entra/identity/conditional-access/what-if-tool) already supports agent identity simulation -## Implementation Path +## Implementation Status -**Phase 1 (in production):** Token-time CA evaluation on agent tokens. Engineering cost: minimal — ensure agent platform identities register as Entra Agent ID constructs. +The repository provisions token-time Conditional Access for Agent Identities +and demonstrates post-token governance in the sidecar and direct A2A +applications. Generic evaluation of CA application and service-principal +filters is not complete. -**Phase 2 (Q1 FY27):** Data-plane CA enforcement in sidecar. CA evaluation client in ingress pipeline, claims inspection (`acrs`/`capolids`), real-time CA endpoint calls, claims challenge flow. Engineering cost: moderate. - -**Phase 3 (Q2 FY27):** Unified policy visibility, What If integration, bidirectional policy awareness. Sign-in logs show both token-time and data-plane CA results. +The remaining implementation and documentation work is tracked in +[#31](https://github.com/microsoft/identity-spiffe/issues/31). See `docs/architecture/admin-governance-layer.md` for technical details — diagrams, YAML schema, enforcement matrix, integration design. diff --git a/docs/platform-learnings/Conditional-Access-Learnings.md b/docs/platform-learnings/Conditional-Access-Learnings.md index 0b6a6f2..8f7c201 100644 --- a/docs/platform-learnings/Conditional-Access-Learnings.md +++ b/docs/platform-learnings/Conditional-Access-Learnings.md @@ -2,8 +2,15 @@ > **Purpose:** Comprehensive reference for Claude Code instances working on CA enforcement in the Identity Research for Agent Management Using SPIFFE PoC. Load this file before working on admin governance, Layer 4 enforcement, agent risk, or custom security attributes. > -> **Last updated:** 2026-03-20 +> **Last updated:** 2026-09-01 > **Sources:** Microsoft Learn docs, Entra CA team briefings, ADR-010, admin-governance-layer.md +> +> **Historical scope:** Sections that say "Public Preview" describe the March +> 2026 platform state in which these learnings were captured. Agent Identity +> GA changes are documented in +> `docs/platform-learnings/agent-id-blueprints-and-users.md`. Reconciliation +> of the CA implementation and current platform terminology is tracked in +> [#31](https://github.com/microsoft/identity-spiffe/issues/31). --- @@ -251,7 +258,9 @@ For the PoC, we simulate agent risk via an external "Security Portal" mock porta - RBAC engine checks risk before evaluating rules - If risk matches `blocked_risk_levels` in CA policy → 403 -**Future:** Agent risk will be a CA claim in the JWT token, evaluated at both STS and data plane. +The target model is to carry agent risk into both STS and data-plane +evaluation. Completion and validation are tracked in +[#31](https://github.com/microsoft/identity-spiffe/issues/31). ### CA Authentication Flow Coverage for Agents diff --git a/docs/platform-learnings/GitHub-Actions-Federation.md b/docs/platform-learnings/GitHub-Actions-Federation.md index 4ad5ad0..0400e65 100644 --- a/docs/platform-learnings/GitHub-Actions-Federation.md +++ b/docs/platform-learnings/GitHub-Actions-Federation.md @@ -4,7 +4,7 @@ > > **Last updated:** 2026-04-19 > **Sources:** GitHub OIDC docs, Entra FIC docs, Azure Flexible FIC preview docs -> **Related:** `docs/architecture/next-github-actions-agent-federation.md` +> **Related work:** [#34](https://github.com/microsoft/identity-spiffe/issues/34) --- diff --git a/docs/platform-learnings/Google-Cloud-Federation.md b/docs/platform-learnings/Google-Cloud-Federation.md index b92e3da..00c820f 100644 --- a/docs/platform-learnings/Google-Cloud-Federation.md +++ b/docs/platform-learnings/Google-Cloud-Federation.md @@ -4,7 +4,7 @@ > > **Last updated:** 2026-04-02 > **Sources:** SPIRE plugin docs, Entra WIF docs, GCP OIDC docs, implementation PoC analysis -> **Related:** `docs/architecture/next-google-cloud-agent-federation.md` +> **Related work:** [#35](https://github.com/microsoft/identity-spiffe/issues/35) --- @@ -359,5 +359,5 @@ These are GCP-only considerations that don't apply to AWS or ServiceNow: - [Entra Workload Identity Federation](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation) - [GCE Instance Identity Tokens](https://cloud.google.com/compute/docs/instances/verifying-instance-identity) - [Entra + GCP federation walkthrough](https://blog.identitydigest.com/azuread-federate-gcp/) -- `docs/architecture/next-google-cloud-agent-federation.md` — implementation plan +- [#35](https://github.com/microsoft/identity-spiffe/issues/35) — productization and validation backlog - `docs/runbooks/hard-won-learnings.md` — #30 (FIC subject), #31 (MSAL), #32 (container modes) diff --git a/docs/runbooks/hard-won-learnings.md b/docs/runbooks/hard-won-learnings.md index f99ffd2..ea17017 100644 --- a/docs/runbooks/hard-won-learnings.md +++ b/docs/runbooks/hard-won-learnings.md @@ -181,7 +181,8 @@ On 2026-03-27, we needed to reconfigure the SPIRE server Docker container to use 2. Mount `/opt/spire/data/server` as a Docker volume BEFORE first deploy, so the data survives `docker rm`, OR 3. After `docker rm` + `docker run`, ALWAYS re-run deploy.sh Steps 5-6 to re-attest agents -**TODO:** Mount the SPIRE data directory as a Docker volume in the cloud-init script (`spire-server-vm.bicep`). This is the proper fix — makes the CA persistent across container restarts. +Durable SPIRE server storage is tracked in +[#28](https://github.com/microsoft/identity-spiffe/issues/28). ## 26. `azd deploy ` Kills SPIFFE Sidecars — Use deploy.sh or reattest.sh @@ -255,7 +256,10 @@ This is not a bug — MSAL Python is designed for interactive and confidential c The spiffe-proxy `entrypoint.sh` currently only supports `CONTAINER_MODE=server` (SPIRE Server) and `CONTAINER_MODE=agent-proxy` (starts embedded SPIRE Agent + proxy). There is no `proxy-only` mode for connecting to an external SPIRE Agent socket. -For SPIFFE federation scenarios where the SPIRE Agent runs as a separate process (e.g., GCP `gcp_iit` attestation), a new container mode or socket override is needed. See `docs/architecture/next-google-cloud-agent-federation.md` for details. +For SPIFFE federation scenarios where the SPIRE Agent runs as a separate +process (for example, GCP `gcp_iit` attestation), a new container mode or +socket override is needed. Productization of this path is tracked in +[#35](https://github.com/microsoft/identity-spiffe/issues/35). ## 34. Rapid-Fire Run-Commands Wedge the Guest Agent on Fresh VMs diff --git a/infra/modules/spire-server-vm.bicep b/infra/modules/spire-server-vm.bicep index d4025ed..f1b70c6 100644 --- a/infra/modules/spire-server-vm.bicep +++ b/infra/modules/spire-server-vm.bicep @@ -77,7 +77,7 @@ resource nsg 'Microsoft.Network/networkSecurityGroups@2023-11-01' = if (subnetId protocol: 'Tcp' sourcePortRange: '*' destinationPortRange: '8081' - // TODO: Restrict to VNet once Container Apps are VNet-integrated. + // VNet restriction and secret-storage hardening are tracked in #32. // Currently Container Apps use Azure-managed outbound IPs (not in // this VNet), so 'VirtualNetwork' tag would block agent attestation. // For production: VNet-integrate Container Apps + use private endpoint. diff --git a/mkdocs.yml b/mkdocs.yml index e9526d5..9571835 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,9 +40,6 @@ nav: - RBAC Authorization: architecture/layers/rbac-authorization.md - OAuth2 and JWT: architecture/layers/oauth-jwt.md - Conditional Access Governance: architecture/layers/conditional-access.md - - Reference Designs: - - Google Cloud Agent Federation: architecture/next-google-cloud-agent-federation.md - - GitHub Actions Agent Federation: architecture/next-github-actions-agent-federation.md - Reference: - Management APIs: reference/management-apis.md - Authentication Flows: reference/authentication-flows.md diff --git a/src/admin-control-plane/ca_evaluator.py b/src/admin-control-plane/ca_evaluator.py index 9d3e413..cfac0bd 100644 --- a/src/admin-control-plane/ca_evaluator.py +++ b/src/admin-control-plane/ca_evaluator.py @@ -5,11 +5,8 @@ risk level should result in denial based on the Entra CA policy's agentIdRiskLevels condition. -Currently evaluates: agentIdRiskLevels (risk enforcement) -TODO: Evaluate applicationFilter/servicePrincipalFilter (tag enforcement from CA policy) - This would replace the hardcoded tag matching with CA-policy-driven tag evaluation - by parsing filter rule expressions like: - CustomSecurityAttribute.AgentIdentity_Department -eq "Finance" +Currently evaluates: agentIdRiskLevels (risk enforcement). +Application and service-principal filter evaluation is tracked in GitHub #31. This module is imported by agent apps (budget-approval, budget-report, etc.) for direct A2A enforcement — the same enforcement the sidecar performs at diff --git a/src/budget-approval/ca_evaluator.py b/src/budget-approval/ca_evaluator.py index 9d3e413..cfac0bd 100644 --- a/src/budget-approval/ca_evaluator.py +++ b/src/budget-approval/ca_evaluator.py @@ -5,11 +5,8 @@ risk level should result in denial based on the Entra CA policy's agentIdRiskLevels condition. -Currently evaluates: agentIdRiskLevels (risk enforcement) -TODO: Evaluate applicationFilter/servicePrincipalFilter (tag enforcement from CA policy) - This would replace the hardcoded tag matching with CA-policy-driven tag evaluation - by parsing filter rule expressions like: - CustomSecurityAttribute.AgentIdentity_Department -eq "Finance" +Currently evaluates: agentIdRiskLevels (risk enforcement). +Application and service-principal filter evaluation is tracked in GitHub #31. This module is imported by agent apps (budget-approval, budget-report, etc.) for direct A2A enforcement — the same enforcement the sidecar performs at diff --git a/src/budget-backend/ca_evaluator.py b/src/budget-backend/ca_evaluator.py index 9d3e413..cfac0bd 100644 --- a/src/budget-backend/ca_evaluator.py +++ b/src/budget-backend/ca_evaluator.py @@ -5,11 +5,8 @@ risk level should result in denial based on the Entra CA policy's agentIdRiskLevels condition. -Currently evaluates: agentIdRiskLevels (risk enforcement) -TODO: Evaluate applicationFilter/servicePrincipalFilter (tag enforcement from CA policy) - This would replace the hardcoded tag matching with CA-policy-driven tag evaluation - by parsing filter rule expressions like: - CustomSecurityAttribute.AgentIdentity_Department -eq "Finance" +Currently evaluates: agentIdRiskLevels (risk enforcement). +Application and service-principal filter evaluation is tracked in GitHub #31. This module is imported by agent apps (budget-approval, budget-report, etc.) for direct A2A enforcement — the same enforcement the sidecar performs at diff --git a/src/budget-report/ca_evaluator.py b/src/budget-report/ca_evaluator.py index 9d3e413..cfac0bd 100644 --- a/src/budget-report/ca_evaluator.py +++ b/src/budget-report/ca_evaluator.py @@ -5,11 +5,8 @@ risk level should result in denial based on the Entra CA policy's agentIdRiskLevels condition. -Currently evaluates: agentIdRiskLevels (risk enforcement) -TODO: Evaluate applicationFilter/servicePrincipalFilter (tag enforcement from CA policy) - This would replace the hardcoded tag matching with CA-policy-driven tag evaluation - by parsing filter rule expressions like: - CustomSecurityAttribute.AgentIdentity_Department -eq "Finance" +Currently evaluates: agentIdRiskLevels (risk enforcement). +Application and service-principal filter evaluation is tracked in GitHub #31. This module is imported by agent apps (budget-approval, budget-report, etc.) for direct A2A enforcement — the same enforcement the sidecar performs at diff --git a/src/demo-agent/ca_evaluator.py b/src/demo-agent/ca_evaluator.py index 9d3e413..cfac0bd 100644 --- a/src/demo-agent/ca_evaluator.py +++ b/src/demo-agent/ca_evaluator.py @@ -5,11 +5,8 @@ risk level should result in denial based on the Entra CA policy's agentIdRiskLevels condition. -Currently evaluates: agentIdRiskLevels (risk enforcement) -TODO: Evaluate applicationFilter/servicePrincipalFilter (tag enforcement from CA policy) - This would replace the hardcoded tag matching with CA-policy-driven tag evaluation - by parsing filter rule expressions like: - CustomSecurityAttribute.AgentIdentity_Department -eq "Finance" +Currently evaluates: agentIdRiskLevels (risk enforcement). +Application and service-principal filter evaluation is tracked in GitHub #31. This module is imported by agent apps (budget-approval, budget-report, etc.) for direct A2A enforcement — the same enforcement the sidecar performs at diff --git a/src/employee-menus/ca_evaluator.py b/src/employee-menus/ca_evaluator.py index 9d3e413..cfac0bd 100644 --- a/src/employee-menus/ca_evaluator.py +++ b/src/employee-menus/ca_evaluator.py @@ -5,11 +5,8 @@ risk level should result in denial based on the Entra CA policy's agentIdRiskLevels condition. -Currently evaluates: agentIdRiskLevels (risk enforcement) -TODO: Evaluate applicationFilter/servicePrincipalFilter (tag enforcement from CA policy) - This would replace the hardcoded tag matching with CA-policy-driven tag evaluation - by parsing filter rule expressions like: - CustomSecurityAttribute.AgentIdentity_Department -eq "Finance" +Currently evaluates: agentIdRiskLevels (risk enforcement). +Application and service-principal filter evaluation is tracked in GitHub #31. This module is imported by agent apps (budget-approval, budget-report, etc.) for direct A2A enforcement — the same enforcement the sidecar performs at diff --git a/src/shared/ca_evaluator.py b/src/shared/ca_evaluator.py index 9d3e413..cfac0bd 100644 --- a/src/shared/ca_evaluator.py +++ b/src/shared/ca_evaluator.py @@ -5,11 +5,8 @@ risk level should result in denial based on the Entra CA policy's agentIdRiskLevels condition. -Currently evaluates: agentIdRiskLevels (risk enforcement) -TODO: Evaluate applicationFilter/servicePrincipalFilter (tag enforcement from CA policy) - This would replace the hardcoded tag matching with CA-policy-driven tag evaluation - by parsing filter rule expressions like: - CustomSecurityAttribute.AgentIdentity_Department -eq "Finance" +Currently evaluates: agentIdRiskLevels (risk enforcement). +Application and service-principal filter evaluation is tracked in GitHub #31. This module is imported by agent apps (budget-approval, budget-report, etc.) for direct A2A enforcement — the same enforcement the sidecar performs at diff --git a/src/spiffe-proxy/AGENTS.md b/src/spiffe-proxy/AGENTS.md index b15b470..24b925d 100644 --- a/src/spiffe-proxy/AGENTS.md +++ b/src/spiffe-proxy/AGENTS.md @@ -86,7 +86,7 @@ policies: - **Management API** endpoints: `GET /mgmt/policy`, `PUT /mgmt/policy`, `GET /mgmt/allowed-ids`, `PUT /mgmt/allowed-ids`, `GET /mgmt/health` - **gRPC tunnel** multiplexes HTTP requests over a single mTLS connection between egress and ingress proxies. - **SPIRE Agent monitor** — a background loop in `entrypoint.sh` checks the agent process every 10s and restarts it if it dies. This handles mid-run crashes (agent re-attests using cached key material in `/opt/spire/data/agent/`). Does NOT help on full container restart where the data dir is wiped and the join token is consumed. -- **No `proxy-only` mode** — `entrypoint.sh` only supports `CONTAINER_MODE=server` and `CONTAINER_MODE=agent-proxy`. There is no mode for connecting to an external SPIRE Agent socket (needed for SPIFFE federation with `gcp_iit` attestation). See `docs/architecture/next-google-cloud-agent-federation.md` and hard-won-learnings #32. +- **No `proxy-only` mode** — `entrypoint.sh` only supports `CONTAINER_MODE=server` and `CONTAINER_MODE=agent-proxy`. There is no mode for connecting to an external SPIRE Agent socket (needed for SPIFFE federation with `gcp_iit` attestation). See hard-won-learnings #32 and GitHub issue #35. ## Building diff --git a/src/spiffe-proxy/internal/ca/graph_client.go b/src/spiffe-proxy/internal/ca/graph_client.go index 8dd54ed..c213198 100644 --- a/src/spiffe-proxy/internal/ca/graph_client.go +++ b/src/spiffe-proxy/internal/ca/graph_client.go @@ -3,8 +3,8 @@ // agentIdRiskLevels conditions, and determines which risk levels // should block callers based on live CA policy state. // -// Currently evaluates: agentIdRiskLevels (risk enforcement) -// TODO: Evaluate applicationFilter/servicePrincipalFilter (tag enforcement from CA) +// Currently evaluates agentIdRiskLevels. Application and service-principal +// filter evaluation is tracked in GitHub issue #31. package ca import ( diff --git a/src/spiffe-proxy/internal/ca/policy_cache.go b/src/spiffe-proxy/internal/ca/policy_cache.go index f6d3d76..ef34278 100644 --- a/src/spiffe-proxy/internal/ca/policy_cache.go +++ b/src/spiffe-proxy/internal/ca/policy_cache.go @@ -31,7 +31,7 @@ type caCachedPolicy struct { State string `json:"state"` RiskLevels []string `json:"risk_levels"` IsBlockPolicy bool `json:"is_block_policy"` - // TODO: Add tag filter fields when implementing CA-driven tag evaluation. + // CA-driven tag filter fields are tracked in GitHub issue #31. // These would be parsed from conditions.clientApplications.servicePrincipalFilter // and conditions.applications.applicationFilter rule expressions. }