Skip to content

chore(api): drop dead revoke binding and placeholders, resolve the Azure account once, validate registration email - #1285

Draft
IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-311
Draft

IAmJSD wants to merge 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-311

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Issue #311 bundles four low-severity internal/api findings from the 2026-09-02 audit. I re-checked each one against current main (a14dc8f2). All four still reproduced. This PR fixes three and part of the fourth. The rest of the fourth is already done in an open PR.

Refs #311

Per-finding status

Finding What Status
A01-015 (a) findDuplicatePendingExecution has no production caller; only its test keeps it Deferred to open #1217 (fix/issue-760), which already deletes the function and TestFindDuplicatePendingExecution, and moves the missing cases onto matchDuplicateInList. Doing it here would edit the same lines in handler_purchases.go and handler_purchases_guards_test.go.
A01-015 (b) _ = updated // kept for future use in revokeViaSession Fixed. updated, err := is now _, err := and the dead line is gone. The doc comment also wrongly said CancelledBy is written by "a follow-up SavePurchaseExecution on the freshly-returned row". It now says what the code does: a narrow SetCancelledBy UPDATE keyed on the execution ID.
A01-022 Two bare t.Skip revoke+4-eyes placeholders (TestRevoke_4EyesMode_TODO, TestRevokePurchase_FourEyesApproval) Fixed by deleting the placeholders (the issue's second option). Neither revoke path calls enforceFourEyesPolicy, and RequireDifferentApprover gates approvals only. revokeViaSession's doc comment now says this and calls it an open owner decision. Nothing about revoke behavior changes.
A01-020 The Azure exchange execute path calls GetCloudAccountByExternalID up to three times (scope check, client build, constraint AccountIDs), and each call handles "no account" differently Fixed. authorizeAzureExchangeExecution looks the account up once and passes that row to requireAzureAccountScope, azureExchangeClientForAccount and azureExchangeAccountID.
A02-025 POST /api/register accepts any 5+ character contact_email containing @ Fixed. The new registrationContactEmail parses the value with mail.ParseAddress, applies validateEmailFormat to the bare address, and stores that bare address.

Root cause and fix

A01-020. authorizeAzureExchangeExecution was built from three helpers, and each one looked up the subscription's account itself. A scoped session made two lookups even with the test factory (scope check and AccountIDs), and production made three. Because they were separate reads, a concurrent re-registration between them could let the scope check approve one account while the constraint check ran against another. The fix:

  • lookupAzureAccount runs once at the top of authorizeAzureExchangeExecution.
  • requireAzureAccountScope / azureAccountInScope apply the same errNotFound rule as before to that row. Unregistered and out-of-scope accounts stay indistinguishable.
  • azureExchangeClientForAccount builds credentials from that row. buildAzureExchangeClient is now a lookup followed by this, so listExchangeableAzureRIs and getAzureCompatibleOfferings behave as before.
  • azureExchangeAccountID (a pure function replacing resolveAzureExchangeAccountID) maps a nil account to unattributedAccountConstraint as before.

Order and outcomes are unchanged. The scope check still runs before any credential resolution. A scoped caller still gets errNotFound for an unregistered or out-of-scope subscription. An unrestricted caller still gets the 404 "no Azure account registered" in production, and unattributedAccountConstraint with the injected factory. One small difference: a store error on the lookup now surfaces before a getAccountScope error. Both are 500s.

A02-025. validateRegistrationRequest checked strings.Contains(email, "@") && len >= 5. It now calls registrationContactEmail, which:

  • refuses anything mail.ParseAddress refuses, such as a CRLF followed by a header, or a second address;
  • refuses a bare address that fails validateEmailFormat. That covers no TLD, and a quoted local part like "a b"@example.com, which unquotes to a b@example.com and would not parse again;
  • stores addr.Address. So Jane Doe <jane@example.com>, user@example.com and user@example.com (ops) are stored and sent to the admin notification as the bare address.

The error message is unchanged (contact_email must be a valid email address). Header injection was already unreachable, as the issue says. This narrows what can be stored, including the input to #111's federation-tfvars scenario.

No DB, migration or concurrency change: there is no new write, and the Azure change removes two reads.

Verification

All local on Linux, with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true. This is unit/mock evidence only: no Postgres, no cloud calls, no email.

New tests:

  • TestExecuteAzureExchange_ResolvesAccountOnce (handler_ri_exchange_azure_test.go): a scoped, in-scope session. The store returns the caller's account on the first lookup and another account on any later one. The test asserts exactly one lookup and constraint AccountIDs == ["acct-mine"].
  • TestValidateRegistrationRequest_ContactEmail (handler_registrations_validate_test.go): 4 accept cases with the stored bare address, and 8 reject cases (CRLF+Bcc twice, two addresses, no TLD, no local part, no @, quoted local part, display name only).
  • TestSubmitRegistration_StoresBareContactEmail and TestSubmitRegistration_RejectsHeaderInjectionContactEmail (handler_registrations_submit_test.go): through the handler, the stored row and the admin notification carry the bare address, and a CRLF value is refused before any insert or email.

Fail-before: I put main's handler_ri_exchange.go and handler_registrations.go back with the new tests in place, then restored the fix.

TestExecuteAzureExchange_ResolvesAccountOnce: expected 1 lookup, actual 2; AccountIDs expected ["acct-mine"], actual ["acct-other"]
TestValidateRegistrationRequest_ContactEmail: 9 of 12 subtests FAIL (3 stored values not bare; 6 rejects returned nil, incl. "a@b\r\nBcc: x@y", "@example.com", "user@hostonly")
TestSubmitRegistration_StoresBareContactEmail: stored/notified "Jane Doe <jane@example.com>"
TestSubmitRegistration_RejectsHeaderInjectionContactEmail: want a 400 client error, got <nil>

Pass-after: all four pass.

Commands run on this branch:

go build ./... && go vet ./... && go vet -tags integration ./...      # OK
go test ./internal/api/ -count=1                                       # ok
go test ./internal/mocks/ -count=1                                     # ok
golangci-lint (v2.10.1) run --allow-parallel-runners ./internal/api/...  # 0 issues
gocyclo -over 10 -ignore "_test\.go" internal/api/handler_purchases.go internal/api/handler_ri_exchange.go internal/api/handler_registrations.go  # none

(authorizeAzureExchangeExecution 8, validateRegistrationRequest 8, registrationContactEmail 3.)

Combined-tree verification. This branch was tested together with the other queued branches on a local, never-pushed tree: queue/platform @ 936996ad = origin/main a14dc8f2 plus these heads, merged in this order:

Branch Tested head
fix/issue-337 ff3420c
fix/issue-169 7d75eb5
fix/issue-342 66b221f
fix/issue-310 5e11c21
fix/issue-326 5f0ea59
fix/issue-243 365e959
fix/issue-343 6c94036
fix/issue-312 d2848a8
fix/issue-316 acd60dd
fix/issue-339 75ea9b9
fix/issue-339-commitment-type 73bdb19
fix/issue-353 5d25fcd
fix/issue-313 3156bc4
fix/issue-357 53d9e58
fix/issue-368 0ba727d
fix/issue-419 61648c9
fix/issue-111 b04c372
fix/issue-686 0ad90b7
fix/issue-674 793095d
fix/issue-1055 4ec96f1
fix/issue-559 dfa9525
fix/issue-558 034eed4
fix/issue-874 1720539
fix/issue-666 1e120da
fix/issue-668 fb26345
fix/issue-568 ce04173
fix/issue-311 e54f250
fix/issue-177 0cbb88a
fix/issue-151 c21377c
fix/issue-166 41fb77b
fix/issue-27 2d67622
fix/issue-323 69288ae
fix/issue-59 36463e8
fix/issue-43 35e4069
fix/issue-158 6f76f8a
fix/issue-104 bb00491
fix/issue-55 971ef1b
fix/issue-361 89ac220
fix/issue-120 b458719
fix/issue-36 42bac6e
fix/issue-525 57c97dd
fix/issue-712 e5bcda0
fix/issue-72 6cc63ed

fix/issue-72 and fix/issue-312 conflict on one line in internal/api/handler_dashboard.go (both change the calculateCommitmentMetrics call). The tree used #72's side with #312's validated provider variable; whichever PR merges second takes that same resolution.

Linux, Go 1.26.9, AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers:

  • Passed at 936996ad: go build ./..., go vet ./..., go vet -tags integration ./... and the full go test -race -short -count=1 ./....
  • Static checks at 936996ad: golangci-lint v2.10.1 found 0 issues on every changed package, with and without --build-tags integration. tests/e2e is a separate module, so it was vetted under its own e2e tag. gocyclo -over 10 is silent on the changed non-test files.
  • Integration tests, every changed package, one package at a time (-tags integration -count=1, 60-minute local timeout because the VM was shared):
    • At 936996ad, these pass: internal/server, internal/credentials, internal/scheduler, internal/database/postgres/migrations, internal/purchase and internal/api.
    • internal/config passes except TestPostgresStoreDB_ResolveStaleRIExchange_ValidatesCloudCheck, which fails the same way on unmodified main (fixed by fix(config): compare resolve checked_at at the database's microsecond precision #1252).
    • These were run on the first build of this tree (26795376, same branches at earlier heads): internal/analytics, internal/auth, internal/commitmentopts, internal/database and cmd/lambda/clear-rate-limit all pass. The later fixes don't touch those packages.
  • What the combined run caught, each fixed on its own branch before this PR opened:
    • a compile break between fix/issue-104's test and fix/issue-326's httpToLambdaRequest signature change;
    • fix/issue-316 rejecting legacy-keyed plans;
    • fix/issue-120 versus main's global-filter test.
  • Secrets: git-secrets, using the repo's scripts/setup-git-secrets.sh patterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged. scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.
  • Nothing was applied to any cloud.

Out of scope / notes

No frontend change is needed. The 400 message for a bad contact email is unchanged.

Maintainer: please mirror the issue's triage labels onto this PR: priority/p3, severity/low, urgency/eventually, impact/internal, effort/s, type/chore, triaged.

🤖 Generated with Claude Code

…ure account once, validate registration email

Three of the four internal/api findings in issue LeanerCloud#311:

- revokeViaSession no longer binds TransitionExecutionStatus's returned
  row only to discard it, and its doc comment now says how CancelledBy is
  written and that neither revoke path applies the 4-eyes policy.
- Delete the two bare t.Skip revoke+4-eyes placeholders; revocation is not
  dual-controlled, and whether it should be is an owner decision.
- The Azure exchange execute path looks the subscription's CloudAccount up
  once and uses that row for the scope check, the client credentials and
  the constraint AccountIDs, instead of three lookups that could disagree.
- POST /api/register validates contact_email with validateEmailFormat and
  stores the bare address, instead of accepting any 5+ character string
  containing "@" (which stored "a@b\r\nBcc: x@y").

The findDuplicatePendingExecution removal is left to the open LeanerCloud#1217,
which already deletes it.

Refs LeanerCloud#311

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added impact/internal Team-internal only type/chore Maintenance / non-user-visible triaged Item has been triaged priority/p3 Polish / idea / may never ship severity/low Minor harm urgency/eventually No deadline effort/s Hours labels Oct 23, 2026
@coderabbitai

coderabbitai Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/internal Team-internal only priority/p3 Polish / idea / may never ship severity/low Minor harm triaged Item has been triaged type/chore Maintenance / non-user-visible urgency/eventually No deadline

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant