Add read-only admin impersonation
Admins (with 2FA) can view the site as a non-admin user for up to 30 minutes, with a required reason and a "Viewing as" banner. It is strictly read-only: a guard mounted before every route refuses any write made with an impersonation token, protect/optionalAuth check the session is live, and GET handlers skip the price-refresh writes. Start, end, reason and every page/API call (including blocked attempts) are audited; optional email to the user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'impersonation_started';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'impersonation_ended';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ImpersonationSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"adminId" TEXT NOT NULL,
|
||||
"targetUserId" TEXT NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"endedAt" TIMESTAMP(3),
|
||||
"endedReason" TEXT,
|
||||
|
||||
CONSTRAINT "ImpersonationSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ImpersonationRequestLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"method" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"blocked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ImpersonationRequestLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ImpersonationSession_adminId_idx" ON "ImpersonationSession"("adminId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ImpersonationSession_targetUserId_idx" ON "ImpersonationSession"("targetUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ImpersonationRequestLog_sessionId_createdAt_idx" ON "ImpersonationRequestLog"("sessionId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImpersonationSession" ADD CONSTRAINT "ImpersonationSession_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImpersonationSession" ADD CONSTRAINT "ImpersonationSession_targetUserId_fkey" FOREIGN KEY ("targetUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ImpersonationRequestLog" ADD CONSTRAINT "ImpersonationRequestLog_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ImpersonationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -111,6 +111,8 @@ model User {
|
||||
ledGroups AllocationGroup[] @relation("AllocationGroupLeader")
|
||||
feedbackInvites FeedbackInvite[]
|
||||
feedbackGiven FeedbackResponse[]
|
||||
impersonationsDone ImpersonationSession[] @relation("ImpersonationAdmin")
|
||||
impersonatedIn ImpersonationSession[] @relation("ImpersonationTarget")
|
||||
scanLocksForced ScanLock[] @relation("ScanLockForcedBy")
|
||||
syncReportsDone OfflineSyncReport[] @relation("SyncReportReviewer")
|
||||
|
||||
@@ -499,6 +501,8 @@ enum AdminAuditAction {
|
||||
scan_lock_force_released
|
||||
offline_sync
|
||||
ticket_transferred
|
||||
impersonation_started
|
||||
impersonation_ended
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -854,6 +858,37 @@ enum TicketDelivery {
|
||||
|
||||
// One named person on a group registration, for one main-ticket place. Gets their
|
||||
// own quantity-1 ticket and their own attendee form response.
|
||||
/// An admin viewing the site as another user (strictly read-only, at most 30 minutes).
|
||||
model ImpersonationSession {
|
||||
id String @id @default(uuid())
|
||||
adminId String
|
||||
targetUserId String
|
||||
reason String
|
||||
startedAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
endedAt DateTime?
|
||||
endedReason String? // 'ended' | 'expired'
|
||||
admin User @relation("ImpersonationAdmin", fields: [adminId], references: [id], onDelete: Cascade)
|
||||
target User @relation("ImpersonationTarget", fields: [targetUserId], references: [id], onDelete: Cascade)
|
||||
requests ImpersonationRequestLog[]
|
||||
|
||||
@@index([adminId])
|
||||
@@index([targetUserId])
|
||||
}
|
||||
|
||||
/// Every page viewed and API call made during an impersonation session.
|
||||
model ImpersonationRequestLog {
|
||||
id String @id @default(uuid())
|
||||
sessionId String
|
||||
method String
|
||||
path String
|
||||
blocked Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
session ImpersonationSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([sessionId, createdAt])
|
||||
}
|
||||
|
||||
/// First-party funnel tracking (only after cookie consent): one row per anonymous
|
||||
/// session, event and stage ('view' | 'start' | 'submit'). "Paid" comes from the
|
||||
/// registration that carries the session id. Rows older than 90 days are rolled up
|
||||
|
||||
Reference in New Issue
Block a user