Files
saas_backend/alembic/versions/b8e1c4a6f90c_customer_webhooks.py
T
2026-08-31 20:39:41 -04:00

124 lines
5.7 KiB
Python

"""Telling a customer's own systems when something happens.
There is already an outbox, and it delivers to **modules** — components this
platform runs, registered by a superadmin, with credentials the platform issued.
A customer cannot use it. If they want to know when a user is added, their only
option is to poll, which means either stale data or a script hammering the API on
a timer.
This is the same idea pointed the other way: a workspace registers its own URL,
picks the events it cares about, and gets a signed POST when one happens.
Two things it does that the module outbox does not have to:
**The URL is attacker-supplied.** A workspace administrator can type anything,
and the *server* is the one that fetches it — so `169.254.169.254` is the cloud
metadata service and `127.0.0.1:5432` is this database. Every delivery goes
through the same resolve-then-pin check the identity-provider discovery uses.
**Failure has to be visible and self-limiting.** A module that stops answering is
an incident somebody is paged for. A customer endpoint that stops answering is
Tuesday — a certificate expired, a firewall rule changed, someone deleted the
Lambda. So deliveries are recorded per attempt, and an endpoint that fails
consistently is disabled rather than retried for ever behind live traffic.
Revision ID: b8e1c4a6f90c
Revises: a7d9f1c3e80b
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB, UUID
revision: str = "b8e1c4a6f90c"
down_revision: Union[str, Sequence[str], None] = "a7d9f1c3e80b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SCOPED = ("webhook_endpoints", "webhook_deliveries")
def upgrade() -> None:
op.create_table(
"webhook_endpoints",
sa.Column("id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
sa.Column("url", sa.String(2048), nullable=False),
sa.Column("description", sa.String(255), nullable=True),
sa.Column("event_types", JSONB(), nullable=False,
server_default=sa.text("'[]'::jsonb")),
sa.Column("secret_enc", sa.Text(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("disabled_reason", sa.String(255), nullable=True),
sa.Column("consecutive_failures", sa.Integer(), nullable=False,
server_default="0"),
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_failure_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_by_id", UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
)
op.create_index("ix_webhook_endpoints_tenant", "webhook_endpoints", ["tenant_id"])
op.create_table(
"webhook_deliveries",
sa.Column("id", UUID(as_uuid=True), primary_key=True,
server_default=sa.text("gen_random_uuid()")),
sa.Column("tenant_id", UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
sa.Column("endpoint_id", UUID(as_uuid=True),
sa.ForeignKey("webhook_endpoints.id", ondelete="CASCADE"),
nullable=False),
sa.Column("event_id", UUID(as_uuid=True), nullable=False),
sa.Column("event_type", sa.String(120), nullable=False),
sa.Column("payload", JSONB(), nullable=False),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
sa.Column("response_status", sa.Integer(), nullable=True),
sa.Column("error", sa.String(1000), nullable=True),
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
)
op.create_index("ix_webhook_deliveries_endpoint", "webhook_deliveries",
["endpoint_id"])
op.create_index(
"ix_webhook_deliveries_due", "webhook_deliveries",
["next_attempt_at"],
postgresql_where=sa.text("status = 'pending'"),
)
for table in SCOPED:
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
op.execute(
f"""
CREATE POLICY tenant_isolation ON {table}
USING (
current_setting('app.bypass_rls', true) = 'on'
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
WITH CHECK (
current_setting('app.bypass_rls', true) = 'on'
OR tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
"""
)
def downgrade() -> None:
for table in SCOPED:
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {table}")
op.drop_index("ix_webhook_deliveries_due", table_name="webhook_deliveries")
op.drop_index("ix_webhook_deliveries_endpoint", table_name="webhook_deliveries")
op.drop_table("webhook_deliveries")
op.drop_index("ix_webhook_endpoints_tenant", table_name="webhook_endpoints")
op.drop_table("webhook_endpoints")