Skip to content

v2.12.2

Choose a tag to compare

@mp-abram-cookson mp-abram-cookson released this 11 Sep 14:29
· 138 commits to main since this release

IBeam v2.12.2

Fixes provider webhooks failing in any host that registers service-operation
authorization. One commit: ee1e8f8.

Provider webhooks no longer fail authorization

Every provider webhook returned 500 before it could handle a payload.

Service-operation authorization asks two things: which principal is acting, and
in which tenant. A payment processor can answer neither — it cannot sign in to
the consuming app, and the tenant is not known until the payload has been read.
So BillingProviderEventService.RecordEventAsync threw
"tenantId is required for service operation authorization" on the way in, and
recording happens on every outcome, including the ignore branches, so no
payload got through.

This contradicted IBeam.Billing.Api's own documented design: "Provider
webhooks are anonymous because processor callbacks cannot sign in to the
consuming app."

It only affects hosts that register an IServiceOperationAuthorizer — the
demand short-circuits when none is present, which is presumably why it shipped.

What changed

IServiceOperationSystemContext (new, in IBeam.AccessControl) marks a stretch
of work as originating from a verified machine callback.
ServiceOperationExecutor honours it by skipping the tenant demand.
BillingWebhookProcessor opens one — strictly after signature verification,
never before
. The signature over the raw body is what establishes that the
request is genuine; the scope only suppresses a check that cannot apply to such
a caller, and proves nothing on its own.

Registered scoped by AddIBeamServicePolicies and AddIBeamServiceAuditing, so
an exemption cannot outlive the request that opened it even if a handle is never
disposed. Nesting is supported, and a double dispose cannot pop an outer scope.
Enter() requires a reason, so an exemption never appears unexplained.

Why a scope rather than Permission = false

Three operations on the webhook path carry no tenant —
billing.provider-events.record, billing.purchases.get and
billing.purchases.apply-provider-update — plus whatever a host's own
IBillingPaidPurchaseHandler calls, since that runs inside ProcessAsync too.
Annotating each would have exempted the authenticated admin and support tooling
that reaches the same services, and would have needed a new annotation every
time the path grew.

Why it was not caught

Mock billing reaches the same processor from inside an authenticated request,
where ITenantContext is populated and the demand is satisfied. Same code,
different caller — so it passed every test that used the mock gateway.

Upgrade notes

  • IServiceOperationSystemContext is new and registered automatically by
    AddIBeamServicePolicies / AddIBeamServiceAuditing. Hosts calling neither
    are unaffected; the executor treats it as optional.
  • ServiceOperationExecutor and BillingWebhookProcessor each take one new
    optional constructor parameter. Existing construction, including in tests,
    is unaffected.
  • No configuration change is required.

Verification

All 31 test projects pass on main (70e0675), rebuilt and re-run after the
promotion rather than only on the feature branch.

New coverage pins the security boundary rather than the happy path:

  • no tenant and no scope still refuses — if that ever passes, an
    unauthenticated caller would sail through every service operation;
  • authorization resumes once the scope closes;
  • a double dispose cannot pop an outer scope;
  • Enter() rejects a blank reason.