Repository navigation
FIX: Make auth mode an explicit choice instead of an inferred one - #3010
hannahwestra25 wants to merge 7 commits into
Conversation
| auth_mode (AuthMode): ``"identity"`` authenticates with a Microsoft Entra ID token and ignores | ||
| ``api_key`` and its environment variable entirely. ``"api_key"`` (the default) keeps the | ||
| historical resolution order: token-provider callable, explicit key, environment variable, | ||
| then an Entra ID fallback for recognized Azure endpoints. |
There was a problem hiding this comment.
I recommend removing the legacy fallback for simplicity. I do not think the compatibility ambiguity is worth it: api_key should require a key or an explicitly supplied provider, and identity should ignore keys and use identity. Please remove automatic identity fallback from api_key mode across the affected targets, rather than add an auto or omitted-mode compatibility path. I would also remove the service's _accepts_auth_mode fallback: every target that advertises identity support should accept the explicit mode, and a target that cannot meet that contract should fail clearly. Please document the compatibility change. (Drafted with GitHub Copilot.)
There was a problem hiding this comment.
Agreed on the end state, but it's a silent breaking change: OpenAIChatTarget(endpoint=<azure>) + az login works and is documented today.
Two notes: no auto/omitted-mode path was added — that fallback is pre-existing and unreordered. And _accepts_auth_mode exists solely because AzureBlobStorageTarget lacks auth_mode; it deletes itself once #2846 lands.
Filed #3029 for the deprecation path. If you'd rather take the break now in this PR, say so — it's a small change.
There was a problem hiding this comment.
Yes, please take the compatibility break now in this PR rather than use the deprecation path in #3029. My recommendation remains simplicity: I do not think the compatibility ambiguity is worth it. api_key should require a key or an explicitly supplied provider and fail clearly when neither is available; identity should ignore keys and use identity. Please apply that rule to OpenAI, Azure ML, Prompt Shield, and Blob Storage, without an auto or omitted-mode compatibility path, and document the change and the auth_mode="identity" migration.
I checked the current head (19f2f8031). The conflicting-input fix is addressed, Blob Storage now preserves identity intent and bypasses SAS credentials, and _accepts_auth_mode is gone. The remaining item in this thread is removal of the implicit identity fallback. I understand that the fallback predates this PR; the recommendation is to remove it now, not to treat it as a newly introduced defect. Code changes and merging remain with you.
(Drafted with GitHub Copilot.)
Selecting identity-based authentication was signalled by deleting the api_key, but "no api_key" is ambiguous: every auth resolver interprets it as "read the key from the environment variable", so an explicit identity choice was silently downgraded to api-key auth whenever the env var was set. Thread an explicit auth_mode through resolve_openai_auth, OpenAITarget, AzureMLChatTarget and PromptShieldTarget. auth_mode defaults to "api_key", so the existing callable -> explicit key -> env var -> Entra fallback chain is unchanged; only an explicit auth_mode="identity" short-circuits to a token provider. Identity still refuses to mint tokens for unrecognized hosts and now raises a clear ValueError instead. AuthMode moves to pyrit/common/auth_mode.py so pyrit.auth can reference it without depending on the target layer; it is re-exported from pyrit.prompt_target.common.prompt_target for backward compatibility. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
auth_mode is also a constructor parameter, so the registry accepted it inside params as a second, competing channel. A request with auth_mode="api_key" and params["auth_mode"]="identity" selected identity, silently ignoring a supplied api_key and bypassing the service's supported_auth_modes check; the opposite conflict was silently resolved in favor of the top-level value. Reject conflicting values and forward the request-level auth_mode for both modes so it is authoritative in either direction. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The configuration guide described Entra auth only as the implicit fallback. Document the explicit mode, the resolution order it bypasses, and the targets that accept it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
PR microsoft#2846 landed the AzureBlobStorageTarget half of this bug using a get_auth_mode_parameters classmethod. Adopt that hook as the single mechanism for carrying auth intent into target construction and drop the _accepts_auth_mode signature introspection, which only existed because AzureBlobStorageTarget lacked the parameter. Every target advertising identity support now overrides the hook, guarded by a registry-wide contract test so a future identity target cannot silently fall back to inferring auth from a missing key. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
c312db1 to
9e51bbd
Compare
AUTH_MODES is typed tuple[AuthMode, ...], so the membership check already narrows auth_mode to AuthMode and the cast tripped ty's redundant-cast rule. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…icit Per review feedback, drop the "no key found and the endpoint looks like Azure, so mint a token" fallback from the OpenAI resolver and the two inlined copies in AzureMLChatTarget and PromptShieldTarget. That fallback is what made an explicit auth_mode="identity" indistinguishable from "no key configured", so the two paths could not be told apart. api_key mode now requires a key or an explicit token provider and fails with an error that names the env var, api_key, and auth_mode="identity". identity mode ignores keys entirely. Also threads auth_mode through OpenAITextEmbedding, which is a fourth consumer of resolve_openai_auth and would otherwise have lost its only ergonomic path to identity auth. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Description
Selecting identity-based authentication did not actually force identity auth.
pyrit/backend/services/target_service.pysignalled identity by deleting the api_key:But "no api_key passed" is ambiguous — it can mean "the user chose identity" or "read the key from the environment variable". Every auth resolver resolves that ambiguity toward the env var, so the user's explicit choice was silently downgraded to api-key auth whenever a key happened to be present in
.env.Reproduction (on
main, prints the env key)On this branch the same three cases now behave correctly:
This is not a regression — it's two features that don't compose
pyrit/auth/openai_auth.pywas introduced in #2235 purely as a fallback: when no key exists for a recognized Azure endpoint, mint an Entra token rather than fail. That was correct for its purpose. The gap only appeared once the backend let users explicitly choose identity: a fallback mechanism has no way to distinguish "no key available" from "key available but deliberately not wanted."Affected paths
pyrit/auth/openai_auth.py::resolve_openai_auth— theif api_key_value: return api_key_valueenv check ran before theis_azure_openai_endpointEntra fallbackOpenAITextEmbeddingAzureMLChatTargetis_azure_ml_endpointPromptShieldTargetis_azure_openai_endpointThe fix
Make auth intent explicit rather than inferred, in two parts.
1.
auth_modeis threaded down to the resolvers, so identity skips the key and env var entirely:auth_modeis plumbed throughopenai_target.py,azure_ml_chat_target.py,prompt_shield_target.pyandopenai_text_embedding.py, andtarget_service.create_target_asyncpasses it explicitly instead of relying solely on poppingapi_key.2.⚠️ The implicit Entra fallback is removed (see Breaking change below). Fixing (1) alone leaves identity and "no key configured" still resolving down the same path, which is the root of the ambiguity.
Import cycle
AuthModewas defined atpyrit/prompt_target/common/prompt_target.py, butpyrit/auth/may only import frompyrit.authandpyrit.common(prompt_targetdepends onauth, not the reverse). The canonical definition moves to the new neutralpyrit/common/auth_mode.pyand is re-exported frompyrit.prompt_target.common.prompt_target, so every existing import keeps working. Verified with a cleanpython -c "import pyrit.prompt_target".pyrit/cli/_auth.py::AuthModeis a different Literal ("auto" | "azure_cli" | "device_code" | "none") and is untouched.Previously, when
api_keymode found no key and the endpoint looked like an Azure host, the resolvers silently minted an Entra token. That fallback is deleted.Why it had to go. It is exactly what makes an explicit
auth_mode="identity"indistinguishable from "no key configured" — the two requests converge on the same resolution path, so the resolver cannot honor the explicit one. Keeping it would mean keeping a third, unnamed auth state ("infer from what happens to be in the environment") alongside the two named ones.New contract:
auth_mode="api_key"(the default) requires a key: a token-provider callable passed asapi_key, an explicitapi_keystring, or the target's API key environment variable. If none yields a key it raisesValueErrorrather than guessing.auth_mode="identity"ignores keys and the environment variable entirely, and raisesValueErrorfor an endpoint that is not a recognized Azure host — it never mints a token for an unknown host.Who is affected. Only callers who relied on
OpenAIChatTarget(endpoint=<azure>)+az loginwith no key configured. The migration is one keyword argument, and the error message names it:A dedicated test (
test_api_key_mode_error_names_the_identity_migration) pins that wording so the migration hint cannot silently regress.The blast radius is narrower than it looks. Every Entra example in the repo already passes an explicit token provider rather than relying on the fallback —
doc/code/targets/1_openai_chat_target.py,2_openai_responses_target.py,realtime_target.py,prompt_shield_target.py,doc/code/scoring/2_float_scale_scorers.py,doc/code/setup/1_configuration.py— as doesTargetInitializer(pyrit/setup/initializers/targets.py). The implicit path was never the documented one. This supersedes the deprecation cycle previously tracked in #3029.OpenAITextEmbeddingis included deliberately. It is a fourth consumer ofresolve_openai_authand would otherwise have lost identity auth with no replacement other than hand-constructing a provider, so it gainsauth_modealongside the targets. This keeps the resolver contract uniform across all of its callers.Relationship to #2846 (merged)
#2846 has landed on
main(799ea49b2) and this branch is rebased onto it.#2846 fixed the
AzureBlobStorageTargethalf of this bug — ABS's credential issas_token, sopop("api_key")was a no-op — by adding aget_auth_mode_parametersclassmethod hook that lets a target declare the constructor parameters implied by a chosen auth mode, plusidentity_conflictingparameter metadata so the service can strip any flagged credential rather than a hardcodedapi_key.It did not fix the resolver half.
pyrit/auth/openai_auth.pyis absent from its diff, its baseget_auth_mode_parametersreturns{}, and onlyAzureBlobStorageTargetoverrode it — so the reproduction above still returned the env key on799ea49b2.Rather than leave two competing mechanisms in the tree, this PR adopts #2846's hook as the single channel and deletes the
_accepts_auth_modecapability probe this branch previously used.Tests
tests/unit/auth/test_openai_auth.pyplus identity tests in the AzureML, PromptShield, OpenAI-target-auth, embedding and target-service suites:auth_mode="identity"resolves to a token provider, not the env key — covered for all four resolver paths.auth_mode="api_key"and the default preserve the key chain exactly (callable precedence, explicit-key precedence, env var).ValueError.api_keymode with no key available raises, andget_azure_openai_authis asserted not called — the fallback cannot creep back in.target_service: anauth_mode="identity"create request produces a target that does not use the env key (OpenAI and AzureML), and anapi_keyrequest still honors the env var.test_identity_targets_accept_an_explicit_auth_mode) asserts that every registered target advertising"identity"overridesget_auth_mode_parameters, so a future identity-capable target cannot silently reintroduce the bug. Verified non-vacuous by temporarily removing an override and confirming the test fails.Ten existing tests that encoded the old fallback behavior were rewritten to assert the new contract (e.g.
test_no_key_recognized_azure_endpoint_auto_mints_entra→..._raises). These are intentional contract updates, not coverage removal — each asserts the replacement behavior at the same call site.Review follow-ups
params.auth_modeconflicts (390c1f9). Becauseauth_modeis now a constructor parameter, the registry also accepted it insideparamsas a second, competing channel. A request with top-levelauth_mode="api_key"andparams["auth_mode"]="identity"selected identity, silently ignoring a suppliedapi_keyand bypassing the service'ssupported_auth_modesgate (that check only ran inside the identity branch). Conflicting values are now rejected, and the request-levelauth_modeis forwarded for both modes so it is authoritative in either direction.This check is deliberately retained on top of #2846: that PR applies the hook with
params.update(...), which overwrites a smuggledparams["auth_mode"]rather than rejecting it, so the conflicting request would otherwise still be accepted silently.Docs (c312db1, da72cf5).
doc/code/setup/1_configurationdocuments the explicitauth_mode="identity", the resolution order, and a migration note covering the removed fallback. Both the.pyand.ipynbwere updated and verified in sync. Stale "Entra ID authentication is used automatically" docstrings onOpenAITargetandOpenAITextEmbeddingare corrected.tyredundant cast (19f2f80). TypingAUTH_MODESastuple[AuthMode, ...]letstynarrow through the membership check, making thecast("AuthMode", ...)in_get_supported_auth_modesredundant and failing thety (type check)hook. The cast and its now-unusedtyping.castimport are removed; the narrowing is equivalent.Remaining loose end. #2846 types ABS's parameter as
AuthMode | None = Nonewhile this PR usesAuthMode = "api_key". Both spell the "infer from credentials" third state differently. Now that the implicit fallback is gone on the resolver side, ABS can be unified to the same spelling — left out of this PR because it would reverse a just-merged design decision without the reviewer's sign-off, and is better done as a focused follow-up.Validation
Local commands run on the final branch (
da72cf550, on top of799ea49b2):pytest tests/unit/auth/ tests/unit/backend/test_target_service.py tests/unit/prompt_target/ tests/unit/registry/test_target_registry.py -qpytest tests/unit/auth/test_openai_auth.py tests/unit/prompt_target/target/test_openai_chat_target.py tests/unit/prompt_target/target/test_openai_target_auth.py tests/unit/prompt_target/target/test_azure_ml_chat_target.py tests/unit/prompt_target/target/test_prompt_shield_target.py -qpytest tests/unit/embedding -qruff check pyrit tests/unitty check pyritpython build_scripts/validate_docs.pydoc/code/setup/1_configuration.pyand.ipynbin syncpython -c "import pyrit.prompt_target"Full-suite validation is from CI, which is green on
da72cf550across every job (all platforms × Python 3.11–3.14,devanddev_all, plus coverage, docs and frontend). The local full-suite run was not used as evidence: this environment is missing several optional dependencies (mcp,httpx2,setuptools,feedgen), so six modules fail to collect locally onmainas well — a pre-existing environment gap, not a property of this branch. CI installs the full--extra allset and exercises those modules.A previously reported local failure,
tests/unit/backend/test_scenario_run_routes.py::TestResumeScenarioRunRoute::test_resume_has_no_get_preflight, was confirmed pre-existing: it touches no auth or target code, passes in isolation, and reproduces identically in a detached worktree at cleanmain(799ea49b2) with none of this branch's changes present.