Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,11 @@ jobs:
TF_VAR_slack_client_id: ${{ secrets.SLACK_CLIENT_ID }}
TF_VAR_slack_client_secret: ${{ secrets.SLACK_CLIENT_SECRET }}
TF_VAR_slack_signing_secret: ${{ secrets.SLACK_SIGNING_SECRET }}
# Set only while a signing secret is being rotated, and cleared after.
# An unset repository secret arrives as an empty string, which the
# endpoint treats as "no previous key" — so the normal state needs no
# action, and a rotation is two secret edits rather than a code change.
TF_VAR_slack_signing_secret_previous: ${{ secrets.SLACK_SIGNING_SECRET_PREVIOUS }}
TF_VAR_slack_app_id: ${{ secrets.SLACK_APP_ID }}
run: |
terraform apply \
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
-- CreateEnum
CREATE TYPE "WebhookReceiptOutcome" AS ENUM ('HANDLED', 'NO_ACTION', 'NOT_PROCESSED');

-- CreateTable
CREATE TABLE "WebhookSubscription" (
"id" TEXT NOT NULL,
"institutionId" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"endpoint" TEXT NOT NULL,
"events" TEXT[],
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "WebhookSubscription_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "WebhookReceipt" (
"id" TEXT NOT NULL,
"institutionId" TEXT,
"connectionId" TEXT,
"providerId" TEXT NOT NULL,
"externalId" TEXT,
"externalEventId" TEXT,
"semanticKey" TEXT NOT NULL,
"envelopeType" TEXT NOT NULL,
"eventType" TEXT,
"bodyDigest" TEXT NOT NULL,
"bodySize" INTEGER NOT NULL,
"retryNum" INTEGER,
"retryReason" TEXT,
"outcome" "WebhookReceiptOutcome" NOT NULL,
"note" TEXT NOT NULL,
"connectionsMatched" INTEGER NOT NULL DEFAULT 0,
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "WebhookReceipt_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "WebhookSubscription_institutionId_providerId_idx" ON "WebhookSubscription"("institutionId", "providerId");

-- CreateIndex
CREATE UNIQUE INDEX "WebhookSubscription_connectionId_key" ON "WebhookSubscription"("connectionId");

-- CreateIndex
CREATE INDEX "WebhookReceipt_institutionId_receivedAt_idx" ON "WebhookReceipt"("institutionId", "receivedAt");

-- CreateIndex
CREATE INDEX "WebhookReceipt_providerId_receivedAt_idx" ON "WebhookReceipt"("providerId", "receivedAt");

-- CreateIndex
CREATE UNIQUE INDEX "WebhookReceipt_providerId_externalEventId_key" ON "WebhookReceipt"("providerId", "externalEventId");

-- CreateIndex
CREATE UNIQUE INDEX "WebhookReceipt_providerId_semanticKey_key" ON "WebhookReceipt"("providerId", "semanticKey");

-- AddForeignKey
ALTER TABLE "WebhookSubscription" ADD CONSTRAINT "WebhookSubscription_institutionId_fkey" FOREIGN KEY ("institutionId") REFERENCES "Institution"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "WebhookSubscription" ADD CONSTRAINT "WebhookSubscription_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "Connection"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "WebhookReceipt" ADD CONSTRAINT "WebhookReceipt_institutionId_fkey" FOREIGN KEY ("institutionId") REFERENCES "Institution"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "WebhookReceipt" ADD CONSTRAINT "WebhookReceipt_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "Connection"("id") ON DELETE SET NULL ON UPDATE CASCADE;

192 changes: 186 additions & 6 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ model Institution {
resources Resource[]
directoryPeople DirectoryPerson[]
restrictedIdentities RestrictedIdentity[]
webhookSubscriptions WebhookSubscription[]
webhookReceipts WebhookReceipt[]
exceptions Exception[]
seatMeterEvents SeatMeterEvent[]
registrySeal RestrictedRegistrySeal?
Expand Down Expand Up @@ -462,10 +464,10 @@ model ApprovalRequest {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

steps ApprovalStep[]
conversation Conversation? @relation("ApprovalConversation")
event Event? @relation("ApprovalEvent")
ledgerEntries LedgerEntry[]
steps ApprovalStep[]
conversation Conversation? @relation("ApprovalConversation")
event Event? @relation("ApprovalEvent")
ledgerEntries LedgerEntry[]
// Exceptions whose waiver this request authorised. A back-relation only —
// nothing about this table changes, which is the whole point of ADR-0015.
waivedExceptions Exception[]
Expand Down Expand Up @@ -1202,6 +1204,10 @@ model Connection {
revokedAt DateTime?
updatedAt DateTime @updatedAt

/// What this connection is subscribed to receive, and what arrived.
webhookSubscription WebhookSubscription?
webhookReceipts WebhookReceipt[]

/// Re-installing the same workspace updates rather than duplicates, so a
/// retry converges instead of leaving two rows that disagree.
@@unique([institutionId, providerId, externalId])
Expand Down Expand Up @@ -1388,7 +1394,7 @@ model Exception {
status ExceptionStatus @default(OPEN)

/// One line, for the worklist row.
title String
title String
/// The pair that makes this an exception rather than an error: what was
/// supposed to happen, and what happened instead. An operator who reads only
/// these two sentences knows what is wrong.
Expand Down Expand Up @@ -1445,7 +1451,7 @@ model Exception {

/// A waiver's end. Mandatory when the status is WAIVED — a waiver with no
/// expiry is a policy change wearing an exception's clothes.
expiresAt DateTime?
expiresAt DateTime?
/// The approval that authorised the waiver, where one exists.
///
/// NULLABLE and, today, unusable for an exception with no club: creating an
Expand Down Expand Up @@ -1712,3 +1718,177 @@ model RestrictedRegistrySeal {

institution Institution @relation(fields: [institutionId], references: [id], onDelete: Cascade)
}

// ─── Inbound webhooks ────────────────────────────────────────────────────────

/// What this deployment expects a provider to deliver, and where.
///
/// Integration §12 asks for subscription renewal and deletion, and both need a
/// row: an installation that is subscribed is a fact about the connection, not
/// about the code, and offboarding has to be able to find and remove it.
///
/// ── What "renewal" means here, honestly ─────────────────────────────────────
///
/// `expiresAt` is null for Slack, and that is not an omission. Slack's Events
/// API subscription is configured on the app and delivered for as long as the
/// app is installed — there is no lifetime and no renewal call, so a renewal
/// job for Slack would renew nothing. Providers whose subscriptions do expire
/// (Microsoft Graph tops out at three days, Google Drive's watch channels at
/// one week) set this, and the column is what a renewal pass would read. It is
/// declared now rather than added later because the row's whole purpose is to
/// survive an offboarding, and a subscription record that cannot express "this
/// one dies on Thursday" would have to be migrated the day Outlook arrives.
///
/// ── One row per connection ──────────────────────────────────────────────────
///
/// Slack has one subscription per installed workspace, so the connection is the
/// key. A provider with several — Graph subscribes per resource — needs this
/// widened, and that is a schema change made when that provider lands rather
/// than a column nothing sets today.
model WebhookSubscription {
id String @id @default(cuid())
institutionId String
institution Institution @relation(fields: [institutionId], references: [id], onDelete: Cascade)

/// The installation this subscription belongs to. Cascades, because a
/// subscription outliving its connection is a delivery nobody can attribute.
connectionId String
connection Connection @relation(fields: [connectionId], references: [id], onDelete: Cascade)

/// Catalog product id, e.g. "slack.workspace". Denormalised from the
/// connection so a query for "every Slack subscription" needs no join.
providerId String

/// Where this deployment expects delivery. Slack's Request URL is app-level
/// configuration rather than something Tenure sets, so this is not a command
/// — it is the value to compare against when an app configured for staging
/// starts delivering into production, which is otherwise invisible.
endpoint String

/// The event names asked for. Kept so a receipt for something unsubscribed
/// can be recognised as a configuration drift rather than a code gap.
events String[]

/// When the provider stops delivering unless renewed. Null means the provider
/// does not expire this subscription — see the note above.
expiresAt DateTime?

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@unique([connectionId])
@@index([institutionId, providerId])
}

/// One verified inbound delivery, recorded before anything acts on it.
///
/// Integration §12 asks for event-id dedupe and a minimised immutable receipt.
/// This is both: the two unique indexes below are the dedupe — a provider event
/// id and a semantic key derived from the delivery's content — and the columns
/// are deliberately everything except the payload.
///
/// ── Why the body is not here ────────────────────────────────────────────────
///
/// `bodyDigest` proves what arrived without this table becoming a copy of a
/// tenant's Slack traffic. A `message` event carries what somebody typed and
/// `tokens_revoked` carries the member ids whose tokens went away; none of that
/// is needed to know that a delivery happened, was verified, and was acted on.
///
/// It is also the reason this table is not a queue in disguise. A row here
/// cannot be replayed, by design: replaying needs the payload, and the carrier
/// that is supposed to hold one is the transactional outbox — blocked on the
/// envelope conflict between Identity §21.2 and Integration §9, and requiring
/// (per §9) that a payload live behind a governed external reference rather than
/// inline in a row like this one. Storing the body here to be drained later
/// would be that second carrier, built without the decision that governs it.
///
/// ── Why institutionId can be null ───────────────────────────────────────────
///
/// A delivery's tenant is derived, not given: the envelope names a Slack team,
/// and the team is matched to a `Connection`. That derivation can find nothing
/// — an install that failed after the OAuth exchange, or an app still installed
/// in a workspace whose connection was deleted — and it can find more than one,
/// because one workspace may legitimately be connected by two institutions. In
/// both cases the tenant is genuinely unknown and a guessed one would be worse
/// than none; `connectionsAffected` records what the lookup actually found.
model WebhookReceipt {
id String @id @default(cuid())

/// Resolved from the workspace, when exactly one connection matched.
institutionId String?
institution Institution? @relation(fields: [institutionId], references: [id], onDelete: SetNull)

/// The matched connection, on the same condition. Set null rather than
/// cascade-deleted: a receipt is evidence that a delivery arrived, and it
/// stays true after the connection it was about is gone.
connectionId String?
connection Connection? @relation(fields: [connectionId], references: [id], onDelete: SetNull)

/// Catalog product id, e.g. "slack.workspace".
providerId String

/// The provider's own identifier for the workspace the delivery is about.
externalId String?

/// The provider's id for this delivery — Slack's `event_id`. Null when the
/// envelope carried none, and NULLs do not collide in a Postgres unique
/// index, which is the behaviour wanted here: an envelope with no id cannot
/// be deduplicated on one, and the semantic key covers it instead.
///
/// Its unique index does NOT include the workspace, which is an assertion
/// about the provider rather than an oversight: Slack documents `event_id` as
/// unique across all workspaces. A provider that numbers events per tenant
/// needs that key widened before it is added, or one tenant's delivery would
/// be answered as a duplicate of another's and never processed —
/// `webhook-receipt.itest.ts` pins the constraint so the widening cannot be
/// forgotten.
externalEventId String?

/// Content identity, always present. `team:type:event_ts` where the envelope
/// supplies all three, a content digest otherwise.
semanticKey String

/// Envelope `type` and inner `event.type`, kept separately because the second
/// is what decides handling and the first is what says the envelope was a
/// shape this endpoint understands at all.
envelopeType String
eventType String?

/// SHA-256 of the exact bytes received, and their length. What this table
/// keeps instead of the payload.
bodyDigest String
bodySize Int

/// Slack's retry counter and reason, when it is retrying. Present means an
/// earlier delivery of the same event did not get a 2xx, which is worth
/// seeing next to a receipt that says it was handled.
retryNum Int?
retryReason String?

outcome WebhookReceiptOutcome

/// Why, in a sentence somebody reads. For NOT_PROCESSED this is the plain
/// statement that something arrived and nothing acted on it.
note String

/// How many connections the workspace resolved to. Zero and two are both real
/// and both leave `connectionId` null; the number is what tells them apart.
/// What was DONE about them is `outcome` and `note`, not this.
connectionsMatched Int @default(0)

receivedAt DateTime @default(now())

@@unique([providerId, externalEventId])
@@unique([providerId, semanticKey])
@@index([institutionId, receivedAt])
@@index([providerId, receivedAt])
}

enum WebhookReceiptOutcome {
/// Understood, and an effect was applied in the same transaction as this row.
HANDLED
/// Understood, and correctly nothing to do.
NO_ACTION
/// Accepted and recorded; no code path acts on it. `note` says which.
NOT_PROCESSED
}
43 changes: 40 additions & 3 deletions apps/web/src/app/api/integrations/slack/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import {
CONNECTION_INSTALLED_ACTION,
recordConnectionEvent,
} from "@/lib/integrations/connection-audit"
import {
SLACK_EVENTS_PATH,
SLACK_SUBSCRIBED_EVENTS,
} from "@/lib/integrations/slack/events"
import { verifyInstallState } from "@/lib/integrations/slack/install"
import { classifySlackInstallFailure } from "@/lib/integrations/slack/install-exceptions"
import { exchangeCodeForToken } from "@/lib/integrations/slack/oauth"
Expand Down Expand Up @@ -35,9 +39,10 @@ import { awsSecretStore, secretNameFor } from "@/lib/integrations/secret-store"
* as connected and fails on first use; a secret with no row is invisible,
* harmless, and overwritten by the next install of the same workspace.
*
* The row and its audit record then go in one transaction, because a connection
* nobody can account for is the thing §4 exists to prevent — see
* `connection-audit.ts`.
* The row, its webhook subscription and its audit record then go in one
* transaction, because a connection nobody can account for is the thing §4
* exists to prevent — see `connection-audit.ts` — and a connection with no
* subscription row is one that offboarding cannot find anything to delete for.
*
* ── Where a failure goes ────────────────────────────────────────────────────
*
Expand Down Expand Up @@ -173,6 +178,38 @@ export async function GET(req: Request) {
},
})

// The installation IS the subscription, for Slack. Event delivery starts
// the moment the app is in the workspace and stops when it leaves, so the
// row is created here rather than by a separate call there is no API for
// — and the events endpoint deletes it on `app_uninstalled`, which is the
// offboarding half Integration §12 asks for.
//
// Upserted on the connection, so re-installing the same workspace
// converges instead of failing on the unique key or leaving a stale
// endpoint behind from a previous deployment host.
await tx.webhookSubscription.upsert({
where: { connectionId: connection.id },
update: {
endpoint: new URL(SLACK_EVENTS_PATH, req.url).toString(),
events: [...SLACK_SUBSCRIBED_EVENTS],
},
create: {
institutionId,
connectionId: connection.id,
providerId: SLACK_WORKSPACE_PRODUCT_ID,
// Derived from the request rather than hard-coded: this is the host
// the administrator is actually installing against, and comparing it
// to the Request URL configured on the Slack app is how you find a
// production install pointed at a staging endpoint.
endpoint: new URL(SLACK_EVENTS_PATH, req.url).toString(),
events: [...SLACK_SUBSCRIBED_EVENTS],
// No expiry. Slack's Events API subscription has no lifetime and no
// renewal call — it lasts exactly as long as the installation. A
// provider whose subscriptions do expire sets this.
expiresAt: null,
},
})

// Re-installing updates the row in place, so without this the trail would
// show one connection and no history — including the case that matters
// most, a workspace reconnected by somebody other than whoever installed it.
Expand Down
Loading
Loading