Skip to main content

Enterprise credential-exchange patterns for connectors

Bottom line. For first-class clouds, exchange a trust relationship, never a long-lived secret — this is what Wiz/Orca/Datadog converge on and what enterprise buyers expect. Recommended pattern per connector:

ConnectorRecommended patternPer-tenant secret?
AWSCross-account assume-role + external-IDNo (one platform AWS-caller cred)
Entra / GraphMulti-tenant app + admin consentNo (one platform app secret)
GCPWorkload Identity FederationNo
GitHubSV0 GitHub AppNo (one global private key)
Atlassian CloudForge app + Forge Remote (not Connect — deprecated)No
ServiceNow / other SaaSOAuth client_credentials, customer-pasted into portalYes (in KV)

ADR-027's broker interface stays; for the federated clouds the broker mints a token instead of fetching a stored secret. The Slice-1 flat-env provider is dev/demo only — not a production control. Full per-connector rationale in §6; the decision and follow-up issues are in §7.

1. Why this research

ADR-027 currently models tenant credentials as a flat "customer hands us a secret bundle, we put it in Azure Key Vault, broker resolves it per scan" flow. That works for any source system but is the worst-case shape — it assumes every connector requires a long-lived shared secret. For modern clouds (AWS, Azure, GCP) and well-designed SaaS (GitHub), the industry has moved to federated patterns where no long-lived secret is exchanged at all. The customer grants a trust relationship; tokens are minted per-call.

Two questions this research answers:

  1. Per source system — what's the right trust model? Cross-account IAM role for AWS, multi-tenant app with admin consent for Azure/Entra, workload identity federation for GCP, GitHub App for GitHub. ServiceNow and other SaaS without federation models still need a secret, but the secret should never traverse email/Slack — it should be customer-pasted directly into our admin portal, encrypted-in-transit to KV.

  2. Per CSPM peer (Wiz, Orca, Datadog) — what do they actually do? Public-doc evidence of how each one onboards a customer. Used as cover for our own choices: if Wiz, Orca, and Datadog all converge on the same pattern, we should have a strong reason to deviate.


2. Vendor survey

2.1 Wiz

Wiz's principle across every cloud surface is "federation-first, no shared secret." Onboarding is always bootstrapped by a Wiz-authored artifact (CloudFormation, Cloud-Shell script, Helm chart, GitHub App manifest) so the customer never edits IAM directly. The customer hands back only identifiers — Role ARN, External ID, tenant ID, service-account email, installation ID — never a credential value.

Read the table as inferred, not confirmed. Wiz's own docs are login-walled (docs.wiz.io → 403). The cells below are reconstructed from partner-published walkthroughs, the public Helm chart, and Google/Microsoft partner pages, and from the shape of the install artifact (e.g., a wiz_managed_identity_external_id Terraform input implies WIF). Specific mechanics — trust principal account, exact GCP federation wiring, GitHub App scopes — are inferred. Treat the direction (federation-first) as well-evidenced and the specifics as best-effort reconstruction.

CloudCustomer-side actionWhat is exchangedTrust mechanism
AWSDeploy Wiz-supplied CloudFormation template (single account) or StackSet (org-wide). Copy stack-output ARN back to Wiz UI.Role ARN + External ID. No AWS access key leaves the account.Cross-account sts:AssumeRole with ExternalId condition. Trust principal = Wiz's own AWS account. Permissions = SecurityAudit managed policy + scoped snapshot/KMS verbs for SideScanning.
AzureRun Wiz-supplied bash script in Azure Cloud Shell. Scope = management group / subscription / Entra-ID-only.Service principal materialised in customer tenant for Wiz's multi-tenant Entra app; RBAC role assigned at chosen scope. No client secret exchanged.Multi-tenant Entra app + admin consent + RBAC.
GCPRun Wiz auto-generated shell script (wiz-gcp.sh) or Terraform module at org/project scope.Service-account created in customer org with read + snapshot perms. Wiz-managed external_id parameter wires up federation.Workload Identity Federation with service-account impersonation. No SA JSON key downloaded.
KubernetesCustomer adds Wiz Helm repo and installs wiz-kubernetes-integration.Wiz-issued service-account client-ID + token stored as a K8s Secret in the cluster. Direction inverted: broker authenticates outbound to Wiz.Outbound HTTPS tunnel (useHATunnel: true, optional AWS PrivateLink) from broker to Wiz API. Cluster never accepts inbound.
GitHubOrg admin installs Wiz's published GitHub App (Wiz Code).GitHub App installation ID. Wiz mints short-lived (1h) installation tokens via its globally-held private key.Standard GitHub App pattern; no PAT/OAuth-token exchange.

Note on sourcing. docs.wiz.io is gated behind app.wiz.io/login (returns HTTP 403 unauthenticated). The table above is reconstructed from partner-published walkthroughs (Cordant, Tom Walker on LinkedIn, Medium write-ups), the public wiz-sec/charts Helm repo, Google Cloud's published Wiz partner architecture, and Microsoft Learn's Wiz connector page. Exact CloudFormation parameter names, GCP federation audience strings, and GitHub App scope manifests are not in unauthenticated public material.

URLs:

2.2 Orca Security

Orca's onboarding mirrors Wiz's shape (federation in every cloud, vendor-authored bootstrap artifact, identifiers-only round-trip) but adds snapshot-create + share permissions to the customer-side role/identity in every cloud. This is forced by Orca's "SideScanning" architecture, where scanning runs against snapshots copied into Orca's own cloud tenant rather than against the customer's APIs directly.

Same sourcing caveat as Wiz. docs.orcasecurity.io is login-walled. The AWS detail (external-ID, SecurityAudit, snapshot/KMS verbs, tag-scoped delete) is corroborated across the AWS Storage Blog and the public orcasecurity/Orca_AWS_In-Account_Policies repo; the Azure/GCP role lists are reconstructed from partner pages + the standard pattern each cloud requires for snapshot-sharing. Treat specific role names as inferred.

CloudCustomer-side actionWhat is exchangedTrust mechanism
AWSSelf-service CloudFormation template (single account) or StackSet against the AWS Organization. Multi-account auto-onboarding propagates to new accounts.Role ARN + Orca-supplied External ID. No long-lived secret.Cross-account sts:AssumeRole with ExternalId condition. Trust principal = Orca's production AWS account. Permissions = SecurityAudit managed policy + 6 Orca-managed policies covering ec2:CreateSnapshot/CopySnapshot, tag-scoped ec2:DeleteSnapshot (Orca=*), and KMS Decrypt/CreateGrant/... for CMK-encrypted volumes.
AzureSaaS-deployment wizard triggers admin consent for Orca's multi-tenant Entra app + ARM/Bicep role-assignment script. Management-group-scoped onboarding covers all subscriptions.Service principal in customer tenant. RBAC = built-in Reader + targeted readers (Key Vault, logs) + a custom role for Microsoft.Compute/disks/beginGetAccess/action and snapshots/write.Multi-tenant Entra app + admin consent.
GCPSelf-service shell script or Terraform at org/project scope.IAM binding: Orca's production service account gets roles/iam.serviceAccountTokenCreator on a customer-side SA. No JSON key exported.Cross-project service-account impersonation (Google's current recommendation; close to WIF but uses a long-lived IAM binding rather than a federated identity pool). Custom role for compute.disks.createSnapshot/snapshots.setIamPolicy.

Deviation from "pure read-only". Where classic CSPMs (e.g., API-only Wiz, pre-agentless Prisma) grant Reader/SecurityAudit, Orca additionally needs snapshot create + share (and on AWS, KMS grant) — necessary for SideScanning's snapshot-copy model. Tag-scoping (Orca=* on DeleteSnapshot) is how Orca narrows the destructive surface.

Note on sourcing. Same caveat as Wiz: docs.orcasecurity.io is login-walled. Cross-referenced against AWS Storage Blog, Orca's partner-page descriptions, the public orcasecurity/Orca_AWS_In-Account_Policies GitHub repo (snapshot/KMS verbs), and the SideScanning technical brief.

URLs:

2.3 Datadog

Datadog is the laggard of the three on Azure and (until recently) GCP — both still expose a "customer creates app/SA and pastes secret/key into Datadog UI" path as the documented default. AWS is on par with Wiz/Orca. The key insight from Datadog's docs is that modern federation and legacy paste-a-secret coexist on the same product, gated by feature flags (secretless_auth_enabled for Azure WIF) or version markers (V1 GCP service-account JSON deprecated in favor of V2 WIF).

IntegrationCustomer-side actionWhat is exchangedTrust mechanism
AWSCloudFormation Quick Create (default), manual IAM role, or Terraform datadog_integration_aws.Role ARN + Datadog-generated External ID (Datadog mints it server-side, customer pastes it into AWS — opposite direction from Wiz/Orca). Access keys allowed only in GovCloud and China partitions (commercial AWS = role-only).Cross-account sts:AssumeRole + ExternalId.
AzureARM template or manual app-registration walkthrough. Customer creates the app registration in their own tenant (not a Datadog-published multi-tenant app).tenant_id + client_id + client_secret pasted into Datadog UI. WIF is in preview (secretless_auth_enabled flag).Customer-owned single-tenant app + secret OR (preview) federated credential.
GCPTerraform module or Cloud Shell script (recommended for WIF) or manual JSON-key upload (legacy).V1: full service-account JSON key uploaded. V2: just client_email; WIF trust to Datadog's principal. V1 marked deprecated.V1: SA key (legacy). V2: WIF.
ServiceNow"Add New Instance" tile. Customer creates a ServiceNow OAuth Application Registry record + dedicated user with the right roles.OAuth2 client_id + client_secret (recommended) OR HTTP Basic username/password (legacy). Pasted into Datadog UI; no public credentials-POST API for this integration.OAuth client_credentials or basic. No federation option exists in ServiceNow.
GitHub"Connect GitHub Account" button → redirect to GitHub App install.GitHub App installation ID. No customer paste step into Datadog UI. Webhook URL paste from Datadog into GitHub side (one direction only).GitHub App (same as Wiz).

Programmatic credential intake. Datadog publishes versioned REST APIs for cloud integrations: POST /api/v2/integration/aws/accounts, POST /api/v1/integration/azure, POST /api/v2/integration/gcp/accounts. Mirrors the UI fields exactly — customers can register integrations via IaC pipeline. No equivalent for ServiceNow or GitHub (UI/redirect-only). The cloud-side APIs are the closest public precedent for a "tenant admin API" pattern: versioned REST, accept credentials over TLS, return server-generated trust artifacts (Datadog's external ID is the prime example).

URLs:

2.4 Cross-vendor convergence

A short cross-reference of where the three peers agree, disagree, and what their disagreements tell us:

SurfaceWizOrcaDatadogConvergence verdict
AWSCross-account role + external-IDCross-account role + external-IDCross-account role + external-ID (commercial); IAM access keys only in GovCloud/ChinaStrong convergence. Confirmed for Datadog (primary docs); consistent with partner-published Wiz/Orca walkthroughs (their own docs are login-walled — see sourcing notes in §2.1/§2.2). It is also the AWS-documented standard for third-party access, so the convergence is well-grounded even where vendor specifics are inferred.
AzureMulti-tenant Entra app + admin consentMulti-tenant Entra app + admin consentCustomer-owned app reg + paste secret (legacy); WIF in previewTwo camps. Modern (Wiz, Orca — inferred) vs. legacy (Datadog — confirmed). See note below on multi-tenant-app vs WIF.
GCPWorkload Identity FederationService-account impersonation (close to WIF)WIF (V2 default); legacy SA-key JSON (V1 deprecated)Trending to federation. All three avoid raw SA-key JSON for new integrations (Datadog confirmed; Wiz/Orca inferred).
GitHubGitHub App(not surveyed)GitHub AppGitHub App is the dominant pattern (confirmed for Datadog; Wiz per blog material; Orca not surveyed). OAuth Apps and PATs are explicitly deprecated for new integrations per GitHub's own docs.
KubernetesOutbound broker (inverted direction)(out of scope)(out of scope)Wiz's approach reflects that clusters are typically not reachable from the outside — connector model has to invert when scanning runtime-only data.
ServiceNow / generic SaaS(not in core onboarding surface)(not in core onboarding surface)OAuth client_credentials, customer paste into UINo federation option — every vendor degrades to customer-paste. Choose to either (a) accept paste, with strict UX rules, or (b) skip the integration class.
Atlassian (Jira / Confluence)(not in core onboarding surface)(not in core onboarding surface)(mostly in developer-tool integrations as 3LO, not in the surveyed CSPM surface)Currency flag: Atlassian Connect is deprecated (new Marketplace apps must be Forge; Connect support ends Dec 2026 — §3.8). The 2026 pattern is a Forge app with Forge Remote (Marketplace install + SV0-controlled backend). Existing-vendor Connect apps are mid-migration ecosystem-wide. PAT remains for Data Center. A Connect-first recommendation in 2026 is a red flag.

Azure note — don't conflate two patterns. The modern Azure pattern is multi-tenant app + admin consent for reading the customer's Graph (§3.4). Entra Workload Identity Federation (§3.5) is a different thing — it only removes the vendor's own app secret. They compose; they are not alternatives.

What we learn. The federation patterns are the default expectation among modern customers. A new entrant that asks for an Azure client_secret today is doing what Datadog does, not what Wiz does — and Wiz's posture is the one customers point to in procurement. For AWS / Azure / GCP / GitHub, "federation-first" should be the default; for ServiceNow and other SaaS, paste is acceptable only if the UX is encrypted-direct-to-KV with no staff visibility.


3. Foundational federation patterns

Authoritative summaries from official cloud-provider docs. This section is the technical backbone the per-connector recommendation in §6 will refer back to.

3.1 AWS — Cross-account IAM role with sts:AssumeRole + sts:ExternalId

Trust model. Customer's AWS account trusts the vendor's AWS account only when the vendor presents an agreed-upon external ID, mitigating the confused-deputy problem.

Customer creates. An IAM role in their account whose trust policy names the vendor's AWS account as Principal and adds a Condition clause: "StringEquals": {"sts:ExternalId": "<vendor-assigned-tenant-id>"}. Permission policy is scoped to the read-only actions needed. Customer hands the role ARN back to the vendor.

Vendor side (once). A single AWS account holding the IAM principal that calls sts:AssumeRole.

Per-tenant. Generate a unique random external ID, store the {tenantId → roleArn, externalId} mapping, always pass ExternalId on AssumeRole.

Doc. AWS — External IDs for third-party access

Gotcha. The external ID is not a secret — AWS says explicitly "AWS does not treat the external ID as a secret … can be seen by anyone with permission to view the role." It must be generated by the vendor (not customer) and unique per AWS account; otherwise the confused-deputy protection collapses.

3.2 AWS — IAM Roles Anywhere

Trust model. AWS trusts X.509 certificates issued by a customer-controlled CA (the "trust anchor") that the vendor's external workload presents.

Customer creates. A trust anchor (reference to AWS Private CA or external CA cert), a profile (session policy + role list), and an IAM role whose trust policy trusts the rolesanywhere.amazonaws.com service principal, pinned to the trust anchor ARN via aws:SourceArn.

Vendor side. A workload with a valid X.509 cert + private key from a CA that the customer's trust anchor recognizes. Signs CreateSession requests using SigV4-with-X.509.

Doc. AWS — IAM Roles Anywhere intro

Gotcha. Requires PKI — vendor must run (or buy into) a CA whose cert chain the customer is willing to trust. Not appropriate when the customer doesn't already operate a CA. Trust boundary is at the AWS account level: any cert from any trust anchor in that account can assume any role unless trust policies add conditions. Niche pattern; not used by any surveyed peer.

3.3 AWS — OIDC federation (sts:AssumeRoleWithWebIdentity)

Trust model. Customer AWS account trusts a JWT issued by an external OIDC IdP (GitHub Actions, Entra, GCP, our own IdP).

Customer creates. An IAM OIDC identity provider resource (issuer URL + audience/client-ID), then an IAM role whose trust policy uses "Federated": "arn:aws:iam::ACCT:oidc-provider/<issuer>" with Action: sts:AssumeRoleWithWebIdentity and Condition on <issuer>:aud and <issuer>:sub to pin to a specific workload.

Vendor side (once). Operate the OIDC IdP — publish a discovery doc at /.well-known/openid-configuration with issuer, jwks_uri, id_token_signing_alg_values_supported (RS256/ES256 etc.), and sign JWTs per workload.

Per-tenant. Customer adds the OIDC provider + role; vendor stores role ARN.

Doc. AWS — OIDC federation

Gotcha. Hard caps — JWKS limited to 100 RSA + 100 EC keys; 100 audiences max per provider; OIDC provider must be in the same account as the role. AWS falls back to thumbprint verification if it can't validate the JWKS TLS cert against trusted CAs, so a self-signed JWKS endpoint forces thumbprint rotation chores.

Trust model. Vendor publishes one app registration in their home tenant set to signInAudience = AzureADMultipleOrgs. When a customer admin consents, a service principal is materialized in their tenant — that SP is the trust object.

Customer-side action. Customer tenant admin clicks through https://login.microsoftonline.com/common/adminconsent?client_id=<app> (or installs from the Microsoft AppSource gallery). One button; creates the SP + a delegation recording consent.

Vendor side (once). Single multi-tenant app registration; code points at /common or /organizations endpoint and must validate iss against the per-tenant tid.

Per-tenant. Capture the tenant ID on first consent callback; store {tenantId → ...}.

Doc. Microsoft Entra — Convert app to multi-tenant

Gotcha. "App-only permissions always require a tenant administrator's consent" — and so do many delegated Graph permissions. If the customer tenant has disabled user consent (common in enterprise), every permission requires admin consent. Revocation is by deleting the enterprise application (service principal) in the customer's tenant — vendor has no API to force-remove it.

3.5 Azure / Entra — Workload Identity Federation

Trust model. An Entra app registration (or user-assigned managed identity) trusts JWTs from an external IdP (GitHub Actions, AWS-via-Cognito, GCP, Kubernetes, SPIFFE).

Setup. Add a federated identity credential to the app/MI specifying issuer, subject, audience. External workload calls the OAuth2 client-credentials endpoint with client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer and the external IdP's JWT as client_assertion — no client_secret required.

Doc. Microsoft Entra — Workload Identity Federation

Gotcha. issuer, subject, audience are case-sensitive exact matches against the incoming JWT — silent failures are common. Tokens issued by Entra itself cannot be used as the federation assertion. External IdP JWKS capped at 100 signing keys.

Important distinction: Entra WIF is for our workloads (running outside Azure) to authenticate to Entra apps we own. It is not the pattern for reading a customer's Entra tenant — that's §3.4 (multi-tenant app + admin consent). The two patterns compose: our workload can use WIF to auth as our own multi-tenant app, then call Graph with a per-tenant token.

3.6 GCP — Workload Identity Federation

Trust model. A GCP project trusts an external IdP (AWS, Azure, OIDC, SAML, GitHub, GitLab, Okta) via a workload identity pool; external identities then impersonate a GCP service account.

Customer creates. A workload identity pool, a workload identity pool provider (OIDC/SAML/AWS with attribute mapping + optional CEL attributeCondition), a service account, and an IAM binding roles/iam.workloadIdentityUser granting the external principal (or set) the right to impersonate that SA.

Vendor side. External workload exchanges its IdP token at sts.googleapis.com/v1/token (grant urn:ietf:params:oauth:grant-type:token-exchange) for a federated access token, then optionally calls generateAccessToken on the target SA. Audience must be //iam.googleapis.com/projects/<NUMBER>/locations/global/workloadIdentityPools/<POOL>/providers/<PROVIDER> — note the project number, not ID.

Doc. GCP — Workload Identity Federation

Gotcha. Audience must use the project number, not the ID — common foot-gun. google.subject mapping is mandatory; some GCP APIs (e.g., legacy ones) still don't accept federated credentials and silently require SA JSON keys.

3.7 GitHub — App installation flow

Trust model. Org admin installs an App; GitHub mints short-lived per-installation tokens via the App's private key. No long-lived per-customer secret.

Customer-side action. Org owner clicks Install on the App's public install URL, picks All repositories or Only select repositories, reviews the requested fine-grained permissions, confirms.

Vendor side (once). Register the App, generating: App ID, RSA private key (PEM, download once), optional webhook secret, optional client ID/secret for user-to-server OAuth.

Per-installation. Receive installation_id (either via installation.created webhook payload or GET /app/installations); to act, sign a JWT (≤10 min lifetime) with the App private key, then POST /app/installations/{installation_id}/access_tokens — returns a token valid 1 hour, optionally narrowed via repositories/repository_ids (max 500) and a permissions subset.

Doc. GitHub — Generating an installation access token

Gotcha. The App private key is a global secret — leak it and every installation is compromised. Rotate via the App settings UI, which creates a second key while the first is still valid (only manual rotation, no per-installation key isolation). Rate limits scale with installation size (repos × org users), but a single misbehaving installation can still exhaust its quota.

3.8 Atlassian — Forge (with Forge Remote) is the current path; Connect is deprecated

Currency warning (verified 2026-05-19). Atlassian deprecated Atlassian Connect (announced 2025-03-17, three-phase plan). Phase 1: all new Marketplace apps must be built on Forge with no Connect modules. Phase 2 (2026-03-31): Connect apps can no longer be updated. Connect is supported until December 2026, then "use at your own risk" (no security patches). Recommending a brand-new Connect app in 2026 is not market-defensible — the Marketplace would reject it. The correct target is Forge.

Forge + Forge Remote (the 2026 pattern). Forge is Atlassian's managed app platform; admin installs from Marketplace, Atlassian mints scoped tokens to the app at runtime. The historical objection for a CSPM — "Forge runs inside Atlassian's cloud and restricts egress, so it can't ship bulk data to our backend" — is addressed by Forge Remote, which lets a Forge app call an SV0-controlled remote backend (declared in manifest.yml under permissions.external.fetch.backend) and supports data-residency realm pinning (region-pinned remote URLs). So we get the Forge install/trust model and keep our own backend.

Trust model. Admin installs the SV0 Forge app from Marketplace → Atlassian issues short-lived scoped tokens to the app; the Forge frontend calls our remote backend via Forge Remote, with our backend authenticating the Forge invocation token. No per-install shared secret to store; no global private key.

Doc. Atlassian — Connect end of support timeline · Forge Remote · Forge Remote data residency

Gotcha. Forge Remote is newer and the model is more constrained than Connect's free-form server — egress domains must be declared in the manifest, and the app's compute/fetch operations must be declared for data-residency scope. Migrating an existing Connect app to Forge is non-trivial (>1,000 Marketplace apps are mid-migration across the ecosystem). For SV0 this is a greenfield build, so we start on Forge directly and avoid the migration entirely.

3.9 Atlassian — alternatives (briefly)

For completeness; not recommended for our CSPM Cloud use case:

  • Atlassian Connect — the prior dominant pattern (Marketplace app + per-install sharedSecret + HS256 JWTs signed with a qsh claim). Deprecated (see §3.8). Only relevant now as a transitional path for a pre-existing Connect app, with a hard Dec-2026 sunset. Do not start here. The qsh-canonicalization gotcha (any query-string rewrite invalidates the JWT) and the lifecycle-webhook-as-rotation-control-plane risk both still apply if a transitional Connect app exists.
  • OAuth 2.0 3LO (three-legged) — user-delegated access bound to a specific human user's permissions. Refresh token rotates on every use; invalidated whenever the authorizing user changes password, leaves the org, or has permissions reduced. Connector silently breaks at a human-lifecycle event. Reserved for developer-tool integrations (PR-to-issue linking) where human attribution is desirable. Doc: developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps.
  • OAuth 2.0 Client Credentials (2LO) — Atlassian Cloud does not expose a generic 2LO endpoint for third-party integrators.
  • Personal API tokens — admin pastes email + api_token; HTTP Basic against *.atlassian.net. Tied to a real human's permissions — when that admin leaves, the connector dies. Atlassian force-expired all pre-Dec-2024 tokens between March and May 2026. Acceptable only as a quick-start fallback. Doc: support.atlassian.com — manage API tokens.

3.10 Atlassian — Data Center / Server (self-hosted)

For self-hosted Atlassian instances (Data Center is now the only self-hosted SKU since Server EOL Feb 2024):

  • Personal Access Tokens (Jira 8.14+, Confluence 7.9+) — bearer in Authorization: Bearer <pat>, per-user, inherits permissions, admin-configurable expiry. Modern recommended path. Doc: confluence.atlassian.com — Using Personal Access Tokens.
  • OAuth 1.0a via Application Links — legacy "official" pattern; RSA-SHA1 signed, painful key exchange, still common at large enterprises.
  • OAuth 2.0 — added in recent DC releases but adoption is patchy.
  • Basic auth — still works, strongly discouraged.

Gotcha. Many enterprise customers are still on unsupported Server versions; a connector must probe capability rather than assume PAT support.


4. Customer-side credential-exchange UX (the "no email" rule)

For connectors where a secret is unavoidable (ServiceNow OAuth client_secret, Snowflake key-pair, Okta API token, custom SaaS), the open question is how the secret physically gets from the customer's IT team into our infrastructure. Email, Slack, Signal, "send me the values" — all leak the secret into channels we don't control and can't audit.

The industry-standard answer is the tenant admin portal pattern:

  1. Customer admin logs into our portal at app.securityv0.com/admin/connections.
  2. Picks a connector type (e.g., "ServiceNow OAuth").
  3. Pastes the secret into a form field that is masked on input and never displayed back.
  4. The form POSTs over TLS directly to a /api/v1/admin/connector-credentials endpoint that writes straight to Key Vault — Mongo only ever sees the credentials_ref pointer.
  5. No SV0 staff sees the secret value at any point. The audit log records that a value was set and by whom, not the value itself.

This guarantee depends on a customer-authenticated admin doing the pasting — i.e. when the actor in step 1 is the customer's admin. During operator-mediated onboarding (today's design-partner reality, where SV0 operators create instances on the customer's behalf), there is no customer admin in the loop, so an operator may handle a secret directly and the no-staff-visibility property does not apply to that phase. Two ways to close the gap:

  • (a) a customer-authenticated one-time credential-intake link the operator sends; or
  • (b) restrict operator-mediated onboarding to federation connectors (no secret exchanged) and require the paste-flow for secret-bearing ones.

Federation connectors sidestep this entirely; it only bites for ServiceNow / Atlassian Data Center / generic SaaS.

Vendor precedent:

  • Datadog's cloud APIs (POST /api/v2/integration/aws/accounts, POST /api/v1/integration/azure, POST /api/v2/integration/gcp/accounts) are the closest public precedent for a versioned credentials-POST endpoint. Customers can register cloud integrations via IaC without touching the UI. Datadog's secretless_auth_enabled flag shows how to make the same endpoint accept either paste-a-secret or federation against the same schema.
  • Datadog's UI for Azure / ServiceNow is the canonical "customer pastes into masked form" UX — fields for tenant_id, client_id, client_secret, server-side write to their secret store.
  • Wiz's CloudFormation/Cloud-Shell pattern is the alternative: skip the paste entirely by making the customer run a vendor-authored script that provisions the federation server-side. This is the gold standard for federation-capable integrations but doesn't help for ServiceNow et al.

Design questions for SecurityV0:

  • Where in the platform UI does this form live? — Operations console (/operations/instances per ADR-027 Slice 2) is the natural fit; a customer-facing tenant-admin portal is a future option once we have customer admins (today operators add instances on customers' behalf).
  • Is the customer admin a WorkOS-authenticated user (Layer 2 per ADR-023)? Yes — but role-gating needs an additional tenant_admin role on top of super_admin / member.
  • Does the POST go through the platform API server (which then writes to KV) or directly to a KV proxy? — Through the API server, because we need to audit the operation (who-set-what-when) and the API server already has tenant-scoping middleware. The blast-radius argument for a KV proxy doesn't outweigh the cost of a new auth surface.
  • What about per-tenant rotation triggered by the customer? — A separate PUT on the same endpoint, same UI flow, using staged rotation: write the new secret as a new KV version, health-check it (a probe call against the source system), promote it atomically, then revoke/delete the old version. Do not zero the old value before writing the new one — a malformed or under-scoped new secret would otherwise cause an outage with no rollback.

5. Gap analysis against ADR-027 today

ADR-027 §(b) defines a CredentialBroker interface with four providers: env, op (rejected at runtime), azure_keyvault, aws_secretsmanager. Every provider assumes the credential VALUE exists and the broker's job is to fetch it. The federation patterns in §3 bypass this assumption entirely for first-class clouds:

Source systemADR-027 todayModern patternNet effect on broker
AWSazure_keyvault provider stores AWS access keys per tenantCross-account assume-role + external-ID (§3.1)credentials_ref becomes a config (account_id, role_arn, external_id) stored in Mongo; broker mints temp creds via sts:AssumeRole at scan time. No per-tenant secret in KV — but our platform still holds one AWS-caller credential (the identity that calls AssumeRole) unless we run OIDC federation. See §6.1.
Azure / EntraKV stores AZURE_CLIENT_SECRET per tenantMulti-tenant app + admin consent (§3.4)credentials_ref becomes { tenant_id, sp_object_id, granted_scopes, consent_at, consenting_admin } — there is a per-tenant artifact (the consented service principal), it's just not a secret. Our single multi-tenant app's client secret is our own platform secret (one, in KV, not per-tenant).
GCP(not in ADR-027's first slice)Workload Identity Federation (§3.6)credentials_ref becomes { project_id, service_account_email }. No SA key exchanged.
GitHub(not in ADR-027)GitHub App installation (§3.7)credentials_ref becomes { installation_id }. Tokens minted per-call via our App's private key (one platform-wide secret).
ServiceNowKV stores SERVICENOW_* per tenantOAuth client_credentials with customer-provisioned clientStays close to ADR-027's model. Customer pastes client_id/client_secret into admin portal → direct-to-KV.
Atlassian Cloud (Jira / Confluence)(not in ADR-027)Forge app + Forge Remote (§3.8); Connect deprecatedcredentials_ref becomes { atlassian_site_id / installation }. Forge mints scoped tokens; our remote backend authenticates the Forge invocation. New provider variant atlassian_forge. (Connect's per-install sharedSecret model only if a transitional Connect app exists.)
Atlassian Data Center(not in ADR-027)Personal Access Token (§3.10)Customer pastes PAT into admin portal → direct-to-KV. Stays close to ADR-027's model.
Snowflake / Okta / Workday / custom SaaSKV per tenantSame — usually no federation optionSame as ServiceNow.

Implication. The broker interface from ADR-027 is right (vendor-agnostic, per-tenant scoped). What changes is that for AWS / Azure / GCP / GitHub, the broker's implementation is "mint a token from our trust relationship," not "fetch a stored secret." New broker provider variants — aws_assume_role, azure_multi_tenant, gcp_wif, github_app — sit alongside the existing env and azure_keyvault providers.

Isolation caveat for the federation providers. ADR-027's "tenantId derives the namespace" rule keeps env/KV providers safe but does not carry over to federation — there is no prefix; the dangerous value is the binding (role ARN, entra_tenant_id, installation_id). The isolation mechanism for federation is owned by 15-connector-runtime-architecture.md §4: in short, the binding is selected by the verified tenant (re-derived from the DB chain, not the job payload) and the external trust is scoped customer-side (their role trusts our account only with their external-ID; their consent grants only their tenant). Bottom line: "tenantId enforces isolation" is true for env/KV and misleading for federation — spec it explicitly per provider.

Concrete deltas to ADR-027:

  1. Add federation providers to the CredentialBroker interface (§(b)). The resolve() signature already returns a Record<string, string> — the AWS provider returns {AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN} from STS; the GitHub provider returns an installation token; the Azure provider returns an OAuth access token. The connector subprocess contract is unchanged.
  2. Extend CredentialsRef to carry per-provider config: { provider: "aws_assume_role", config: { role_arn, external_id, region } }.
  3. ADR-027's tenant-isolation invariant (tenantId derives the lookup namespace, not the ref) still holds — for federation, "lookup" means "which STS role to assume" or "which Entra tenant to issue a token for," and the tenant-prefix-from-tenantId rule still applies.
  4. The azure_keyvault provider is not retired — it remains the storage for ServiceNow and other non-federated SaaS creds, and remains the platform's own-secret store.

What doesn't change in ADR-027:

  • The Slice 1 plan (env-broker only, ship the interface) is still the right first slice. Federation providers come after the interface lands; they're additional implementations of the same interface.
  • The pipeline-run root, deploy-gate, and operator-UX decisions are independent of credential model.
  • The runtime architecture in 15-connector-runtime-architecture.md §3 (per-scan sequence) is unchanged — the broker call site at execute_scan is the same.

6. Recommendation

A per-connector credential strategy, ordered by priority. Each item gets one option as primary recommendation; the alternatives are tracked in §7 (Next Action) so a decision-maker sees the path not taken.

6.1 AWS connector — cross-account assume-role + external-ID

  • Connector-side is already done. The AWS connector already accepts AWS_ROLE_ARN + AWS_EXTERNAL_ID and has an assume_role_into path (sv0_aws/config.py:82-83, sv0_aws/cli/main.py:1367). The remaining work is platform-side: stop putting per-tenant AWS access keys in KV, store the role binding as config, and have the broker mint STS creds at scan time. Frame this as "wire up what the connector can already do," not "build assume-role from scratch."
  • Customer creates an IAM role in their account; trust policy names our AWS account + an SV0-generated external ID. Onboarding artifact: an SV0-published CloudFormation template (single account) and a StackSet (org-wide). UI surfaces the role ARN field + the SV0-generated external ID (read-only display).
  • Two distinct bootstrap models — pick one, do not mix them (they need different customer-side trust policies and different STS APIs):
    • (A) Cross-account AssumeRole (recommended day one). SV0 holds one AWS principal (an IAM user or role) in our own AWS account. The customer's role trusts that account with the external-ID condition. We call sts:AssumeRole. This requires SV0 to hold one long-lived AWS credential — see "no per-tenant secret, but not zero secret" below. The customer-side trust policy is the standard third-party form (§3.1).
    • (B) OIDC federation, Entra → AWS (AssumeRoleWithWebIdentity). SV0 holds no AWS credential; the Azure VM's Managed Identity gets an Entra-issued token and exchanges it directly via AssumeRoleWithWebIdentity. This is not free or out-of-the-box: it requires (i) the customer to register an IAM OIDC identity provider in their account pointing at our Entra issuer URL, and (ii) us to prove the exact token shape (issuer, aud, sub claims) Entra emits is accepted by AWS's OIDC verification. The customer-side trust policy is different from (A). Treat (B) as "requires building and proving an OIDC bridge," a follow-up — not a day-one option.
  • "No per-tenant secret" — but not "no secret." Model (A) still needs one AWS-caller credential (the IAM user/role key) on the platform side, in kv-sv0-prod. The win over today is eliminating per-tenant AWS keys, not all AWS keys. That one platform credential needs a rotation schedule, blast-radius analysis (it can assume into every customer role), and an emergency-revocation procedure. Only model (B) achieves zero AWS secrets.
  • The real design choice is identity-bootstrap, not network. STS accepts a caller from anywhere, so Azure-hosting is no obstacle. But model (A) needs an AWS credential on the Azure VM, and model (B) needs the Entra→AWS OIDC bridge — pick based on that, not the network path.
  • What's stored per tenant: { account_id, role_arn, external_id } in Mongo connector_instances.credentials_ref. No per-tenant secret in KV.
  • Connector-side rework is required — flag this. Today the Entra connector expects a per-tenant client secret (entra-servicenow/config.py:25-27 reads AZURE_TENANT_ID + AZURE_CLIENT_ID + AZURE_CLIENT_SECRET, i.e. a customer-owned single-tenant app). Moving to a multi-tenant app means the connector authenticates as our app against the customer's tenant ID — a different code path. This is not a config swap; it's the one connector recommendation that needs connector changes, not just platform wiring.
  • Publish a single SV0 multi-tenant app registration in our Entra tenant. Customer tenant admin clicks an SV0-provided URL → admin consents → service principal materialised in their tenant with the requested Graph scopes.
  • Our platform code authenticates against the customer's tenant ID using OAuth2 client_credentials with our app's secret (one platform-wide secret in kv-sv0-prod, NOT per-tenant).
  • What's stored per tenant — full consent metadata, not just the tenant ID: { entra_tenant_id, sp_object_id, granted_app_roles, consent_at, consenting_admin_upn, consent_status }. The bare { entra_tenant_id } is credential-correct but authorization-incomplete — without the SP object ID and granted-scope set we can't detect drift (an admin narrowing our permissions), can't audit who consented, and can't tell "consented" from "revoked." None of this is a secret; it's authorization state.
  • Required Graph permissions must be pinned down before this is "adopted" (not just deferred): minimum scopes to extract User, Application, ServicePrincipal, DirectoryAudits reads. All application-permission, all *.Read.All — least-privilege is the read-only enforcement boundary (the connector code is not).
  • Customer revocation: they delete our enterprise app in their Azure portal (we have no API to force-remove it). We detect token-fetch failure → mark instance disconnected and surface it.

6.3 ServiceNow connector — OAuth client_credentials + tenant admin paste-into-portal

  • No federation option exists in ServiceNow. Customer creates an OAuth Application Registry record in their instance + a dedicated user with our required roles, then pastes instance_url, client_id, client_secret into our operator UI form.
  • Form POSTs to /api/v1/admin/connector-credentials → writes to kv-sv0-prod under tenants/{id}/connector/servicenow/* (per ADR-027 Slice 5 path shape). Mongo holds only the credentials_ref pointer.
  • Operator sees the customer pasted a value, never sees the value itself.
  • What's stored per tenant: the credential values in KV (Slice 5 path) + a credentials_ref in Mongo.

6.4 GitHub connector — SV0-published GitHub App

  • Register a single SV0 GitHub App with the minimum permissions our connector needs (metadata: read, contents: read, members: read, administration: read as applicable).
  • Customer org admin clicks Install on our App's URL → picks repos → consents → GitHub fires installation.created webhook to our /webhooks/github endpoint.
  • We mint installation tokens per scan via our App's private key.
  • What's stored per tenant: { installation_id }. Zero per-tenant secrets for this connector.
  • Single-point-of-failure caveat + containment plan. The App private key mints tokens for every installation across all customer orgs — one global secret, leak = total compromise of all GitHub access. The pattern is named in §3.7; the containment plan it requires: (a) keep the private key in kv-sv0-prod / HSM, never on disk; (b) split prod and staging into separate GitHub Apps with separate keys; (c) document a key-rotation cadence (GitHub lets a second key coexist with the first for zero-downtime rotation); (d) alert on anomalous token-mint volume; (e) keep App permissions minimal so a leaked token's blast radius is read-only. Atlassian Connect (§6.7) avoids this single global key by issuing a per-install shared secret — a relevant contrast, though Connect then makes the lifecycle webhook the control plane (§6.7 caveat).

6.5 GCP connector — Workload Identity Federation (when GCP enters scope)

  • Not in current scope per ADR-027's Priority Connectors table (GCP is "future"). When it lands, the recommended pattern is GCP WIF (§3.6): customer creates a workload identity pool + provider trusting our identity (an Entra-issued token from our VM), grants impersonation on a customer-side SA.
  • Same per-tenant shape as Entra: { project_id, service_account_email }. Zero per-tenant secrets.

6.6 Other SaaS (Snowflake, Okta, Workday, etc.) — paste-into-portal

  • Default to the same UX as ServiceNow (§6.3): customer pastes into the operator UI; direct-to-KV write; ref pointer in Mongo.
  • For each integration as it enters scope, check whether the vendor offers OAuth client_credentials (preferred), OAuth user-delegated (acceptable with refresh), or only static API keys (worst). Document the choice in that connector's per-integration doc.

6.7 Atlassian (Jira + Confluence) — Forge app + Forge Remote for Cloud; PAT for Data Center

Cloud (the common case) — build on Forge, not Connect. Connect is deprecated (§3.8); a new Connect Marketplace app would be rejected and would hit a Dec-2026 sunset. The 2026-correct design:

  • Publish an SV0 Forge app on the Marketplace. Atlassian site admin installs it; Atlassian mints short-lived scoped tokens to the app at runtime.
  • Use Forge Remote so the app calls our SV0-controlled backend (declare egress in manifest.yml permissions.external.fetch.backend; pin remote URLs by region for data residency). This keeps our own backend/storage while using the Forge install + trust model — the historical "Forge can't ship data out" objection is resolved by Forge Remote.
  • What's stored per tenant: the install/site identifier ({ atlassian_site_id / cloud_id, installation }) in Mongo as credentials_ref. No per-install shared secret to vault and no global private key — the Forge invocation token is what our backend verifies.
  • Required Marketplace scopes must be pinned down before "adopted" (least-privilege, read-only): minimum scopes to extract issue history, permission schemes, and audit events.
  • If a transitional Connect app is unavoidable (e.g., to ship before the Forge build lands), the per-install sharedSecret model from §3.9 applies, and the lifecycle webhook becomes the credential control plane and must be hardened: verify the lifecycle JWT (iss/aud/qsh), bind clientKey+baseUrl to an SV0-originated install, stage secret replacement (write-new/health-check/promote), and alert on unexpected reinstalls. Treat this as an explicitly time-boxed exception with the Dec-2026 sunset on the calendar.

Data Center (the enterprise self-hosted case):

  • Customer admin creates a Personal Access Token in their Atlassian DC instance and pastes {baseUrl, pat} into our operator UI (same form pattern as §6.3 ServiceNow; same staff-visibility caveat from §4).
  • Connector probes capability on first sync: if Authorization: Bearer fails, fall back to OAuth 2.0 if the DC instance exposes it; OAuth 1.0a Application Links if not; document basic-auth as not supported.
  • What's stored per tenant: { baseUrl, deployment: "data_center" } in Mongo credentials_ref; PAT in KV (staged rotation per §4).

Anti-pattern (do not adopt):

  • A new Connect app — deprecated; Marketplace will reject it; Dec-2026 sunset. Forge is the only forward path for Cloud.
  • 3LO (user-delegated OAuth) — breaks when the authorizing admin's password changes / they leave the org. Use only for developer-tool integrations where human attribution is required (none of ours).
  • Personal API tokens for Cloud — tied to one admin's lifecycle; Atlassian force-expired pre-Dec-2024 tokens in 2026. Quick-start fallback only.

6.8 Anti-recommendations

  • Do not ask customers to email or Slack credentials to us. Tracked as a hard rule for onboarding; goes in the runbook.
  • Do not store AWS access keys / Azure client_secrets / GCP SA JSON keys per tenant when a federation pattern is available. The provider has to be in the broker's provider: enum but real customer onboarding should not select it.
  • Do not build a customer-facing self-service portal in the next 6 months. Operator-mediated onboarding (operator clicks "create instance" in our UI; customer admin separately runs CloudFormation / clicks Install / pastes secret) is sufficient for design partners. A self-service portal is a 1-year follow-up tied to product GTM, not an ADR-027 concern.

7. Next Action

Status: research-complete

Decision needed from: Ivan (CTO) — direction confirmation; CEO sign-off not required (technical architecture).

Options:

  1. Adopt §6 as the connector-credential roadmap. Create one umbrella issue per connector for the federation migration; sequence them after ADR-027 Slice 1 lands. Concrete next moves:

    • File issue: "ADR-028: connector identity model (federation-first per connector)" — captures §6's decisions formally.
    • File issue: "AWS connector: switch to cross-account assume-role + external-ID"sv0-platform + sv0-connectors.
    • File issue: "Entra connector: publish multi-tenant Entra app for SV0"sv0-infrastructure (app registration), sv0-platform (consent callback handler).
    • File issue: "Tenant admin credential-paste API endpoint"sv0-platform. ServiceNow needs this on day one.
    • File issue: "GitHub connector: publish SV0 GitHub App"sv0-infrastructure, sv0-platform.
    • File issue: "Atlassian connector: SV0 Forge app + Forge Remote (Cloud) + PAT-paste (Data Center)"sv0-infrastructure (Marketplace Forge listing), sv0-platform (Forge Remote backend + PAT paste handler). Not Connect — deprecated, Dec-2026 sunset.
  2. Adopt §6 but defer ADR-028 until first paying customer. Just file the AWS-assume-role issue now (cheapest, biggest blast-radius reduction); revisit Entra and GitHub when the first customer needs them. Risk: every connector that lands in the meantime defaults to ADR-027's secret-bundle path, which we'd later migrate.

  3. Reject §6 and stay with ADR-027's secret-bundle model for all connectors. Defensible if (a) we expect ≤3 customers in the next 6 months and (b) we accept that the procurement conversation gets harder ("you store our AWS keys?"). Document as a deliberate trade-off.

Security-hardening items (from adversarial review) that the implementation ADR-028 must carry — not optional polish:

  • Tenant isolation, secret-exposure, and KV-RBAC limits are owned by 15-connector-runtime-architecture.md §2a + §4 — carry them into ADR-028 verbatim: re-derive tenant from the DB chain (never the payload); the flat-env provider is dev/demo only (not production); shared-MI KV RBAC is a path convention, not per-tenant isolation; federation isolation rests on verified-binding selection + customer-side trust scoping.
  • Least-privilege customer-side scopes are the real read-only boundary, not connector code — lint the onboarding templates (CloudFormation / Bicep / App manifests) to stay read-only.
  • Per-connector single-secret blast radius: the GitHub App private key and the platform AWS-caller credential are global secrets — each needs KV/HSM storage, rotation cadence, prod/staging split, and anomaly alerting.
  • Staged rotation everywhere (write-new, health-check, promote, revoke-old) — never zero-then-write.

Market-parity items (from market-standards review) — table stakes for an enterprise buyer / CISO conversation, not stretch goals:

  • Ship versioned, linted onboarding artifacts — don't just recommend them. Mature peers (Wiz/Orca/Prisma/Lacework/CrowdStrike) hand the customer a one-click/generated package. Release-gated deliverable list: AWS single-account CloudFormation + Organizations StackSet (org-wide auto-onboarding); Azure admin-consent URL + Bicep (app reg + Graph consent + RBAC + management-group scope); GCP Terraform/Cloud-Shell WIF bootstrap; GitHub Marketplace App install flow; Atlassian Forge Marketplace listing; ServiceNow paste-form. Each artifact CI-linted to fail if it requests write scopes outside an explicit remediation path.
  • Per-connector minimum-permission manifests. "Connector code is read-only" is not a procurement answer; the customer-side permission ceiling is. Pin exact least-privilege scope sets per connector (Graph *.Read.All set, GitHub read permission set, Atlassian Forge scopes, ServiceNow roles, AWS SecurityAudit + any read-only additions) before any connector is called "adopted," and lint the onboarding artifact against them.
  • Incident-response runbooks for the global secrets. The GitHub App private key and the platform AWS-caller credential are global minting authorities. Each needs a documented runbook: KV/HSM key protection posture, dual-key rotation cadence, prod/staging app separation, token-mint anomaly alerting, customer-notification clock, emergency mass-revocation drill (quarterly tabletop evidence for SOC 2). Lives in docs/runbooks/, referenced from ADR-028.
  • SOC 2 / ISO control mapping. Map the credential model to SOC 2 CC6.1/CC6.6 and ISO 27001:2022 A.5.15/A.5.17/A.5.18/A.8.15 — secrets management, least privilege, separation of duties (who can read a tenant's secret), rotation/change control, access logging. The flat-env Slice 1 fails several of these; that's why it's non-production (see 15-connector-runtime-architecture.md §2a).

GitHub Issues: not yet created. Listed above under Option 1.


8. References

Vendor onboarding docs

Foundational cloud-provider docs

Standards & frameworks

SecurityV0 internal


9. Appendix — standards mapping

Per-connector mapping of the recommended pattern to the external standard / best-current-practice it satisfies. Buyer security questionnaires (CAIQ/SIG) and auditors expect this mapping; pin it before any connector is "adopted."

ConnectorRecommended patternStandard / BCP it satisfiesKey control to evidence
AWSCross-account role + external-ID (OIDC bridge later)AWS Well-Architected SEC03-BP09; confused-deputy mitigation via sts:ExternalId; RFC 9700 (temporary, scoped creds)External-ID generated by SV0 + unique per account; SecurityAudit read-only ceiling; STS session ≤1h
Azure / EntraMulti-tenant app + admin consentAzure WAF security (minimize secrets, prefer identity); OIDC issuer validation (tid/iss)Validate iss against per-tenant tid on every token; *.Read.All least-privilege Graph set; consent metadata stored
GCPWorkload Identity FederationGoogle Cloud "WIF preferred over SA keys"; RFC 8693 token exchangeNo SA JSON key; WIF attribute-condition pins our identity; impersonation scoped to one SA
GitHubGitHub AppGitHub's own "Apps over OAuth Apps/PATs"; short-lived scoped tokensApp private key in KV/HSM; 1h installation tokens; read permission set; prod/staging app split
Atlassian CloudForge app + Forge RemoteAtlassian platform direction (Connect deprecated); data-residency realm pinningForge invocation token verified at our backend; egress declared in manifest; region-pinned remotes
ServiceNowOAuth 2.0 client_credentialsRFC 6749 client_credentials; RFC 9700 (consider sender-constrained / mTLS where the instance supports it)Customer-provisioned OAuth client; paste-direct-to-KV; staged rotation; dedicated read-only role
AllTenant admin paste / federationRFC 9700 issuer + state validation on any consent callback; NIST SP 800-204 service-to-service identity if the broker becomes distributedOAuth state + issuer validation on callbacks; audit log on every credential resolve

Gaps to close before "adopted" (not just deferred): explicit OAuth state/issuer validation on the Entra and Atlassian consent callbacks; a sender-constrained-token (mTLS) assessment for ServiceNow where the instance supports it; and a NIST SP 800-204-style service-to-service identity plan if/when the credential broker fans out beyond one worker (ties to ADR-027 §Plan-D).