Risk-State Bootstrap and Recovery¶
Risk evidence is authorization state, not test preparation. Missing evidence
never means low risk. Tests and deployment verification must not reset production
risk or call Graph confirmSafe.
Authority and Persistence¶
CA_RISK_PROVIDER selects the ingress gateway's provider as well as the Python
and portal provider:
| Provider | Authority | Restart behavior |
|---|---|---|
entra (default) |
Explicit Graph riskyAgents/{object-id} rating |
Hydrate required callers from Graph; the portal monitoring cache is not authority |
sidecar |
Explicit timestamped records in Azure Blob Storage | Read the same central records after restart; never seed from process memory |
Both modes persist Security Portal updates in the environment's dedicated
strisk<resource-token> storage account, container agent-risk-evidence.
Ingress startup requires durable storage in both modes, including Entra
mode: an acknowledged SOC elevation must not disappear when the process restarts.
Memory-only risk stores are confined to local fixtures.
The account disables shared-key and anonymous access. Budget Backend's managed
identity receives container-scoped Storage Blob Data Contributor; the portal's
policy-store identity has no role on this account. The authenticated deployment
operator also receives container-scoped access when provisioning sidecar evidence.
Each exact SPIFFE ID maps to sha256(spiffe_id).json. A record contains
spiffe_id, risk_level, observed_at, and expires_at. Cross-identity,
malformed, future-dated, expired, or unavailable records cannot authorize traffic.
An absent record is unknown. In Entra mode only, an absent SOC record means
no additional signal; it cannot replace a missing Entra rating.
Entra evidence may be none, low, medium, or high. Manual management
updates accept only low, medium, or high. Explicit Entra none can be
persisted during provisioning; it is never inferred from a missing Graph record.
SOC signals can raise but cannot lower the effective Entra rating. Expired or
unreadable existing SOC evidence denies rather than silently lowering risk.
Freshness and Conflicts¶
- Management updates are stamped by the gateway and expire after 24 hours by
default.
RISK_EVIDENCE_LIFETIMEcan shorten this duration, never exceed 24h. Deployment resolves the explicit environment value, then the saved azd value, then24h; validates and persists it and injects the same duration into the gateway and first-deploy bootstrap. Use Go duration syntax (for example30mor1h30m), with microsecond precision or coarser. Standalone bootstrap accepts--lifetimeorRISK_EVIDENCE_LIFETIME. Cross-cloud onboarding reads the actual gateway duration from authenticated/admin/health, not the operator's local default; absent or invalid duration fails before writes or admission. Renew evidence through the authenticated risk API; do not renew it merely because a process restarted. Deployment preserves existing records and fails on expired records rather than quietly clearing or extending them. - A governed call revalidates its authenticated caller's durable record from
Blob. Cold start, a changed policy or admitted caller set, and explicit
readiness probes hydrate all required enabled callers. Successful hydration
completion is remembered separately from risk ratings, avoiding redundant
full-store reads on every request. A failed readiness probe or caller-risk
lookup invalidates that completion. CA policy freshness is checked even after
hydration. Remote readiness reads run outside the hydration-state mutex:
a slow unrelated caller cannot stall a warm request's own fresh risk lookup.
Before publishing completion, a scan revalidates the policy, admitted caller
set, CA levels/freshness/observation, and invalidation generation. An intervening
failed caller lookup or probe prevents stale completion from being restored.
A read/write operation has a five-second deadline. Provider errors
cannot use a cached safe rating.
Full readiness scans use at most four simultaneous identity reads and a
twelve-second total deadline, inherited from the requester. Every required
enabled identity must finish successfully before readiness/completion is
published. Cancellation invalidates completion even if a provider returns
success after cancellation. Budget Backend waits fifteen seconds for readiness,
the admin control plane twenty, and the deployment verifier thirty-five.
Process
/healthremains independent and reachable for recovery. The four full-scan slots are shared process-wide across overlapping readiness, sidecar snapshots, and combined Entra snapshots—not four additional reads per request. Waiting for a slot consumes the request's existing total budget; cancelled queued reads do no provider/cache work. Slots are released on success, error, or cancellation. Authenticated actual-caller reads remain outside this scan-only queue, so repeated readiness probes cannot queue warm governed traffic behind unrelated scans. This is not a distributed tenant-wide provider limit. - Management risk snapshots (both sidecar and combined Entra) use the same
four-worker fanout with an eight-second total deadline; Budget Backend's
risk-read bridges wait ten seconds. Sidecar snapshots return an explicit 503
on read failure/cancellation, never a partial or cached-safe map. Combined
Entra snapshots preserve explicit
unknownfor unavailable evidence and fail the whole snapshot on total-deadline/cancellation. Exact source attribution and per-caller authority remain unchanged. - Writes use Blob ETags (
If-Match, orIf-None-Match: *for a new record). Persist before acknowledging. Concurrent conflicts return HTTP 409; provider failures return 503. Re-read authoritative state before retrying a conflict. Sequential authenticated updates are newer explicit assertions, not merges. The Go writer checks the exact serialized JSON size before upload against the same inclusive 4096-byte bound as the reader. Oversized updates are neither uploaded nor acknowledged and cannot poison subsequent reads/recovery. - Entra runtime
risk_cache_secondsretains its existing default of 90 seconds; zero forces a lookup every call. Failed required refreshes invalidate evidence. - Graph CA policies must be observed successfully. A failed refresh retains the last policy for less than three sync intervals (180 seconds by default); the successful-fetch timestamp is not extended. At the boundary the policy becomes unavailable, including a previously empty policy list.
First Deployment and Upgrades¶
Use ./deploy.sh --new or ./deploy.sh --skip-provision. Do not use
--portal-only for this upgrade: agents, sidecar authentication, managed identity
access, and authorization readiness all change.
For sidecar mode, deployment provisions missing report, approval, and management identity records before rolling sidecars. It reads explicit ratings from Entra using the configured provisioner. Existing evidence, including high risk, is never overwritten. Missing/unlicensed/unreachable Graph data stops deployment. This is onboarding, not a post-deploy test reset.
For genuinely new agents without Entra evidence, the operator can supply an
approved JSON array via RISK_BOOTSTRAP_FILE:
[
{
"spiffe_id": "spiffe://aim.microsoft.com/ests/bp/<blueprint-oid>/aid/<agent-oid>",
"risk_level": "low",
"observed_at": "<actual-observation-time-in-RFC3339>",
"expires_at": "<actual-expiry-time-in-RFC3339>"
}
]
Use a real assessment and current timestamps, not the placeholders above.
Approval is an operator trust decision; the file is not a default-safe template.
Both timestamps must use RFC3339 with uppercase T, seconds, optional decimal
fractional seconds (up to nine digits), and uppercase Z or a ±HH:MM timezone offset. Other ISO
formats (including space separators or compact dates) are rejected before
upload because the sidecar cannot hydrate them. Approved timestamps are preserved
exactly; bootstrap does not replace or renew assessment times.
Freshness and lifetime comparisons retain nanosecond precision, including
Go-generated nine-digit fractions; Python microsecond truncation cannot accept
an overlong assessment. Approved extra fields are preserved, but the complete
record must serialize to at most 4096 UTF-8 bytes using the writer's JSON encoding
(including its Unicode escaping). Every pending record is size-checked before
the first upload; an oversized note cannot poison the durable recovery path.
The maximum lifetime is the configured gateway duration (at most 24 hours).
Existing and approved evidence exceeding a shortened duration is rejected,
never clamped or renewed. All pending records are validated before the
first conditional write. A partially completed upload is safe to retry: existing
records are preserved, and conflicting creates fail rather than overwrite.
An upgrade with --skip-provision provisions only the missing dedicated risk
storage resource and access roles before configuring full Container App YAML.
Azure deployment permissions must permit resource creation and role assignments.
Storage role propagation is retried within bounded deadlines; failure is fatal.
Backend access uses the same risk-store-access.bicep module in full provisioning
and skip-provision deployment, retaining its deterministic role-assignment name
across upgrade and reprovisioning. Operator access remains separately scoped.
Bootstrap rejects redirects for both token and Graph requests; neither client
credentials nor Graph bearer tokens are forwarded to redirect destinations.
Cross-cloud onboarding¶
add-google-agent.sh and add-github-agent.sh stage the new caller through
onboard_federated_agent.py using the HTTPS admin-control-plane URL from the
current azd environment. The protocol is: preserve existing caller policy,
establish authoritative evidence, install an exact caller policy, then add the
identity to the mTLS allow list. New budget-reader policies retain required JWT
validation and the Budget.Read role; existing disabled callers cannot be
re-enabled by onboarding. Graph tag synchronization remains a separate
requirement, not an onboarding exemption.
In sidecar mode, the authenticated operator must have container-scoped Blob
access and supplies either an explicit Graph rating or approved timestamped
evidence through RISK_BOOTSTRAP_FILE, as on first deployment. In Entra mode,
an explicit Graph rating remains mandatory; an approved local assessment cannot
replace missing Entra evidence. Both scripts hard-fail on incomplete onboarding
instead of reporting a skipped admission as success.
The management admission API also validates every newly added exact identity
before changing the allow list. Missing policy, disabled state, unavailable CA
policy, or required missing/stale/unavailable evidence returns HTTP 409
caller_onboarding_incomplete and leaves the existing allow list unchanged.
Removal of an existing identity does not require evidence for that identity.
Policy replacement is validated against the current admitted caller set
before publication, including management evidence when enabling enforcement.
Removing an admitted caller's policy, disabling the recovery identity, or
enabling enforcement with unavailable required evidence returns HTTP 409
policy_authorization_state_unavailable and retains the prior policy and load
timestamp. Disabled business callers need no evidence. Policy replacement and
admission mutations are serialized so admission cannot interleave between
validation and publication. Remove transport admission before deleting a caller
policy. No broad management bypass is introduced.
Policy replacement and mTLS admission have an eight-second total validation
deadline and use the request context for Blob, Graph token/risk requests, and
contested Graph identity/token locks. Cancellation is checked under the
policy-store lock before publication (or under the allowlist publication lock
for admission), so a failed requester cannot leave a later acknowledged
preference rollback divergent from the active gateway policy. Budget Backend's
policy and mTLS-admission bridges wait ten seconds, within the portal's
fifteen-second and control-plane's
twenty-second budgets. Provider reads retain their own shorter bounds where
applicable. Cancelled or over-budget replacement returns a failure without
changing the prior policy or its load timestamp. Cancelled admission likewise
leaves the previous allowlist intact, including when validation succeeded just
before cancellation or the request only removes existing IDs. The policy/admission
serialization lock remains shared; no guard or disabled-identity control is bypassed.
Both portal presets retain the unchanged policies of currently admitted callers,
including federated callers, while installing the new preset, then reduce
transport admission. Retained but no-longer-admitted policies cannot confer
transport access. If admission update fails, the existing policy rollback remains
valid. The pre-publication gateway guard is never bypassed.
Staging policy and evidence is not an atomic transaction, but failed staging
never admits the caller; retry preserves existing evidence and policies.
Retain installed external caller stanzas in your deployment policy
configuration: runtime policy updates are not a replacement for deployed policy
configuration.
Cross-cloud runtime setup keeps Blueprint identifiers distinct.
setup-gce-agent.sh requires BLUEPRINT_OBJECT_ID (or
--blueprint-object-id) for the SPIFFE namespace and BLUEPRINT_APP_ID (or
--blueprint-app-id) for the OAuth audience. Deployment supplies them from
ENTRA_BLUEPRINT_OBJECT_ID and ENTRA_OAUTH2_AUDIENCE, respectively; the object
ID is never inferred from the OAuth client ID. Example:
./scripts/setup-gce-agent.sh \
--blueprint-object-id "<blueprint-object-id>" \
--blueprint-app-id "<blueprint-oauth-client-id>" \
--google-agent-id "<agent-object-id>" \
# Supply the remaining required tenant, backend, SPIRE and ACR options.
GitHub runner SPIRE registration and admission use the same Blueprint object
ID, while setup-github-runner.sh receives the OAuth client ID as
BLUEPRINT_APP_ID for its generated workflow environment.
Readiness and Recovery¶
Sidecar /health remains process liveness. Its authenticated /ready reports
200 only when policy and risk evidence for admitted, enabled callers have
hydrated; unavailable evidence returns 503. A known high-risk caller does not
make evidence readiness fail: the gateway is ready to deny it.
Budget Backend exposes /healthz/ready for authorization readiness monitoring and
authenticated /mgmt/ready through the management tunnel. Container App routing
uses process readiness (/health), not authorization readiness: withdrawing
an unhydrated revision from routing would also cut off authenticated recovery.
The gateway's authorization gate, not platform routing, blocks ordinary traffic
until evidence is available. The portal's
/healthz/ready queries /admin/ready, not process liveness. Required callers
follow the current mTLS allow list, including identities added at runtime.
Governed traffic cannot pass while required evidence remains unhydrated.
An unmatched admitted caller cannot obtain risk-governed access through
default_action: allow, and unmatched risk writes remain denied.
This risk readiness contract does not replace JWT, tag, or other authorization
checks; absent Graph tags still deny and require normal attribute synchronization.
Only the exact configured SPIFFE_PREFIX_ADMIN_CONTROL_PLANE workload may
use the risk-recovery exception for PUT /mgmt/agent-risk and
GET /mgmt/ready. Policy names, prefixes, descendants, and caller-supplied
headers do not establish recovery authority. Recovery bypasses only the caller's
risk check and hydration gate. It does not bypass:
- mTLS trust and allow-list admission;
- shared-key management authentication at both backend and sidecar;
- caller-policy/RBAC rules or required JWT checks;
- the disabled-agent kill switch, target tags, or policy availability.
To establish or renew approved evidence, use the existing authenticated
PUT /admin/agent-risk path with {"spiffe_id": "...", "risk_level": "..."}.
The management caller can recover its own expired evidence without first being
treated as safe. Other management methods and routes remain governed. A disabled
management identity cannot use this path. Storage corruption and permissions
failures require an authorized storage/operator repair; no default-safe recovery
exists.
Portal Settings labels enforcement for the selected provider. Durable-sidecar
enforcement can be re-enabled after its authoritative-evidence preflight; the
Entra cache control remains separate and disabled for sidecar mode.
Risk snapshots enumerate deduplicated exact admitted workloads plus exact policy
identities, not namespace prefixes. Raw local snapshots explicitly report
source: "sidecar"; combined Entra snapshots report source: "entra".
Independent portal Entra monitoring settings never determine gateway attribution.
The portal selects /admin/entra-risk for configured Entra mode even when
gateway enforcement is off, retaining the combined Graph/SOC snapshot.
Sidecar mode uses /admin/agent-risk; a raw local snapshot is never relabeled
as Entra evidence. The independent portal monitoring switch controls its separate
Graph display, not the gateway snapshot's provider or endpoint.
Deployment's default verifier is the read-only
scripts/verify_risk_readiness.py. Unhydrated state, denied recovery identity,
or an unreachable management path is a hard failure, not a warning. --no-verify
is an explicit opt-out and does not initialize evidence. The legacy
scripts/test_agents.py no longer resets baseline risk or runs risk mutations
by default; scoped mutating exercises belong to the approved live harness.
The verifier rejects every redirect without forwarding the management key.
Its final check runs after optional Google/GitHub onboarding and before an
optional local portal launch, covering the final admitted caller set rather
than only the base deployment.
Evidence Boundaries¶
The local protocol matrix includes cold start, none/low/medium/high restart, missing, malformed, cross-identity, future-dated and expired evidence, provider and write outages, ETag conflicts, unauthorized recovery, disabled recovery, and unrelated RBAC/JWT/tag checks. It exercises the actual Azure Blob SDK adapter against an ephemeral Blob HTTP boundary, including real adapter writes and fresh-store reloads. This is not proof of Azure managed identity role propagation or Container App/SPIRE recovery. Admission regressions also exercise the actual management HTTP API and validate that failed onboarding leaves management policy installation and rollback reachable, rejected policy removal/enforcement transitions retain the previous state, and unmatched default-allow callers cannot bypass hydration. Script tests execute the gateway YAML generator and compare its lifetime with bootstrap arguments, and use the gateway's advertised lifetime during federated provisioning. They also cover evidence-before-admission ordering, preserved policies, provider failures, and final verification ordering; a Chromium API regression covers source attribution with independent Entra monitoring enabled.
Run the local matrix via tests/run.py and the repeated gate via tests/check.py.
Live rollout/restart testing requires explicit environment and mutation
authorization; never restart a shared environment or clear tenant risk as a test
fixture.