Skip to content
bitzorcas
中EN

Guide

Webhook subscriptions, app scopes, and authorization

Subscription aggregate, nine endpoints, tenant/API Client admission, event scopes, lifecycle, input normalization, and query authorization boundaries.

Last updated

A Webhook subscription is not merely a URL setting. It is the security boundary that says which tenant authorizes which API Client to receive which events. Endpoint Resource/Action authorizes the manager; tenant, Client state, and Client Scope decide whether registration and every later delivery remain admissible.

1. Subscription aggregate contract

WebhookSubscription maps directly to SysWebhookSubscription:

FieldConstraint and meaning
SubscriptionIdaggregate key; Create starts at 0, ORM Add assigns a real id
TenantIdtrusted current tenant; max 64, no surrounding whitespace
ClientIdIdentity API Client; max 64 and immutable after creation
Nametrimmed, required, max 120
TargetUrlabsolute HTTPS URI, AbsoluteUri max 512
EventTypesJsonrequired, ordinal, unique strict JSON string array
ScopesJsonrequired, ordinal, unique strict JSON string array
IpAllowlistJsonoptional; aggregate rejects blanks, not invalid CIDR syntax
SecretHashSHA-256 of current plaintext secret; excluded from summaries
SecretMaterialexpected DataProtection ciphertext; excluded from summaries
PreviousSecretMaterial/ExpiresAtrotation overlap fields; unused by current send/verify paths
StatusActive, Suspended, or Deleted
RotatedAtmost recent rotation time

ORM materialization uses WebhookStorageJson. Empty required arrays, duplicates, surrounding whitespace, invalid JSON, and former line-delimited storage throw instead of silently becoming an empty collection. That exposes bad migrations early, but requires a pre-deployment data audit.

Verify collection migration without a permissive fallback
// Serialization writes the single canonical array representation expected by both ORMs.
var canonical = WebhookStorageJson.SerializeRequired(
[WebhookEventTypes.FileFinalized, WebhookEventTypes.FileDeleted]);
// => ["files.finalized","files.deleted"]
var restored = WebhookStorageJson.DeserializeRequired(canonical);
// Legacy line format and an empty required collection must surface migration defects.
Should.Throw<InvalidOperationException>(() =>
WebhookStorageJson.DeserializeRequired("files.finalized\nfiles.deleted"));
Should.Throw<InvalidOperationException>(() =>
WebhookStorageJson.DeserializeRequired("[]"));

2. Two authorization layers

current user

IAuthorizedRequest
webhooks/resource/action

currentUser.TenantId

Tenant Active

ApiClient
owner tenant + serviceable

required scope for every event

all declared subscription scopes

URL / metadata / collection validation

Management authorization provides subscription View/Create/Update/Delete and delivery View/Update. Suspend, Resume, RotateSecret, and Update share Update; RetryDelivery also uses Update. ResourceDescriptor is webhooks/subscription or webhooks/delivery.

External-app admission then requires:

  1. an Active current tenant;
  2. every EventType registered;
  3. a resolvable ClientId;
  4. a serviceable Client owned by the current tenant;
  5. every event’s required scope on that Client;
  6. every explicitly declared scope on that Client.

Update cannot replace ClientId. The Handler loads the original subscription and validates new EventTypes/Scopes against its original Client. Moving ownership requires a new subscription and explicit cutover.

3. Registered events and required scopes

The in-memory registry starts with:

EventTypeRequired Client Scope
files.finalizedfiles.webhooks.deliver
files.deletedfiles.webhooks.deliver
billing.invoice-issuedbilling.webhooks.deliver
tickets.openedtickets.webhooks.deliver
workflow.startedworkflow.webhooks.deliver
workflow.completedworkflow.webhooks.deliver

api.version.deprecated has a constant and CAP consumer but is not registered and has no Webhook Scope constant. Its example message therefore reaches a consumer that returns Webhook.EventTypeNotRegistered; the subscription API cannot register it either.

Subscription Scopes need not equal or contain event-required scopes. The guard checks the two lists independently, so a Client with Files scope may declare some unrelated, also-authorized scope. If Scopes is intended as an externally visible authorization snapshot, derive it from EventTypes or validate the inclusion relationship.

4. Create/update validation order

Create invokes the guard before aggregate Create. Consequences:

  • if JSON binds eventTypes or scopes to null, the guard foreach/Any can throw instead of returning Webhook.CollectionsRequired;
  • an event with surrounding whitespace fails exact registry matching before aggregate normalization.

There is no Create/Update IRequestRule. Guard and aggregate carry most validation but do not share one shape/error-order contract. Public HTTP should first validate nullability, count, length, URI, and CIDR syntax, then run cross-port admission.

Target request rule: shape first, external ports second
// Request rules reject malformed HTTP shape before tenant/Client stores are contacted.
public static class WebhookErrors
{
public static readonly Error EventTypesRequired =
Error.Validation("Webhook.EventTypesRequired", "Select at least one event.");
public static readonly Error ScopesRequired =
Error.Validation("Webhook.ScopesRequired", "Declare at least one scope.");
}
public sealed class CreateWebhookSubscriptionRule
: IRequestRule<CreateSubscription.Command>
{
public ValueTask<Result> ValidateAsync(
CreateSubscription.Command request,
CancellationToken cancellationToken)
{
if (request.EventTypes is not { Count: > 0 })
return ValueTask.FromResult(Result.Failure(WebhookErrors.EventTypesRequired));
if (request.Scopes is not { Count: > 0 })
return ValueTask.FromResult(Result.Failure(WebhookErrors.ScopesRequired));
// Shape only; tenant, Client, and ownership remain guard responsibilities.
return ValueTask.FromResult(Result.Success());
}
}

5. URL and allowlist responsibilities

The aggregate proves only that TargetUrl is absolute HTTPS and within length. It does not reject URI userinfo, unusual ports, localhost/private/link-local/cloud-metadata destinations, IDN risk, fragments, redirects, or DNS changes.

Creation rejects blank IpAllowlist entries but accepts not-a-cidr; production CIDR policy dead-letters it only during delivery. Management should reuse the same parser before save. Also label this as a destination-address allowlist—not a list of callers allowed to invoke BitzOrcas.

6. Three-state lifecycle

CreateSuspendSuspend againResumeResume againDeleteDelete

Active

Suspended

Deleted

Suspend and Resume are assignment-style success for every non-Deleted state, not strict transition conflicts. Delete sets both domain status and soft-delete fields. Public Find excludes Deleted, so later calls return NotFound. Deleted cannot rotate, suspend, or resume.

Suspended subscriptions can be updated before resume. Update does not change state. List includes Active and Suspended but excludes Deleted.

7. Query and disclosure boundaries

Both lists use currentUser.User.TenantId. Cross-tenant identifiers become NotFound. Subscription summaries reveal TargetUrl, EventTypes, Scopes, IpAllowlist, ClientId, and status—network topology that should be restricted to integration administrators.

Neither list has PageIndex/PageSize. ListAsync loads all tenant rows and sorts in memory by CreateTime/OccurredAt. There is no Id tie-breaker, so equal timestamps have unstable order. Production needs provider-neutral Query Shape paging with explicit maxima.

8. Lifecycle use case

Suspend, change configuration, then resume
// Stop selection first so no new event starts against a half-migrated endpoint.
var suspended = await mediator.Send(
new SuspendSubscription.Command(subscriptionId), cancellationToken);
if (suspended.IsFailure)
return suspended.Error;
var updated = await mediator.Send(
new UpdateSubscription.Command(
subscriptionId,
Name: "ERP v2",
TargetUrl: new Uri("https://hooks.erp.example/v2/events"),
EventTypes: [WebhookEventTypes.FileFinalized],
Scopes: [WebhookScopes.FilesDeliver],
IpAllowlist: ["203.0.113.64/28"]),
cancellationToken);
if (updated.IsFailure)
return updated.Error;
// Resume does not probe the target; a separate challenge should have established readiness.
// Resume changes status only; there is no endpoint handshake or probe.
return await mediator.Send(
new ResumeSubscription.Command(subscriptionId), cancellationToken);

There is no test-delivery endpoint. Do not probe a new URL with a real business event: that creates a real idempotency key, delivery fact, and possible downstream side effect. Add an explicit challenge/handshake contract.

9. Required authorization tests

  1. Allow/deny all six permission actions.
  2. Cross-tenant subscription/delivery ids always become NotFound.
  3. Inactive/expired Client, wrong-tenant Client, and missing required scope.
  4. Duplicate/case-varied/null/empty/blank EventTypes.
  5. Declared scopes missing required, unrelated-but-granted, and ungranted.
  6. HTTPS userinfo, port, IDN, fragment, loopback/private/link-local.
  7. Invalid CIDR should fail registration rather than become delayed dead letter.
  8. Suspended update, idempotent Resume, and all Deleted operations.
  9. Stable paging at tenant scale and equal timestamps.
  10. No summary/serializer ever exposes any secret field.

10. Review commands

Terminal window
# Keep route actions and the permission catalog in one review.
rg -n "GenerateEndpoint|ResourceDescriptor|AuthorizationAction" \
src/Platform/Webhooks/BitzOrcas.Platform.Webhooks.Application
rg -n "Register\(|CapSubscribe|WebhookEventTypes|WebhookScopes" \
src/Platform/Webhooks -g '*.cs'
# The empty result is intentional until shape rules are implemented.
# Expected after correction: Create/Update rules exist and null never reaches the guard.
rg -n "IRequestRule<CreateSubscription|IRequestRule<UpdateSubscription" \
src/Platform/Webhooks -g '*.cs'

Back to Webhooks · signatures and secrets · network security

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%