"""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")