fix(#191): validate credential-template provider so an unknown value can't silently inject nothing - #199
Conversation
…n set
A credential template accepted any string as `provider`. Only a handful of
literals are ever matched when credentials are resolved (`aws`, `ibm`, `gcp`,
`azure`, `ssh`), so a natural misspelling like `provider="ibmcloud"` — which
matches every adjacent field name (`ibmcloud_api_key`, `ibmcloud_resource_group`)
— was stored happily, read back looking healthy, and then matched NO branch in
the resolver. The template silently contributed nothing, the deploy fell through
to global `.env` credentials that weren't there, and the failure surfaced far
away as an opaque Terraform "BearerToken property is required" error.
Root cause: `provider` was never validated at create/update, and the canonical
set it must belong to was implicit, scattered across the resolver's `if
template.provider == ...` branches.
Fix:
- Add `SUPPORTED_PROVIDERS = {aws, gcp, azure, ibm, ssh}` as the single source
of truth in credential_template_service.py, documented against each consumer
that injects/resolves credentials for that provider.
- Validate `provider` in `create_template` and `update_template` (service layer,
covers every caller) -> BadRequestError with an enumerated message.
- Add matching Pydantic validation on the create/update route models so the API
boundary returns a clean 422 naming the bad value and the supported set,
before the service is reached. Update-time validation only fires when the
caller is actually changing `provider`.
Tests lock: the exact `ibmcloud` misspelling is rejected at both the service
(400) and route (422) layers; every canonical provider is still accepted; an
update can't switch a template onto a no-op provider; and a resolver-contract
test documents that a pre-existing `ibmcloud` row injects nothing (the behavior
the validation now prevents from being created). Mutation-tested: reverting the
guards reds 5 tests.
Closes #191
Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
Self-review M3: the SUPPORTED_PROVIDERS comment overstated azure/gcp as general 'credential resolution' — they inject only a post-provision kubeconfig token (AKS via engine_router, GKE via get_gcp_service_account_info), not terraform-env credentials. Only aws/ibm inject into the terraform env, so #191's 'looks healthy, injects nothing' class fully closes for aws/ibm; azure/gcp are still validated but that terraform-env class never applied to them. Comment-only; no behavior change. Claude-Session: https://claude.ai/code/session_01UpRYiFserdBE5ESHn759N4
Self-review (cold, adversarial) — verdict: no blocker, no majorAn independent cold auditor reviewed this PR against issue #191, executing the code rather than eyeballing. Full affected suite: 108 passed; the guard tests were mutation-checked (neutralize Held under attack:
Findings (all minor/informational):
Net: the fix is sound and non-vacuous; the one actionable item (M3 comment) is fixed. |
Review discipline pass — verdict: REVISETwo independent cold audits (clean context, no prior review threads) plus an invariant sweep. Everything below was verified by execution, not inspection. Reviewed at What holds up
Major1. Constrain the field type instead of validating it in a A model-level To be clear about scope: this PR does not introduce that mechanism — the pre-existing IBM-key-required raise at Typing the field closes it — verified: the error becomes field-scoped ( 2. INV-3: the constraint never reaches the API contract. The repo's own type-generation strategy maps
Consequences: Findings 1 and 2 share one fix, which deletes lines rather than adding them: # CredentialTemplateBase
provider: Literal["aws", "azure", "gcp", "ibm", "ssh"]
# CredentialTemplateUpdate
provider: Literal["aws", "azure", "gcp", "ibm", "ssh"] | None = Nonethen regenerate Minor3. The comment at if "provider" in update_data:
validate_provider(update_data["provider"])Worth noting the published contract advertises null as valid here: 4. The rule is expressed three times and the messages have already diverged. 5. Issue #191's own row is neither repaired nor surfaced. No migration, backfill, or detection. A stored 6. Vacuous assertion. Nits
Out of scope — worth its own issue
Verdict REVISE, not BLOCK. The fix is correct, the tests are real, and #191's create path genuinely closes. Findings 1 and 2 collapse into a single type change that is already this codebase's idiom. |
…ak + wire the enum into the contract
bonnyr-f5 round-2 (REVISE). Findings 1+2 share one fix; +minor 3.
Findings 1 & 2 - replace the provider membership check in the route
@model_validator with a Literal field type on both the create and update
models:
CredentialTemplateBase.provider: Literal["aws","azure","gcp","ibm","ssh"]
CredentialTemplateUpdate.provider: Literal[...] | None = None
A model-level ValueError made Pydantic attach the ENTIRE request body to the
422 (no RequestValidationError handler in main.py), leaking the plaintext
credential to the MCP client / FE toast. A Literal yields a field-scoped error
(no body echo) and maps to an OpenAPI enum (INV-3). Mirrors the auth_type
idiom at routes/f5_devices.py:73/82. The service-level validate_provider /
SUPPORTED_PROVIDERS stays as defense for direct callers; the two lists agree
exactly. Regenerated backend/openapi.json (provider now type:string + enum on
both models) and frontend-v2 api-generated.ts (provider is the union).
Finding 3 - type TEMPLATE_PROVIDER_OPTIONS against the generated provider
union (as const satisfies) so an invalid/divergent entry is a compile error.
ssh stays intentionally omitted from the create UI but is now permitted by the
type.
Minor 3 - PUT {"provider": null} returned HTTP 500: model_dump(exclude_unset=
True) includes {"provider": None}, and the generic assignment loop wrote None
into the nullable=False column. Guard now checks presence, dropping an explicit
null so the provider is left unchanged; fixed the inaccurate comment.
Finding 6 - de-vacuumed the misspelled-provider assertion (assert on azure/ssh,
not the "ibm" substring of the offender) and turned it into the body-leak
reproduction (asserts the plaintext secret is absent from the error). Added a
PUT-null regression test. Mutation-checked both new assertions.
Verified: 109 passed (affected suite); ruff clean; generate-openapi.py --check
OK; FE types regen idempotent; tsc --noEmit clean.
Claude-Session: https://claude.ai/code/session_01UCsZXDxBsWV2s4kT47DwDW
Round-2 response @
|
CI status noteThe code review above stands. The only red CI on this PR is the two repo-wide P4 security gates:
Both are environmental and repo-wide, not caused by this PR's code: the advisory/vuln DBs updated after staging last audited clean on 2026-08-24, so every open PR (and staging itself, if re-run) is red on them. Both are fixed in #215 (a documented All P1/P2/P3 gates are green. Awaiting further review. |
Summary
POST/PUT /api/credential-templatesaccepted any string asprovider. Credential resolution only ever matches a small set of literals (aws,ibm,gcp,azure,ssh), so a natural misspelling likeprovider="ibmcloud"was stored, read back looking healthy, and then matched no branch in the resolver — the template silently injected nothing and the deploy fell through to global.envcreds. The failure surfaced far away as an opaque Terraform"BearerToken property is required"error.Root cause
providerwas never validated at create/update, and the canonical set it must belong to was implicit — scattered across the resolver'sif template.provider == ...branches.Fix
SUPPORTED_PROVIDERS = {aws, gcp, azure, ibm, ssh}as the single source of truth incredential_template_service.py, documented against each consumer that injects/resolves credentials (AWS/IBM env injection, GCP SA JSON, Azure engine_router, SSH tunnel). This is exactly the four cloud providers the UI offers plus the legacysshprovider.providerincreate_templateandupdate_template(service layer — covers every caller) →BadRequestError(400) with an enumerated message.provideris actually being changed.What the tests lock
ibmcloudmisspelling from the report is rejected at both the service (400) and route (422) layers, with an enumerated message.aws,gcp,azure,ibm,ssh) is still accepted.provideron update leaves it untouched.ibmcloudrow injects no IBM credentials — the exact silent no-op the validation now prevents from being created.Verification:
pyteston the three affected test files → 108 passed;ruff checkon all changed files → clean.Closes #191