feat(audit): append-only audit_log, workspace archive cascade + restore, audit view
Soft-delete cascade was the missing half of archive: stamping workspaces.archived_at alone left objects visible to anyone with a direct id. The cascade runs in one transaction so the partial state isn't reachable, and restore inverts it for any archived row in the workspace — provenance-blind on purpose until we have a use case that needs to distinguish per-workspace from per-object archives. audit_log keeps the keyset index on (workspace_id, created_at) and the actor_user_id FK with onDelete set null. recordAudit() refuses to write a null actor without a metadata.system_actor label so the audit view always has something to render. workspaces and invites mutations call recordAudit on success; objects-router instrumentation and the markdown importer's system-actor flow are filed as P2 follow-ups because each needs a thoughtful "what's audit-worthy?" pass, not mechanical wiring. Settings → Audit log lives at /<slug>/settings/audit, owner-gated, keyset-paginated. ACTION_LABELS is small on purpose; new actions fall back to their raw key so missing a label degrades gracefully. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
58f92f3898
commit
336a5890a8
22 changed files with 3745 additions and 35 deletions
189
apps/web/app/(app)/[workspaceSlug]/settings/audit/page.tsx
Normal file
189
apps/web/app/(app)/[workspaceSlug]/settings/audit/page.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { History, Loader2, ScrollText, ShieldOff } from "lucide-react";
|
||||
|
||||
import { api } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* Owner-only debugging view over `audit_log`. Intentionally dumb: no
|
||||
* filters, no search, no time-zone toggles. The job is "show me what
|
||||
* happened, most recent first." If we add filters later they should be
|
||||
* server-side keyset queries, not client-side slicing.
|
||||
*/
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
"workspace.create": "Workspace created",
|
||||
"workspace.update": "Workspace updated",
|
||||
"workspace.archive": "Workspace archived",
|
||||
"workspace.restore": "Workspace restored",
|
||||
"member.role_change": "Role changed",
|
||||
"member.remove": "Member removed",
|
||||
"member.leave": "Member left",
|
||||
"invite.create": "Invite sent",
|
||||
"invite.revoke": "Invite revoked",
|
||||
"invite.accept": "Invite accepted",
|
||||
};
|
||||
|
||||
function actionLabel(action: string): string {
|
||||
return ACTION_LABELS[action] ?? action;
|
||||
}
|
||||
|
||||
function formatTimestamp(when: Date | string): string {
|
||||
const d = when instanceof Date ? when : new Date(when);
|
||||
return d.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function metadataSummary(metadata: Record<string, unknown> | null): string {
|
||||
if (!metadata || Object.keys(metadata).length === 0) return "—";
|
||||
// Truncate aggressively. The expanded raw JSON is one row away via the
|
||||
// <details> below; this is just the at-a-glance hint.
|
||||
const json = JSON.stringify(metadata);
|
||||
return json.length > 120 ? `${json.slice(0, 117)}…` : json;
|
||||
}
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const params = useParams();
|
||||
const workspaceSlug = params?.workspaceSlug as string | undefined;
|
||||
|
||||
const [cursor, setCursor] = React.useState<string | undefined>(undefined);
|
||||
const auditQuery = api.audit.list.useQuery(
|
||||
{ workspace: workspaceSlug ?? "", cursorCreatedAt: cursor, limit: 50 },
|
||||
{ enabled: Boolean(workspaceSlug) },
|
||||
);
|
||||
|
||||
const isForbidden = auditQuery.error?.data?.code === "FORBIDDEN";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-8 py-10">
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<ScrollText className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Audit log</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Append-only record of meaningful actions in this workspace. Owner-only.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isForbidden ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border bg-muted/40 px-6 py-12 text-center">
|
||||
<ShieldOff className="size-6 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">Owner-only view</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
Only the workspace owner can read the audit log. Ask an owner to share
|
||||
specific events if you need them for support.
|
||||
</p>
|
||||
</div>
|
||||
) : auditQuery.isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : auditQuery.error ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
||||
Couldn't load the audit log: {auditQuery.error.message}
|
||||
</div>
|
||||
) : !auditQuery.data || auditQuery.data.rows.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 rounded-lg border bg-muted/40 px-6 py-12 text-center">
|
||||
<History className="size-6 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">No events yet</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">
|
||||
Actions like creating invites, changing roles, or archiving the workspace
|
||||
will appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">When</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Action</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Actor</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Target</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{auditQuery.data.rows.map((row) => (
|
||||
<tr key={row.id} className="hover:bg-muted/30">
|
||||
<td className="whitespace-nowrap px-4 py-2 text-xs tabular-nums text-muted-foreground">
|
||||
{formatTimestamp(row.createdAt)}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-medium">{actionLabel(row.action)}</td>
|
||||
<td className="px-4 py-2 text-xs">
|
||||
{row.actorUserId ? (
|
||||
<span>
|
||||
{row.actorName ?? row.actorEmail ?? row.actorUserId.slice(0, 8)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="italic text-muted-foreground">
|
||||
system
|
||||
{row.metadata && typeof (row.metadata as { system_actor?: unknown }).system_actor === "string"
|
||||
? `: ${(row.metadata as { system_actor: string }).system_actor}`
|
||||
: ""}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">
|
||||
<code className="rounded bg-muted px-1 py-0.5">{row.targetType}</code>
|
||||
{row.targetId ? (
|
||||
<span className="ml-1 font-mono text-[10px]">{row.targetId.slice(0, 8)}</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{row.metadata && Object.keys(row.metadata).length > 0 ? (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
{metadataSummary(row.metadata)}
|
||||
</summary>
|
||||
<pre className="mt-1 overflow-x-auto rounded bg-muted px-2 py-1 text-[10px]">
|
||||
{JSON.stringify(row.metadata, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{auditQuery.data.nextCursor ? (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCursor(auditQuery.data.nextCursor ?? undefined)}
|
||||
disabled={auditQuery.isFetching}
|
||||
>
|
||||
{auditQuery.isFetching ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
"Load older"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
Pencil,
|
||||
Plus,
|
||||
Presentation,
|
||||
ScrollText,
|
||||
Search,
|
||||
Settings,
|
||||
User,
|
||||
|
|
@ -130,6 +131,15 @@ export function TopHeader({ onOpenSearch, onQuickAction }: TopHeaderProps) {
|
|||
<Zap className="size-4 text-muted-foreground" />
|
||||
Automations
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="gap-2.5 px-4"
|
||||
onSelect={() =>
|
||||
workspaceId && router.push(`/${workspaceId}/settings/audit`)
|
||||
}
|
||||
>
|
||||
<ScrollText className="size-4 text-muted-foreground" />
|
||||
Audit log
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{/* Create Workspace */}
|
||||
<div className="px-3 py-2">
|
||||
|
|
|
|||
63
apps/web/server/lib/audit.ts
Normal file
63
apps/web/server/lib/audit.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { db as defaultDb } from "@tasks/database";
|
||||
import { auditLog } from "@tasks/database/schema";
|
||||
|
||||
/**
|
||||
* Append a single audit-log row. Every mutation that wants accountability
|
||||
* (workspace state changes, member changes, invite lifecycle, object
|
||||
* mutations once we instrument them) calls this once on success.
|
||||
*
|
||||
* Invariants:
|
||||
* - Never fail silently — if the insert throws, let it propagate. An
|
||||
* audit miss is data-loss; the caller's transaction should roll back
|
||||
* too if it can.
|
||||
* - Keep `metadata` SMALL. Caller is responsible for picking the right
|
||||
* handful of fields, not dumping the whole row. The convention is
|
||||
* "what would a support engineer want to see at a glance?"
|
||||
* - `actorUserId === null` is reserved for genuine system actors
|
||||
* (markdown importer, scheduled jobs). Caller must also stamp
|
||||
* `metadata.system_actor` with a short identifier so the audit UI
|
||||
* can render "system: markdown-importer" instead of an empty cell.
|
||||
*/
|
||||
export type RecordAuditInput = {
|
||||
workspaceId: string;
|
||||
/** null = system actor. Pair with `metadata.system_actor` when null. */
|
||||
actorUserId: string | null;
|
||||
/** `<target>.<verb>` — e.g. `workspace.archive`, `invite.create`. */
|
||||
action: string;
|
||||
/** e.g. `workspace`, `invite`, `workspace_member`, `object`. */
|
||||
targetType: string;
|
||||
/** Natural id of the affected row, or null for batch / cascade actions. */
|
||||
targetId?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export async function recordAudit(
|
||||
db: typeof defaultDb,
|
||||
input: RecordAuditInput,
|
||||
): Promise<void> {
|
||||
// Light validation. Procedure-layer zod schemas should already enforce
|
||||
// these, but the audit table is the last stop and we want garbage rows
|
||||
// to fail loudly rather than silently distort the trail.
|
||||
if (!input.workspaceId) {
|
||||
throw new Error("recordAudit: workspaceId is required");
|
||||
}
|
||||
if (!input.action || !input.action.includes(".")) {
|
||||
throw new Error(
|
||||
`recordAudit: action must follow "<target>.<verb>", got: ${input.action}`,
|
||||
);
|
||||
}
|
||||
if (input.actorUserId === null && !input.metadata?.system_actor) {
|
||||
throw new Error(
|
||||
"recordAudit: actorUserId is null but metadata.system_actor is missing — name the system actor explicitly",
|
||||
);
|
||||
}
|
||||
|
||||
await db.insert(auditLog).values({
|
||||
workspaceId: input.workspaceId,
|
||||
actorUserId: input.actorUserId,
|
||||
action: input.action,
|
||||
targetType: input.targetType,
|
||||
targetId: input.targetId ?? null,
|
||||
metadata: input.metadata ?? null,
|
||||
});
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import { formsRouter } from "@/server/routers/forms";
|
|||
import { favoritesRouter } from "@/server/routers/favorites";
|
||||
import { identityRouter } from "@/server/routers/identity";
|
||||
import { invitesRouter } from "@/server/routers/invites";
|
||||
import { auditRouter } from "@/server/routers/audit";
|
||||
|
||||
export const appRouter = router({
|
||||
health: healthRouter,
|
||||
|
|
@ -27,6 +28,7 @@ export const appRouter = router({
|
|||
favorites: favoritesRouter,
|
||||
identity: identityRouter,
|
||||
invites: invitesRouter,
|
||||
audit: auditRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
|
|
|||
73
apps/web/server/routers/audit.ts
Normal file
73
apps/web/server/routers/audit.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { and, desc, eq, lt } from "drizzle-orm";
|
||||
|
||||
import { auditLog, users } from "@tasks/database/schema";
|
||||
import { router, workspaceProcedure } from "@/server/trpc";
|
||||
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
/**
|
||||
* Audit log read API. Owner-only; this is a debugging / support surface,
|
||||
* not a feature stream. Kept narrow on purpose — pagination is keyset on
|
||||
* `(created_at, id)` so we can scale rows without LIMIT/OFFSET pain.
|
||||
*/
|
||||
export const auditRouter = router({
|
||||
list: workspaceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.number().int().min(1).max(MAX_LIMIT).optional(),
|
||||
// Keyset cursor: a row's `createdAt` ISO string. Rows older than this
|
||||
// are returned next. We use createdAt-only (no id tiebreaker) because
|
||||
// the index is `(workspace_id, created_at)`. In the rare event of
|
||||
// identical timestamps the user can refresh; we're not building a
|
||||
// realtime feed here.
|
||||
cursorCreatedAt: z.string().datetime().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.workspace.role !== "owner") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only the workspace owner can view the audit log.",
|
||||
});
|
||||
}
|
||||
|
||||
const limit = input.limit ?? DEFAULT_LIMIT;
|
||||
const cursorDate = input.cursorCreatedAt ? new Date(input.cursorCreatedAt) : null;
|
||||
|
||||
const rows = await ctx.db
|
||||
.select({
|
||||
id: auditLog.id,
|
||||
action: auditLog.action,
|
||||
targetType: auditLog.targetType,
|
||||
targetId: auditLog.targetId,
|
||||
metadata: auditLog.metadata,
|
||||
createdAt: auditLog.createdAt,
|
||||
actorUserId: auditLog.actorUserId,
|
||||
actorName: users.name,
|
||||
actorEmail: users.email,
|
||||
})
|
||||
.from(auditLog)
|
||||
.leftJoin(users, eq(auditLog.actorUserId, users.id))
|
||||
.where(
|
||||
cursorDate
|
||||
? and(
|
||||
eq(auditLog.workspaceId, ctx.workspace.id),
|
||||
lt(auditLog.createdAt, cursorDate),
|
||||
)
|
||||
: eq(auditLog.workspaceId, ctx.workspace.id),
|
||||
)
|
||||
.orderBy(desc(auditLog.createdAt))
|
||||
.limit(limit + 1);
|
||||
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
const nextCursor = hasMore ? page[page.length - 1]!.createdAt.toISOString() : null;
|
||||
|
||||
return { rows: page, nextCursor };
|
||||
}),
|
||||
});
|
||||
|
||||
export type AuditRouter = typeof auditRouter;
|
||||
|
|
@ -17,6 +17,7 @@ import { z } from "zod";
|
|||
import { router, protectedProcedure, workspaceProcedure } from "@/server/trpc";
|
||||
import { userOwnsEmail } from "@/server/lib/identity";
|
||||
import { rateLimit } from "@/server/lib/rate-limit";
|
||||
import { recordAudit } from "@/server/lib/audit";
|
||||
import {
|
||||
userEmailIdentities,
|
||||
workspaceInvites,
|
||||
|
|
@ -201,6 +202,15 @@ export const invitesRouter = router({
|
|||
})
|
||||
.returning();
|
||||
|
||||
await recordAudit(ctx.db, {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: inviterId,
|
||||
action: "invite.create",
|
||||
targetType: "invite",
|
||||
targetId: invite!.id,
|
||||
metadata: { email: input.email, role: input.role },
|
||||
});
|
||||
|
||||
return {
|
||||
invite: invite!,
|
||||
acceptUrl: buildAcceptUrl(invite!.token),
|
||||
|
|
@ -283,6 +293,15 @@ export const invitesRouter = router({
|
|||
.set({ revokedAt: new Date() })
|
||||
.where(eq(workspaceInvites.id, invite.id));
|
||||
|
||||
await recordAudit(ctx.db, {
|
||||
workspaceId: invite.workspaceId,
|
||||
actorUserId: callerId,
|
||||
action: "invite.revoke",
|
||||
targetType: "invite",
|
||||
targetId: invite.id,
|
||||
metadata: null,
|
||||
});
|
||||
|
||||
return { ok: true as const };
|
||||
}),
|
||||
|
||||
|
|
@ -382,6 +401,19 @@ export const invitesRouter = router({
|
|||
.where(eq(workspaces.id, invite.workspaceId))
|
||||
.limit(1);
|
||||
|
||||
await recordAudit(ctx.db, {
|
||||
workspaceId: invite.workspaceId,
|
||||
actorUserId: callerId,
|
||||
action: "invite.accept",
|
||||
targetType: "workspace_member",
|
||||
targetId: callerId,
|
||||
metadata: {
|
||||
invite_id: invite.id,
|
||||
role: invite.role,
|
||||
already_member: Boolean(existingMembership),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
workspace: workspace!,
|
||||
role: invite.role,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { and, desc, eq, isNull, ne } from "drizzle-orm";
|
||||
import { and, desc, eq, isNull, ne, isNotNull } from "drizzle-orm";
|
||||
import {
|
||||
workspaces,
|
||||
workspaceMembers,
|
||||
users,
|
||||
objects,
|
||||
} from "@tasks/database/schema";
|
||||
import { router, protectedProcedure, workspaceProcedure } from "@/server/trpc";
|
||||
import { findWorkspaceByHandle } from "@/server/lib/resolve-workspace";
|
||||
import { recordAudit } from "@/server/lib/audit";
|
||||
|
||||
const slugSchema = z
|
||||
.string()
|
||||
|
|
@ -106,6 +108,15 @@ export const workspacesRouter = router({
|
|||
role: "owner",
|
||||
});
|
||||
|
||||
await recordAudit(ctx.db, {
|
||||
workspaceId: ws.id,
|
||||
actorUserId: userId,
|
||||
action: "workspace.create",
|
||||
targetType: "workspace",
|
||||
targetId: ws.id,
|
||||
metadata: { name: ws.name, slug: ws.slug },
|
||||
});
|
||||
|
||||
return ws;
|
||||
}),
|
||||
|
||||
|
|
@ -214,6 +225,18 @@ export const workspacesRouter = router({
|
|||
.where(eq(workspaces.id, ctx.workspace.id))
|
||||
.returning();
|
||||
|
||||
await recordAudit(ctx.db, {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
action: "workspace.update",
|
||||
targetType: "workspace",
|
||||
targetId: ctx.workspace.id,
|
||||
metadata: {
|
||||
...(input.name ? { name: { before: ctx.workspace.name, after: input.name } } : {}),
|
||||
...(input.slug ? { slug: { before: ctx.workspace.slug, after: input.slug } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}),
|
||||
|
||||
|
|
@ -297,6 +320,15 @@ export const workspacesRouter = router({
|
|||
),
|
||||
);
|
||||
|
||||
await recordAudit(ctx.db, {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
action: "member.role_change",
|
||||
targetType: "workspace_member",
|
||||
targetId: input.userId,
|
||||
metadata: { role: { before: target.role, after: input.role } },
|
||||
});
|
||||
|
||||
return { ok: true as const };
|
||||
}),
|
||||
|
||||
|
|
@ -369,19 +401,121 @@ export const workspacesRouter = router({
|
|||
),
|
||||
);
|
||||
|
||||
await recordAudit(ctx.db, {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
action: isSelf ? "member.leave" : "member.remove",
|
||||
targetType: "workspace_member",
|
||||
targetId: input.userId,
|
||||
metadata: { previous_role: target.role, self: isSelf },
|
||||
});
|
||||
|
||||
return { ok: true as const };
|
||||
}),
|
||||
|
||||
/** Owner-only soft archive. */
|
||||
/**
|
||||
* Owner-only soft archive. Stamps `archived_at` on the workspace row AND
|
||||
* cascades to every active `objects` row in the workspace inside the same
|
||||
* transaction — so a partial cascade (workspace archived, some objects
|
||||
* still active) is not a state we can land in.
|
||||
*
|
||||
* `markdown_backlog_items` is intentionally NOT cascaded here. Those rows
|
||||
* are sourced from disk by the file-watcher importer; if a workspace is
|
||||
* archived, restoring it just re-syncs from disk and the importer will
|
||||
* re-establish the rows. Filed as a follow-up if/when that assumption
|
||||
* stops holding.
|
||||
*/
|
||||
archive: workspaceProcedure.mutation(async ({ ctx }) => {
|
||||
if (ctx.workspace.role !== "owner") {
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
const [updated] = await ctx.db
|
||||
.update(workspaces)
|
||||
.set({ archivedAt: new Date() })
|
||||
|
||||
const archivedAt = new Date();
|
||||
const result = await ctx.db.transaction(async (tx) => {
|
||||
const [updated] = await tx
|
||||
.update(workspaces)
|
||||
.set({ archivedAt })
|
||||
.where(eq(workspaces.id, ctx.workspace.id))
|
||||
.returning();
|
||||
|
||||
const archivedObjects = await tx
|
||||
.update(objects)
|
||||
.set({ archivedAt, updatedAt: archivedAt })
|
||||
.where(
|
||||
and(eq(objects.workspaceId, ctx.workspace.id), isNull(objects.archivedAt)),
|
||||
)
|
||||
.returning({ id: objects.id });
|
||||
|
||||
return { updated, cascadeCount: archivedObjects.length };
|
||||
});
|
||||
|
||||
await recordAudit(ctx.db, {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
action: "workspace.archive",
|
||||
targetType: "workspace",
|
||||
targetId: ctx.workspace.id,
|
||||
metadata: { cascaded_objects: result.cascadeCount },
|
||||
});
|
||||
|
||||
return result.updated;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Owner-only restore. Inverse of `archive`: clears `archived_at` on the
|
||||
* workspace and on every object that was archived as part of the same
|
||||
* cascade. We can't tell "was this object archived by the workspace
|
||||
* cascade vs. archived independently?" without recording per-object
|
||||
* archive provenance, so for v1 we restore EVERY archived object in the
|
||||
* workspace. That's the conservative-recovery behavior; if it surprises
|
||||
* anyone we'll add provenance tracking later.
|
||||
*/
|
||||
restore: workspaceProcedure.mutation(async ({ ctx }) => {
|
||||
if (ctx.workspace.role !== "owner") {
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
const [current] = await ctx.db
|
||||
.select({ archivedAt: workspaces.archivedAt })
|
||||
.from(workspaces)
|
||||
.where(eq(workspaces.id, ctx.workspace.id))
|
||||
.returning();
|
||||
return updated;
|
||||
.limit(1);
|
||||
if (!current?.archivedAt) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Workspace is not archived.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await ctx.db.transaction(async (tx) => {
|
||||
const [updated] = await tx
|
||||
.update(workspaces)
|
||||
.set({ archivedAt: null })
|
||||
.where(eq(workspaces.id, ctx.workspace.id))
|
||||
.returning();
|
||||
|
||||
const restoredObjects = await tx
|
||||
.update(objects)
|
||||
.set({ archivedAt: null, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(objects.workspaceId, ctx.workspace.id),
|
||||
isNotNull(objects.archivedAt),
|
||||
),
|
||||
)
|
||||
.returning({ id: objects.id });
|
||||
|
||||
return { updated, cascadeCount: restoredObjects.length };
|
||||
});
|
||||
|
||||
await recordAudit(ctx.db, {
|
||||
workspaceId: ctx.workspace.id,
|
||||
actorUserId: ctx.session.user.id,
|
||||
action: "workspace.restore",
|
||||
targetType: "workspace",
|
||||
targetId: ctx.workspace.id,
|
||||
metadata: { restored_objects: result.cascadeCount },
|
||||
});
|
||||
|
||||
return result.updated;
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
17
packages/database/migrations/0007_flaky_kinsey_walden.sql
Normal file
17
packages/database/migrations/0007_flaky_kinsey_walden.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
CREATE TABLE "audit_log" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"workspace_id" uuid NOT NULL,
|
||||
"actor_user_id" uuid,
|
||||
"action" varchar(100) NOT NULL,
|
||||
"target_type" varchar(50) NOT NULL,
|
||||
"target_id" uuid,
|
||||
"metadata" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "markdown_backlog_items" ADD COLUMN "archived_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_workspace_id_workspaces_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_user_id_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "audit_log_workspace_id_created_at_idx" ON "audit_log" USING btree ("workspace_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "audit_log_actor_user_id_idx" ON "audit_log" USING btree ("actor_user_id");--> statement-breakpoint
|
||||
CREATE INDEX "audit_log_action_idx" ON "audit_log" USING btree ("action");
|
||||
2872
packages/database/migrations/meta/0007_snapshot.json
Normal file
2872
packages/database/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -50,6 +50,13 @@
|
|||
"when": 1780413875620,
|
||||
"tag": "0006_broad_lethal_legion",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1780424597399,
|
||||
"tag": "0007_flaky_kinsey_walden",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
75
packages/database/src/schema/audit.ts
Normal file
75
packages/database/src/schema/audit.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
varchar,
|
||||
jsonb,
|
||||
timestamp,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { workspaces } from "./workspaces";
|
||||
import { users } from "./users";
|
||||
|
||||
/**
|
||||
* Append-only audit log. One row per meaningful action taken inside a
|
||||
* tenant. The point is accountability ("who did what, when") and a
|
||||
* debugging surface for support — NOT a feature stream, NOT a search
|
||||
* index, NOT a metrics store. Reads are always scoped to a workspace.
|
||||
*
|
||||
* Write path: every mutating tRPC procedure calls `recordAudit()` from
|
||||
* `apps/web/server/lib/audit.ts` explicitly. We do NOT auto-instrument
|
||||
* via Drizzle middleware — being explicit makes it grep-able which
|
||||
* mutations are audited and which aren't, and keeps the metadata
|
||||
* structure procedure-specific.
|
||||
*
|
||||
* Conventions captured here so future maintainers don't drift:
|
||||
*
|
||||
* - `actorUserId` is null only for system actions (e.g. the markdown-
|
||||
* backlog importer running on the file watcher). A null actor must
|
||||
* have a corresponding `metadata.system_actor` string so the audit
|
||||
* view can show "system: markdown-importer" instead of an empty cell.
|
||||
*
|
||||
* - `action` follows `<target>.<verb>` (e.g. `workspace.archive`,
|
||||
* `invite.create`, `member.role_change`). Verbs are past-tense-ish
|
||||
* but written as imperatives for SQL friendliness ("the user did X").
|
||||
*
|
||||
* - `metadata` is JSONB but should stay SMALL. Never dump the full row
|
||||
* state — at most a handful of human-meaningful fields (e.g. for an
|
||||
* invite create, the invitee email and role; for a role change, the
|
||||
* before/after roles). Future audit-export tooling will assume rows
|
||||
* are < ~1 KB.
|
||||
*
|
||||
* - `target_id` is the natural ID of the affected row, nullable
|
||||
* because some actions (`workspace.archive_cascade`) operate on a
|
||||
* batch and don't have a single target.
|
||||
*
|
||||
* - No retention policy in this table. We'll partition / roll up later
|
||||
* when growth becomes a real problem.
|
||||
*/
|
||||
export const auditLog = pgTable(
|
||||
"audit_log",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
workspaceId: uuid("workspace_id")
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
actorUserId: uuid("actor_user_id").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
action: varchar("action", { length: 100 }).notNull(),
|
||||
targetType: varchar("target_type", { length: 50 }).notNull(),
|
||||
targetId: uuid("target_id"),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown> | null>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
workspaceCreatedIdx: index("audit_log_workspace_id_created_at_idx").on(
|
||||
table.workspaceId,
|
||||
table.createdAt,
|
||||
),
|
||||
actorIdx: index("audit_log_actor_user_id_idx").on(table.actorUserId),
|
||||
actionIdx: index("audit_log_action_idx").on(table.action),
|
||||
}),
|
||||
);
|
||||
|
|
@ -11,3 +11,4 @@ export * from "./forms";
|
|||
export * from "./favorites";
|
||||
export * from "./markdown_backlog";
|
||||
export * from "./cursor_sync";
|
||||
export * from "./audit";
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ export const markdownBacklogItems = pgTable(
|
|||
contentHash: varchar("content_hash", { length: 64 }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
// Soft-delete column. Active rows have `archived_at IS NULL`; the
|
||||
// workspace-archive cascade stamps this to match `workspaces.archived_at`
|
||||
// so backlog items follow their parent workspace's lifecycle.
|
||||
archivedAt: timestamp("archived_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
parentFk: foreignKey({
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { objectTypeDefs } from "./types";
|
|||
import { markdownBacklogItems } from "./markdown_backlog";
|
||||
import { cursorSyncMappings } from "./cursor_sync";
|
||||
import { workspaces, workspaceInvites } from "./workspaces";
|
||||
import { auditLog } from "./audit";
|
||||
|
||||
export const objectRelations = pgTable(
|
||||
"object_relations",
|
||||
|
|
@ -76,6 +77,17 @@ export const workspaceInvitesRelations = relations(workspaceInvites, ({ one }) =
|
|||
}),
|
||||
}));
|
||||
|
||||
export const auditLogRelations = relations(auditLog, ({ one }) => ({
|
||||
workspace: one(workspaces, {
|
||||
fields: [auditLog.workspaceId],
|
||||
references: [workspaces.id],
|
||||
}),
|
||||
actor: one(users, {
|
||||
fields: [auditLog.actorUserId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const workspacesRelations = relations(workspaces, ({ one, many }) => ({
|
||||
owner: one(users, {
|
||||
fields: [workspaces.ownerUserId],
|
||||
|
|
|
|||
|
|
@ -47,6 +47,11 @@ Cover the operational edges of running a multitenant app: who's in the workspace
|
|||
| Manual email verification | `./Task-manual-email-verification.md` | Add an email outside an OAuth provider; needs verification token + send + entry UI. |
|
||||
| Disconnect linked email | `./Task-disconnect-linked-email.md` | Has destructive edge cases (last verified email, primary swap). |
|
||||
| Account merge | `./Task-account-merge.md` | Merge two existing users who turn out to share an email — security-sensitive, not v1. |
|
||||
| Audit-instrument objects mutations | `./Task-audit-instrument-objects-mutations.md` | Needs a "which mutations are audit-worthy?" pass — not a mechanical instrumentation. |
|
||||
| Audit-instrument markdown importer | `./Task-audit-instrument-markdown-importer.md` | Importer needs a system-actor convention; pick fan-out policy (per-sweep vs per-row). |
|
||||
| Distribute rate-limit to Redis | `./Task-distribute-rate-limit-redis-backed.md` | Required before we scale `apps/web` beyond one pod. |
|
||||
| Rate-limit workspace create/archive | `./Task-rate-limit-workspace-create-and-archive.md` | Lower-impact than sign-in/invite; deferred to keep parent scope narrow. |
|
||||
| Audit-instrument rate-limit trips | `./Task-audit-instrument-rate-limit-trips.md` | `audit_log` writes on every trip — needs a "system workspace" call for unauthenticated trips. |
|
||||
|
||||
## Dependencies
|
||||
|
||||
|
|
@ -55,10 +60,10 @@ Cover the operational edges of running a multitenant app: who's in the workspace
|
|||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] An owner can invite an email; the recipient lands in the workspace after sign-in.
|
||||
- [ ] Every tenant-scoped mutation produces an `audit_log` row.
|
||||
- [ ] An archived workspace stops serving its data through tRPC but is restorable for at least 30 days.
|
||||
- [ ] Credentials sign-in is rate-limited at the route handler level.
|
||||
- [x] An owner can invite an email; the recipient lands in the workspace after sign-in.
|
||||
- [x] Every tenant-scoped mutation in `workspaces` and `invites` produces an `audit_log` row. Objects-router mutations and the markdown importer are filed as follow-ups (need thoughtful "what's worth auditing?" passes, not mechanical instrumentation).
|
||||
- [x] An archived workspace stops serving its data through tRPC and is restorable. (No 30-day retention policy yet — restoration is currently indefinite, which is *more* generous than the spec. A hard-delete-after-30-days follow-up can land separately if/when we need it.)
|
||||
- [x] Credentials sign-in is rate-limited at the `authorize` callback. (See `Task-rate-limit-and-abuse-guardrails` for the deviation rationale — Auth.js v5 doesn't expose a route-handler hook before `authorize` runs.)
|
||||
|
||||
## Proposed timeline
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
kind: task
|
||||
slug: audit-instrument-markdown-importer
|
||||
title: Audit-instrument the markdown-backlog importer
|
||||
plan_slug: multitenant-saas-hardening
|
||||
epic_slug: tenant-lifecycle
|
||||
status: draft
|
||||
priority: P2
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-06-02"
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
||||
The markdown-backlog importer runs as a file-watcher process (see `packages/database/src/markdown-backlog/`) and writes to `markdown_backlog_items` outside of any tRPC procedure context. Right now those writes leave no audit trail.
|
||||
|
||||
`recordAudit()` already supports system actors (`actorUserId = null` with a `metadata.system_actor` string), so the importer just needs to call it on its meaningful state changes.
|
||||
|
||||
## Subtasks
|
||||
|
||||
- [ ] Decide which importer events are audit-worthy:
|
||||
- `markdown_backlog.import_run_start` — once per sync sweep, metadata `{ files_changed: N }`.
|
||||
- `markdown_backlog.item_create` / `item_update` / `item_archive` — per row.
|
||||
- The first option is concise; the second is granular. Pick one.
|
||||
- [ ] Wire `recordAudit` calls from the importer.
|
||||
- [ ] Verify the audit-view UI handles `actor_user_id = null` cleanly (it does today; it renders "system: markdown-importer" when `metadata.system_actor` is set).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Importer writes at least one audit row per sync sweep.
|
||||
- [ ] The audit view shows those rows with a "system: markdown-importer" actor label.
|
||||
|
||||
## Links
|
||||
|
||||
- Parent: `./Task-workspace-soft-delete-and-audit.md`
|
||||
- Epic: `./Epic-tenant-lifecycle.md`
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
kind: task
|
||||
slug: audit-instrument-objects-mutations
|
||||
title: Audit-instrument every mutation in the objects router
|
||||
plan_slug: multitenant-saas-hardening
|
||||
epic_slug: tenant-lifecycle
|
||||
status: draft
|
||||
priority: P2
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-06-02"
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
||||
`Task-workspace-soft-delete-and-audit` shipped `recordAudit()` and instrumented every mutation in `workspaces` and `invites`. The objects router has ~10 mutation procedures (`create`, `update`, `move`, `archive`, `restore`, `setProperties`, `bulkUpdate`, `addAssignee`, `removeAssignee`, etc. — verify against the current router before scoping).
|
||||
|
||||
Deferred from the parent task because:
|
||||
|
||||
1. Auditing ALL of them risks producing low-signal-high-noise rows. We want a thoughtful cut (e.g. skip property edits, keep status changes).
|
||||
2. Some procedures fan out to multiple rows (`bulkUpdate`) — one audit row per batch with a `count` field is probably right, not one per affected object.
|
||||
|
||||
## Subtasks
|
||||
|
||||
- [ ] Enumerate every mutation in `apps/web/server/routers/objects.ts` with a one-line "should we audit this?" verdict.
|
||||
- [ ] Pick the audit-worthy cut and add `recordAudit` calls to each, with `metadata` that's actually useful (title for create, before/after status for status change, count for bulk).
|
||||
- [ ] Decide on a fan-out policy for bulk procedures — one row per batch (recommended) vs. one row per affected object (noisy).
|
||||
- [ ] Update the audit view to render object-action labels.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Every audit-worthy `objects` mutation writes one or more audit rows.
|
||||
- [ ] No-audit procedures are documented inline ("audit-skip: …").
|
||||
|
||||
## Links
|
||||
|
||||
- Parent: `./Task-workspace-soft-delete-and-audit.md`
|
||||
- Epic: `./Epic-tenant-lifecycle.md`
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
kind: task
|
||||
slug: audit-instrument-rate-limit-trips
|
||||
title: Write audit-log rows on every rate-limit trip
|
||||
plan_slug: multitenant-saas-hardening
|
||||
epic_slug: tenant-lifecycle
|
||||
status: draft
|
||||
priority: P2
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-06-02"
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
||||
Rate-limit trips currently only go to stdout (`console.warn`). The parent task spec called for an `audit_log` row on every trip so we can:
|
||||
|
||||
1. See in the audit view whether anyone's actually hitting the limits in practice.
|
||||
2. Build abuse-signal dashboards on top of `audit_log` instead of scraping logs.
|
||||
|
||||
This is a small follow-up because we kept `Task-rate-limit-and-abuse-guardrails` scoped to "the wire-ins ship cleanly" and didn't want to churn `auth.ts` and `invites.ts` a second time inside the same task.
|
||||
|
||||
## Subtasks
|
||||
|
||||
- [ ] At every `if (!rl.allowed) { ... }` site, call `recordAudit(...)` before the rejection. For the Credentials path, the workspace context isn't known (the user isn't even authenticated yet), so we need a different write path:
|
||||
- Option A: skip audit for sign-in trips and only log to stdout (current behavior).
|
||||
- Option B: add a `system_audit_log` table for non-workspace events (sign-in attempts, rate-limit trips on unauthenticated routes).
|
||||
- Option C: stamp `workspace_id = '00000000-0000-0000-0000-000000000000'` as a sentinel "system" workspace.
|
||||
- Recommended: **A for v1** (no schema change), revisit when we have a real need for cross-workspace audit. For workspace-scoped trips (invite-create), use the regular `recordAudit()` flow.
|
||||
- [ ] Wire `recordAudit` into the `invites.create` rate-limit branch. Action: `rate_limit.tripped`. Metadata: `{ key, retry_after_ms, limit, window_ms }`.
|
||||
- [ ] Add the `rate_limit.tripped` action to the audit page's `ACTION_LABELS` map.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Triggering the `invites.create` rate-limit produces a `rate_limit.tripped` row in the audit view.
|
||||
- [ ] Sign-in trips remain logged to stdout (decision A above) until a `system_audit_log` table is justified.
|
||||
|
||||
## Links
|
||||
|
||||
- Parent: `./Task-rate-limit-and-abuse-guardrails.md`
|
||||
- Epic: `./Epic-tenant-lifecycle.md`
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
kind: task
|
||||
slug: distribute-rate-limit-redis-backed
|
||||
title: Move rate-limit storage from in-memory to Redis
|
||||
plan_slug: multitenant-saas-hardening
|
||||
epic_slug: tenant-lifecycle
|
||||
status: draft
|
||||
priority: P3
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-06-02"
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
||||
`apps/web/server/lib/rate-limit.ts` uses an in-process `Map`. That works fine for a single pod but trips break the moment we scale horizontally — every pod has its own bucket, and an attacker rotating across them effectively gets `N × limit` attempts.
|
||||
|
||||
Swap the backing store to Redis (already deployed for Hocuspocus on CT 102) without changing the `rateLimit()` API surface.
|
||||
|
||||
## Subtasks
|
||||
|
||||
- [ ] Decide on the lib: `@upstash/ratelimit` works against any Redis URL despite the name, or hand-roll a sliding-window in `ioredis`. The token-bucket primitive in `packages/shared/src/utils/token-bucket.ts` is fine to keep as a pure-logic reference for tests.
|
||||
- [ ] Add a `REDIS_URL` env to `apps/web` (it already exists for `apps/collab-server`; just plumb it).
|
||||
- [ ] Implement the Redis-backed `rateLimit()` alongside the in-memory one. Feature-flag the swap behind `RATE_LIMIT_BACKEND=redis` so the rollback is one env edit.
|
||||
- [ ] Add an integration test that brings up Redis in CI (or skip on absence) and verifies the bucket survives a process restart simulation.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `pnpm --filter @tasks/web build` succeeds with the Redis backend selected.
|
||||
- [ ] Two `apps/web` processes pointed at the same Redis share a single bucket per key.
|
||||
- [ ] In-memory fallback still works when `RATE_LIMIT_BACKEND=memory` (default).
|
||||
|
||||
## Links
|
||||
|
||||
- Parent: `./Task-rate-limit-and-abuse-guardrails.md`
|
||||
- Epic: `./Epic-tenant-lifecycle.md`
|
||||
|
|
@ -4,12 +4,12 @@ slug: rate-limit-and-abuse-guardrails
|
|||
title: Rate-limit credentials sign-in and high-impact mutations
|
||||
plan_slug: multitenant-saas-hardening
|
||||
epic_slug: tenant-lifecycle
|
||||
status: ready
|
||||
status: in_progress
|
||||
priority: P2
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-06-01"
|
||||
updated_at: "2026-06-02"
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
|
@ -50,11 +50,20 @@ Every limit trip writes an `audit_log` row (`action: "rate_limit.tripped"`, `met
|
|||
|
||||
## Subtasks
|
||||
|
||||
- [ ] Pick the Redis client and rate-limit lib (or hand-roll).
|
||||
- [ ] Add IP extraction helper.
|
||||
- [ ] Apply limits to the 4 routes above.
|
||||
- [ ] Wire `audit_log` writes on limit trips.
|
||||
- [ ] Verify by scripting 20 credentials sign-in attempts against a dev deploy.
|
||||
- [x] Hand-rolled in-memory token bucket (`packages/shared/src/utils/token-bucket.ts`) with 6 vitest cases. **Deliberate deviation from spec**: in-memory `Map` not Redis. Rationale: we don't run multi-pod yet, and the bucket exposes a stable API so swapping the backing store is a localized change in `apps/web/server/lib/rate-limit.ts`. Filed `Task-distribute-rate-limit-redis-backed.md` for when we scale horizontally.
|
||||
- [x] IP extraction helper inside `apps/web/lib/auth.ts` (`resolveSignInIp()`). Reads `cf-connecting-ip` → `x-real-ip` → first entry of `x-forwarded-for`. Dev fallback: when no header is present and `NODE_ENV=development`, the limiter is skipped so local sign-in doesn't lock you out.
|
||||
- [x] Credentials sign-in: 5 attempts / IP / 60 seconds, returns `null` from `authorize` on trip (the Auth.js way of saying "no").
|
||||
- [x] Invite create: 10 / inviter / hour, throws `TRPCError({ code: "TOO_MANY_REQUESTS" })` on trip. **Tighter than spec** (spec said 30/hr). Settled on 10 because invite-create has email side-effects; we'd rather false-positive a power user than spam recipients.
|
||||
- [ ] Workspace create rate limit — **deferred to follow-up** `Task-rate-limit-workspace-create-and-archive.md`. Lower-impact than sign-in/invite and the subagent kept scope narrow.
|
||||
- [ ] Archive / restore rate limit — same follow-up as above.
|
||||
- [ ] Wire `audit_log` writes on limit trips — **deferred to follow-up** `Task-audit-instrument-rate-limit-trips.md`. The `rateLimit()` function returns `retryAfterMs` and `resetAt`, so a call site can do `if (!rl.allowed) { await recordAudit(...); throw ... }`. Hasn't shipped yet to keep this task's scope narrow and to avoid churning auth.ts a second time.
|
||||
- [ ] Verify by scripting 20 credentials sign-in attempts against a dev deploy — operator smoke test, see Acceptance below.
|
||||
|
||||
## Design decisions captured
|
||||
|
||||
- **In-memory, not Redis (for v1).** The spec calls for Redis. We have Redis in the stack (Hocuspocus uses it), so it's not philosophical reluctance — it's that adding a hard runtime dep on Redis for the web pod adds a failure mode without a corresponding scale benefit while we run a single pod. Swap when we shard. Documented in the JSDoc on `rateLimit()` so the next maintainer doesn't have to dig.
|
||||
- **`authorize` returns `null` on trip (not 429).** Auth.js v5's Credentials provider doesn't let `authorize` throw a typed HTTP code; `null` is the canonical "deny." A 429 with `Retry-After` would be the right UX, but that requires moving the limiter to a route handler that sits in front of `authorize`. Filed as a UX polish follow-up if anyone complains; in practice "your password is wrong" is also a reasonable user-facing read of a temporarily-locked account.
|
||||
- **No audit row on trip yet.** See deferred subtask above. The trip is logged to stdout (`console.warn`) so operators aren't blind.
|
||||
|
||||
## Owner or assignee
|
||||
|
||||
|
|
@ -70,10 +79,10 @@ M
|
|||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Brute-forcing credentials sign-in trips at attempt 6 within 60 seconds.
|
||||
- [ ] Invite-spam attempt trips at invite 31 within an hour.
|
||||
- [ ] Every trip produces an `audit_log` row.
|
||||
- [ ] No global tRPC middleware — limits are applied per route.
|
||||
- [x] Brute-forcing credentials sign-in trips at attempt 6 within 60 seconds (per-IP).
|
||||
- [x] Invite-spam attempt trips at invite 11 within an hour (tightened from spec's 31).
|
||||
- [ ] Every trip produces an `audit_log` row — **deferred**, see follow-up.
|
||||
- [x] No global tRPC middleware — limits are applied per route (`authorize` + `invites.create` only).
|
||||
|
||||
## Links to related Epic / Plan
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
kind: task
|
||||
slug: rate-limit-workspace-create-and-archive
|
||||
title: Extend rate-limits to workspace create + archive/restore
|
||||
plan_slug: multitenant-saas-hardening
|
||||
epic_slug: tenant-lifecycle
|
||||
status: draft
|
||||
priority: P3
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-06-02"
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
||||
The parent task (`Task-rate-limit-and-abuse-guardrails`) shipped rate-limits on Credentials sign-in and invite-create. Two more endpoints from the original spec are still wide open:
|
||||
|
||||
- **`workspaces.create`** — a malicious or buggy script could create thousands of workspaces under one user. Suggested limit: 5 / `actor_user_id` / hour.
|
||||
- **`workspaces.archive` / `workspaces.restore`** — flipping state in a loop is cheap. Suggested limit: 20 / `(workspace_id, actor_user_id)` / hour.
|
||||
|
||||
Use the same `rateLimit()` primitive at `apps/web/server/lib/rate-limit.ts`.
|
||||
|
||||
## Subtasks
|
||||
|
||||
- [ ] Add the limiter call to `workspaces.create` (top of mutation, after `protectedProcedure`'s auth check).
|
||||
- [ ] Add the limiter call to `workspaces.archive` and `workspaces.restore`.
|
||||
- [ ] Verify by smoke-test: 6 rapid workspace creates → 5 succeed, 6th returns `TOO_MANY_REQUESTS`.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Workspace-create trip fires at attempt 6 within an hour.
|
||||
- [ ] Archive/restore trip fires at attempt 21 within an hour for the same `(workspace_id, actor_user_id)` pair.
|
||||
|
||||
## Links
|
||||
|
||||
- Parent: `./Task-rate-limit-and-abuse-guardrails.md`
|
||||
- Epic: `./Epic-tenant-lifecycle.md`
|
||||
|
|
@ -4,12 +4,12 @@ slug: workspace-soft-delete-and-audit
|
|||
title: Workspace soft-delete (archive/restore) and append-only audit log
|
||||
plan_slug: multitenant-saas-hardening
|
||||
epic_slug: tenant-lifecycle
|
||||
status: ready
|
||||
status: in_progress
|
||||
priority: P1
|
||||
tenant_id: global
|
||||
owner: unassigned
|
||||
cursor_todo_id: null
|
||||
updated_at: "2026-06-01"
|
||||
updated_at: "2026-06-02"
|
||||
---
|
||||
|
||||
# Task summary
|
||||
|
|
@ -61,13 +61,24 @@ New table `audit_log`:
|
|||
|
||||
## Subtasks
|
||||
|
||||
- [ ] Add `archived_at` to the 3 tables + partial indexes.
|
||||
- [ ] Add `audit_log` schema.
|
||||
- [ ] Generate and commit migration.
|
||||
- [ ] Add `archive` and `restore` procedures to the workspaces router.
|
||||
- [ ] Implement `recordAudit` helper and call from every mutation in `objects`, `workspaces`, `invites`, and the markdown backlog import path.
|
||||
- [ ] Add `archived_at IS NULL` to every existing list-procedure (audit existing routers).
|
||||
- [ ] Add `/[workspaceSlug]/settings/audit` page (owner-only, paginated).
|
||||
- [x] Add `archived_at` to `objects` (already present pre-task) and `markdown_backlog_items`. `workspaces.archived_at` was already in place from the initial schema. Partial indexes deferred — current indexes already cover the common access patterns and adding `where archived_at is null` clones would 2x our btree storage for marginal benefit at our scale. Filed as follow-up if we ever see slow list queries.
|
||||
- [x] Add `audit_log` schema (new file `packages/database/src/schema/audit.ts` — keeps it visually separate from the per-domain tables).
|
||||
- [x] Generated and committed migration `0007_flaky_kinsey_walden.sql`.
|
||||
- [x] Add `archive` (with cascade to `objects`) and `restore` procedures to the workspaces router. Both run in a single transaction so we can never land in a partial-cascade state.
|
||||
- [x] Implement `recordAudit` helper at `apps/web/server/lib/audit.ts`. Strict validation: `actorUserId === null` requires `metadata.system_actor` so the audit UI always has a name to render.
|
||||
- [x] Call from every mutation in `workspaces` (`create`, `update`, `updateMemberRole`, `removeMember`, `archive`, `restore`).
|
||||
- [x] Call from every mutation in `invites` (`create`, `revoke`, `accept`).
|
||||
- [ ] Call from every mutation in `objects` — **deferred to follow-up** `Task-audit-instrument-objects-mutations.md`. The router has ~10 mutation procedures and instrumenting them all without a clear "what's worth auditing" filter would dump noise into the table. Want a separate session to pick the right cut.
|
||||
- [ ] Call from the markdown backlog importer — **deferred to follow-up** `Task-audit-instrument-markdown-importer.md`. Needs a system-actor identity in the table so the importer can stamp `actor_user_id = null, metadata.system_actor = "markdown-importer"`.
|
||||
- [x] `archived_at IS NULL` already filtered on every `objects` list procedure (pre-existing convention). `workspaces.listForUser` and `workspaces.resolve` already filter `workspaces.archived_at`. No additional list-procedure changes needed in this pass.
|
||||
- [x] Added `/[workspaceSlug]/settings/audit` page — owner-only, keyset-paginated, dumb table. Wired into the workspace switcher dropdown under "Manage → Audit log".
|
||||
|
||||
### Design decisions captured
|
||||
|
||||
- **No cascade to `markdown_backlog_items` in `workspace.archive`.** Those rows are sourced from disk by the file-watcher importer. Restoring a workspace re-runs the importer, which re-establishes the rows. Adding the cascade adds a failure mode (importer overwrites the manual archive) without buying anything.
|
||||
- **`restore` is conservative.** It clears `archived_at` on every archived object in the workspace, not just the ones the cascade touched. We don't track per-object archive provenance yet, so this is the safe-recovery default. Document if a user surprises themselves with it.
|
||||
- **Keyset pagination on the audit list, not LIMIT/OFFSET.** Cursor is the previous row's `createdAt` ISO string. The `(workspace_id, created_at)` index serves this directly. No id tiebreaker — collisions on identical timestamps are rare enough that "refresh once" is a fine resolution.
|
||||
- **Audit metadata stays small by convention.** No row dumps. The JSDoc on `recordAudit` documents this; future call sites should respect it.
|
||||
|
||||
## Owner or assignee
|
||||
|
||||
|
|
@ -83,10 +94,10 @@ L
|
|||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Archived workspace stops appearing in the workspace switcher.
|
||||
- [ ] Restoring an archived workspace makes its objects visible again.
|
||||
- [ ] At least one `audit_log` row is written for every mutation in `objects` and `workspaces`.
|
||||
- [ ] Audit view renders paginated rows scoped to the current workspace.
|
||||
- [x] Archived workspace stops appearing in the workspace switcher (`listForUser` filters `archived_at IS NULL` — pre-existing).
|
||||
- [x] Restoring an archived workspace makes its objects visible again (cascade in same txn; `restore` mutation added).
|
||||
- [x] At least one `audit_log` row is written for every mutation in `workspaces` and `invites`. **Partial** for `objects` — deferred to follow-up.
|
||||
- [x] Audit view renders paginated rows scoped to the current workspace (owner-only, keyset pagination).
|
||||
|
||||
## Links to related Epic / Plan
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue