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
35 changes: 35 additions & 0 deletions packages/backend/migrations/050_soft_delete_projects.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
-- ============================================================================
-- Migration 050: Soft-delete support for projects
-- ============================================================================
-- Adds a deleted_at column so project deletion is reversible during a grace
-- window (default 30 days). A background worker later hard-deletes rows
-- past the grace window (see migration 051 and the purge worker in worker.ts).
--
-- Also replaces hard uniqueness constraints on slug and (org, name) with
-- partial-index variants (WHERE deleted_at IS NULL) so a soft-deleted project
-- no longer "occupies" its name or slug, allowing the same values to be reused
-- for new projects.
-- ============================================================================

-- 1. Add deleted_at column (NULL = active, non-NULL = soft-deleted)
ALTER TABLE projects ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ NULL;

-- 2. Index to support the purge worker's "find projects past grace window" query
CREATE INDEX IF NOT EXISTS idx_projects_deleted_at
ON projects (deleted_at)
WHERE deleted_at IS NOT NULL;

-- 3. Replace the global slug unique index (added in migration 036) with a
-- per-org partial one. Slugs need only be unique within an organization,
-- and soft-deleted projects no longer occupy their slug.
DROP INDEX IF EXISTS idx_projects_slug_unique;
CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug_unique
ON projects (organization_id, slug)
WHERE deleted_at IS NULL;

-- 4. Replace the (organization_id, name) unique constraint from the original
-- schema with a partial unique index.
ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_organization_id_name_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_org_name_unique
ON projects (organization_id, name)
WHERE deleted_at IS NULL;
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- ============================================================================
-- Migration 051: Drop ON DELETE CASCADE from logs / spans / metrics project FK
-- ============================================================================
-- CAUTION: This migration swaps FK constraints on TimescaleDB hypertables.
-- On large deployments (50M+ rows) the constraint rebuild can take considerable
-- time. Apply during a low-traffic window and verify the query plan beforehand.
--
-- Rationale: with soft-delete in place (migration 050), the projects row is
-- never immediately removed — it stays in the DB throughout the 30-day grace
-- window. The hard-delete purge worker explicitly calls reservoir.purgeProject()
-- (which deletes logs/spans/metrics by project_id) *before* removing the
-- projects row, making the cascade redundant.
-- Dropping it prevents an accidental bulk data-loss if a projects row is
-- ever hard-deleted outside the controlled purge path.
-- ============================================================================

-- logs (TimescaleDB hypertable)
ALTER TABLE logs DROP CONSTRAINT IF EXISTS logs_project_id_fkey;
ALTER TABLE logs ADD CONSTRAINT logs_project_id_fkey
FOREIGN KEY (project_id) REFERENCES projects(id);

-- spans (TimescaleDB hypertable)
ALTER TABLE spans DROP CONSTRAINT IF EXISTS spans_project_id_fkey;
ALTER TABLE spans ADD CONSTRAINT spans_project_id_fkey
FOREIGN KEY (project_id) REFERENCES projects(id);

-- metrics (TimescaleDB hypertable)
ALTER TABLE metrics DROP CONSTRAINT IF EXISTS metrics_project_id_fkey;
ALTER TABLE metrics ADD CONSTRAINT metrics_project_id_fkey
FOREIGN KEY (project_id) REFERENCES projects(id);
12 changes: 0 additions & 12 deletions packages/backend/scripts/tenant-scope-allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -251,12 +251,6 @@
"snippet": "await db.updateTable('projects').set(updates).where('id', '=', projectId).execute();",
"reason": "safe-by-PK: one-shot boot backfill updates all uninitialized projects by PK from a full scan"
},
{
"file": "packages/backend/src/modules/projects/service.ts",
"table": "projects",
"snippet": ".deleteFrom('projects')",
"reason": "safe-by-PK: DELETE by projectId; caller verifies project ownership via getProjectById first"
},
{
"file": "packages/backend/src/modules/projects/service.ts",
"table": "projects",
Expand Down Expand Up @@ -299,12 +293,6 @@
"snippet": ".selectFrom('detection_events')",
"reason": "scoped-indirect: SELECT by id IN eventIds where eventIds came from prior org-scoped query"
},
{
"file": "packages/backend/src/queue/jobs/log-pipeline.ts",
"table": "logs",
"snippet": ".updateTable('logs')",
"reason": "safe-by-PK: UPDATE by log id from job payload; log IDs were just ingested in the same project/org context"
},
{
"file": "packages/backend/src/scripts/seed-massive-data.ts",
"table": "detection_events",
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/database/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export interface ProjectsTable {
has_logs_at: Timestamp | null;
has_traces_at: Timestamp | null;
has_metrics_at: Timestamp | null;
deleted_at: Generated<Timestamp | null>;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
}
Expand Down
6 changes: 3 additions & 3 deletions packages/backend/src/modules/api-keys/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export async function apiKeysRoutes(fastify: FastifyInstance) {

// Check if user has access to the project
const project = await projectsService.getProjectById(projectId, request.user.id);
if (!project) {
if (!project || project.deletedAt) {
return reply.status(404).send({
error: 'Project not found or access denied',
});
Expand All @@ -66,7 +66,7 @@ export async function apiKeysRoutes(fastify: FastifyInstance) {

// Check if user has access to the project
const project = await projectsService.getProjectById(projectId, request.user.id);
if (!project) {
if (!project || project.deletedAt) {
return reply.status(404).send({
error: 'Project not found or access denied',
});
Expand Down Expand Up @@ -121,7 +121,7 @@ export async function apiKeysRoutes(fastify: FastifyInstance) {

// Check if user has access to the project
const project = await projectsService.getProjectById(projectId, request.user.id);
if (!project) {
if (!project || project.deletedAt) {
return reply.status(404).send({
error: 'Project not found or access denied',
});
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/modules/api-keys/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export class ApiKeysService {
])
.where('api_keys.key_hash', '=', keyHash)
.where('api_keys.revoked', '=', false)
.where('projects.deleted_at', 'is', null)
.executeTakeFirst();

if (!result) {
Expand Down
3 changes: 3 additions & 0 deletions packages/backend/src/modules/audit-log/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export const AUDIT_ACTIONS = {
'project.created': 'config_change',
'project.updated': 'config_change',
'project.deleted': 'data_modification',
'project.soft_delete': 'data_modification',
'project.restored': 'data_modification',
'project.hard_delete': 'data_modification',
// api keys
'apikey.created': 'config_change',
'apikey.revoked': 'config_change',
Expand Down
67 changes: 61 additions & 6 deletions packages/backend/src/modules/projects/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,33 @@ const orgQuerySchema = z.object({
organizationId: z.string().uuid('organizationId must be a valid uuid'),
});

const orgQueryWithDeletedSchema = z.object({
organizationId: z.string().uuid('organizationId must be a valid uuid'),
includeDeleted: z.enum(['true', 'false']).optional(),
});

export async function projectsRoutes(fastify: FastifyInstance) {
// All routes require authentication
fastify.addHook('onRequest', authenticate);

// Get all projects for an organization
fastify.get('/', async (request: any, reply) => {
let organizationId: string;
let includeDeleted = false;
try {
({ organizationId } = orgQuerySchema.parse(request.query));
const parsed = orgQueryWithDeletedSchema.parse(request.query);
organizationId = parsed.organizationId;
includeDeleted = parsed.includeDeleted === 'true';
} catch {
return reply.status(400).send({
error: 'organizationId query parameter is required',
});
}

try {
const projects = await projectsService.getOrganizationProjects(organizationId, request.user.id);
const projects = includeDeleted
? await projectsService.getOrganizationProjectsIncludingDeleted(organizationId, request.user.id)
: await projectsService.getOrganizationProjects(organizationId, request.user.id);
return reply.send({ projects });
} catch (error) {
if (error instanceof Error && error.message.includes('do not have access')) {
Expand Down Expand Up @@ -119,7 +129,7 @@ export async function projectsRoutes(fastify: FastifyInstance) {
searchMode: 'substring',
limit: 1,
}).catch(() => ({ logs: [] })),

// Efficient check for existence of a session_id
db.selectFrom('logs')
.select('id')
Expand Down Expand Up @@ -148,14 +158,48 @@ export async function projectsRoutes(fastify: FastifyInstance) {
}
});

// Restore a soft-deleted project
fastify.post('/:id/restore', async (request: any, reply) => {
try {
const { id } = projectIdSchema.parse(request.params);

const project = await projectsService.getProjectById(id, request.user.id);
if (!project) {
return reply.status(404).send({ error: 'Project not found' });
}
if (!project.deletedAt) {
return reply.status(409).send({ error: 'Project is not deleted' });
}

const restored = await projectsService.restoreProject(id, request.user.id);
if (!restored) {
return reply.status(404).send({ error: 'Project not found' });
}

await auditLogService.record({
action: 'project.restored',
target: { type: 'project', id },
organizationId: project.organizationId,
});

const updated = await projectsService.getProjectById(id, request.user.id);
return reply.send({ project: updated });
} catch (error) {
if (error instanceof z.ZodError) {
return reply.status(400).send({ error: 'Invalid project ID format' });
}
throw error;
}
});

// Get a single project
fastify.get('/:id', async (request: any, reply) => {
try {
const { id } = projectIdSchema.parse(request.params);

const project = await projectsService.getProjectById(id, request.user.id);

if (!project) {
if (!project || project.deletedAt) {
return reply.status(404).send({
error: 'Project not found',
});
Expand Down Expand Up @@ -266,13 +310,18 @@ export async function projectsRoutes(fastify: FastifyInstance) {
error: 'A project with this slug already exists in this organization',
});
}
if (error.message.includes('Cannot update a deleted project')) {
return reply.status(409).send({
error: error.message,
});
}
}

throw error;
}
});

// Delete a project
// Soft-delete a project
fastify.delete('/:id', async (request: any, reply) => {
try {
const { id } = projectIdSchema.parse(request.params);
Expand All @@ -283,6 +332,11 @@ export async function projectsRoutes(fastify: FastifyInstance) {
error: 'Project not found',
});
}
if (project.deletedAt) {
return reply.status(409).send({
error: 'Project is already deleted',
});
}

const deleted = await projectsService.deleteProject(id, request.user.id);

Expand All @@ -293,9 +347,10 @@ export async function projectsRoutes(fastify: FastifyInstance) {
}

await auditLogService.record({
action: 'project.deleted',
action: 'project.soft_delete',
target: { type: 'project', id },
organizationId: project.organizationId,
metadata: { name: project.name },
});

return reply.status(204).send();
Expand Down
Loading
Loading