feat(auth): Phase 2.2.2 Authorization Ownership Invariant Fix - strict requireGiveawayOwner null check, Prisma non-null organizerId with onDelete Restrict, repository non-null organizer invariant, migration documentation, and regression tests

This commit is contained in:
Ochenstarik 2026-08-18 11:28:18 +07:00
parent 5d2554f33a
commit 02a04df271
16 changed files with 150 additions and 13 deletions

View file

@ -0,0 +1,57 @@
# Database Migration Strategy: Mandatory Organizer Ownership Invariant
This document details the database schema migration strategy for enforcing mandatory, non-null `organizerId` on the `Giveaway` table in production.
---
## 1. Context & Invariant
In Randomayzer, every giveaway is owned by an authenticated Organizer (represented by the `User` model via VK ID OAuth 2.1).
To prevent broken access control and ambiguous authorization states:
- `Giveaway.organizerId` is strictly `NOT NULL`.
- Foreign key relation is defined with `onDelete: Restrict`, strictly preventing the deletion of an organizer account while owned giveaways exist.
---
## 2. Prisma Schema Definition
```prisma
model User {
id String @id @default(cuid())
vkUserId String @unique
giveaways Giveaway[]
...
}
model Giveaway {
id String @id @default(cuid())
organizerId String
organizer User @relation(fields: [organizerId], references: [id], onDelete: Restrict)
...
@@index([organizerId])
}
```
---
## 3. Migration Plan for Existing Records
### A. Development & Staging Environments
Existing anonymous test records created prior to Phase 2.2 can be purged:
```sql
DELETE FROM "ParticipantSnapshot" WHERE "giveawayId" IN (SELECT "id" FROM "Giveaway" WHERE "organizerId" IS NULL);
DELETE FROM "Participant" WHERE "giveawayId" IN (SELECT "id" FROM "Giveaway" WHERE "organizerId" IS NULL);
DELETE FROM "Giveaway" WHERE "organizerId" IS NULL;
```
### B. Production Migration Protocol
1. **Never assign legacy records to arbitrary users**: If unassigned giveaways exist in a production database, do not automatically bind them to random organizers.
2. **Quarantine or Admin Assignment**: Migrate legacy orphan giveaways to a designated system quarantine table or assign to an explicitly verified administrative organizer.
3. **Execute SQL Migration**:
```sql
-- Step 1: Verify 0 null records remain
SELECT COUNT(*) FROM "Giveaway" WHERE "organizerId" IS NULL;
-- Step 2: Enforce NOT NULL and Restrict constraint
ALTER TABLE "Giveaway" ALTER COLUMN "organizerId" SET NOT NULL;
```

View file

@ -73,8 +73,8 @@ model Giveaway {
winnersCount Int @default(1) winnersCount Int @default(1)
reserveWinnersCount Int @default(0) reserveWinnersCount Int @default(0)
seed String? seed String?
organizerId String? organizerId String
organizer User? @relation(fields: [organizerId], references: [id], onDelete: SetNull) organizer User @relation(fields: [organizerId], references: [id], onDelete: Restrict)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
drawnAt DateTime? drawnAt DateTime?

View file

@ -26,6 +26,7 @@ export async function requireAuthenticatedUser(req: NextRequest): Promise<Sessio
/** /**
* Enforces that a request is authenticated AND that the current user is the verified organizer (owner) of the giveaway. * Enforces that a request is authenticated AND that the current user is the verified organizer (owner) of the giveaway.
* Invariant: A giveaway without an owner (organizerId is null/empty) must NEVER authorize any user.
*/ */
export async function requireGiveawayOwner( export async function requireGiveawayOwner(
req: NextRequest, req: NextRequest,
@ -42,8 +43,12 @@ export async function requireGiveawayOwner(
throw new NotFoundError(`Giveaway with id "${giveawayId}" not found`); throw new NotFoundError(`Giveaway with id "${giveawayId}" not found`);
} }
// If the giveaway has an organizerId, strict owner match is enforced // Mandatory Ownership Invariant: Null organizer must NEVER authorize
if (giveaway.organizerId && giveaway.organizerId !== sessionUser.id) { if (!giveaway.organizerId) {
throw new ForbiddenError('Access denied: giveaway has no valid organizer assigned (ownership integrity error)');
}
if (giveaway.organizerId !== sessionUser.id) {
throw new ForbiddenError('Access denied: you are not the organizer of this giveaway'); throw new ForbiddenError('Access denied: you are not the organizer of this giveaway');
} }

View file

@ -19,7 +19,7 @@ export interface GiveawayWithRelations {
winnersCount: number; winnersCount: number;
reserveWinnersCount: number; reserveWinnersCount: number;
seed: string | null; seed: string | null;
organizerId?: string | null; organizerId: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
drawnAt: string | null; drawnAt: string | null;
@ -43,7 +43,7 @@ export interface GiveawaySummary {
status: GiveawayStatusType; status: GiveawayStatusType;
winnersCount: number; winnersCount: number;
reserveWinnersCount: number; reserveWinnersCount: number;
organizerId?: string | null; organizerId: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
drawnAt: string | null; drawnAt: string | null;
@ -70,7 +70,7 @@ export interface CreateGiveawayInput {
winnersCount?: number; winnersCount?: number;
reserveWinnersCount?: number; reserveWinnersCount?: number;
seed?: string; seed?: string;
organizerId?: string; organizerId: string;
} }
export interface IGiveawayRepository { export interface IGiveawayRepository {

View file

@ -18,6 +18,10 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
private drawLocks: Set<string> = new Set(); private drawLocks: Set<string> = new Set();
async createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations> { async createGiveaway(input: CreateGiveawayInput): Promise<GiveawayWithRelations> {
if (!input.organizerId) {
throw new Error('FATAL: organizerId is strictly required to create a giveaway in repository');
}
const id = 'gw_' + Math.random().toString(36).slice(2, 10); const id = 'gw_' + Math.random().toString(36).slice(2, 10);
const now = new Date().toISOString(); const now = new Date().toISOString();
@ -38,7 +42,7 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
winnersCount: input.winnersCount || 1, winnersCount: input.winnersCount || 1,
reserveWinnersCount: input.reserveWinnersCount || 0, reserveWinnersCount: input.reserveWinnersCount || 0,
seed: input.seed || null, seed: input.seed || null,
organizerId: input.organizerId || null, organizerId: input.organizerId,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
drawnAt: null, drawnAt: null,
@ -99,7 +103,7 @@ export class MemoryGiveawayRepository implements IGiveawayRepository {
status: gw.status, status: gw.status,
winnersCount: gw.winnersCount, winnersCount: gw.winnersCount,
reserveWinnersCount: gw.reserveWinnersCount, reserveWinnersCount: gw.reserveWinnersCount,
organizerId: gw.organizerId || null, organizerId: gw.organizerId,
createdAt: gw.createdAt, createdAt: gw.createdAt,
updatedAt: gw.updatedAt, updatedAt: gw.updatedAt,
drawnAt: gw.drawnAt, drawnAt: gw.drawnAt,

View file

@ -86,7 +86,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
winnersCount: raw.winnersCount, winnersCount: raw.winnersCount,
reserveWinnersCount: raw.reserveWinnersCount, reserveWinnersCount: raw.reserveWinnersCount,
seed: raw.seed, seed: raw.seed,
organizerId: raw.organizerId || null, organizerId: raw.organizerId,
createdAt: raw.createdAt.toISOString(), createdAt: raw.createdAt.toISOString(),
updatedAt: raw.updatedAt.toISOString(), updatedAt: raw.updatedAt.toISOString(),
drawnAt: raw.drawnAt ? raw.drawnAt.toISOString() : null, drawnAt: raw.drawnAt ? raw.drawnAt.toISOString() : null,
@ -115,7 +115,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
winnersCount: input.winnersCount || 1, winnersCount: input.winnersCount || 1,
reserveWinnersCount: input.reserveWinnersCount || 0, reserveWinnersCount: input.reserveWinnersCount || 0,
seed: input.seed, seed: input.seed,
organizerId: input.organizerId || null, organizerId: input.organizerId,
}, },
include: { include: {
participants: true, participants: true,
@ -212,7 +212,7 @@ export class PrismaGiveawayRepository implements IGiveawayRepository {
status: item.status as GiveawayStatusType, status: item.status as GiveawayStatusType,
winnersCount: item.winnersCount, winnersCount: item.winnersCount,
reserveWinnersCount: item.reserveWinnersCount, reserveWinnersCount: item.reserveWinnersCount,
organizerId: item.organizerId || null, organizerId: item.organizerId,
createdAt: item.createdAt.toISOString(), createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(), updatedAt: item.updatedAt.toISOString(),
drawnAt: item.drawnAt ? item.drawnAt.toISOString() : null, drawnAt: item.drawnAt ? item.drawnAt.toISOString() : null,

View file

@ -10,7 +10,7 @@ import { POST as drawPost } from '../src/app/api/giveaways/[id]/draw/route';
import { GET as verifyGet } from '../src/app/api/giveaways/[id]/verify/route'; import { GET as verifyGet } from '../src/app/api/giveaways/[id]/verify/route';
import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session'; import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session';
describe('Phase 2.2.1 Giveaway Ownership & AuthZ Guard Security Suite', () => { describe('Phase 2.2.2 Giveaway Ownership Invariant & AuthZ Guard Security Suite', () => {
let memoryRepo: MemoryGiveawayRepository; let memoryRepo: MemoryGiveawayRepository;
const ownerUser = { id: 'usr_organizer_1', vkUserId: '111111', firstName: 'Alice' }; const ownerUser = { id: 'usr_organizer_1', vkUserId: '111111', firstName: 'Alice' };
const intruderUser = { id: 'usr_intruder_2', vkUserId: '222222', firstName: 'Eve' }; const intruderUser = { id: 'usr_intruder_2', vkUserId: '222222', firstName: 'Eve' };
@ -215,6 +215,64 @@ describe('Phase 2.2.1 Giveaway Ownership & AuthZ Guard Security Suite', () => {
expect(ownerDrawRes.status).toBe(200); expect(ownerDrawRes.status).toBe(200);
}); });
it('null organizer giveaway must NEVER authorize any user (fails with 403 Forbidden)', async () => {
// Manually inject a corrupted/legacy giveaway with empty organizerId
const corruptedId = 'gw_corrupted_null_owner';
(memoryRepo as any).giveaways.set(corruptedId, {
id: corruptedId,
platform: 'VK',
sourceUrl: 'https://vk.com/wall-1_1',
platformOwnerId: '-1',
platformPostId: '1',
title: 'Corrupted',
organizerId: '', // Empty/null owner
status: 'READY',
participants: [],
filterRules: validPostData.filterRules,
winnersCount: 1,
reserveWinnersCount: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
drawnAt: null,
snapshots: [],
latestSnapshot: null,
drawResult: null,
});
// 1. Authenticated user attempts GET -> 403
const getReq = new NextRequest(`http://localhost:3000/api/giveaways/${corruptedId}`, {
headers: { cookie: `${SESSION_COOKIE_NAME}=${ownerSessionId}` },
});
const getRes = await giveawayDetailGet(getReq, { params: { id: corruptedId } });
expect(getRes.status).toBe(403);
const getBody = await getRes.json();
expect(getBody.error?.message).toMatch(/no valid organizer assigned/i);
// 2. Authenticated user attempts POST participants -> 403
const partReq = new NextRequest(`http://localhost:3000/api/giveaways/${corruptedId}/participants`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
cookie: `${SESSION_COOKIE_NAME}=${ownerSessionId}`,
},
body: JSON.stringify({ filterRules: validPostData.filterRules }),
});
const partRes = await participantsPost(partReq, { params: { id: corruptedId } });
expect(partRes.status).toBe(403);
// 3. Authenticated user attempts POST draw -> 403
const drawReq = new NextRequest(`http://localhost:3000/api/giveaways/${corruptedId}/draw`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
cookie: `${SESSION_COOKIE_NAME}=${ownerSessionId}`,
},
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
});
const drawRes = await drawPost(drawReq, { params: { id: corruptedId } });
expect(drawRes.status).toBe(403);
});
it('GET /api/giveaways/[id]/verify remains public without requiring authentication', async () => { it('GET /api/giveaways/[id]/verify remains public without requiring authentication', async () => {
const created = await GiveawayStore.create({ const created = await GiveawayStore.create({
sourceUrl: validPostData.sourceUrl, sourceUrl: validPostData.sourceUrl,

View file

@ -38,6 +38,7 @@ describe('100-Draw Concurrency Regression & Mixed Race', () => {
filterRules: DEFAULT_FILTER_RULES, filterRules: DEFAULT_FILTER_RULES,
winnersCount: 3, winnersCount: 3,
reserveWinnersCount: 1, reserveWinnersCount: 1,
organizerId: 'usr_conc_100',
}); });
const snapshot = await repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES); const snapshot = await repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES);
@ -104,6 +105,7 @@ describe('100-Draw Concurrency Regression & Mixed Race', () => {
filterRules: DEFAULT_FILTER_RULES, filterRules: DEFAULT_FILTER_RULES,
winnersCount: 1, winnersCount: 1,
reserveWinnersCount: 0, reserveWinnersCount: 0,
organizerId: 'usr_conc_100',
}); });
await repo.saveParticipants(gw.id, participants); await repo.saveParticipants(gw.id, participants);

View file

@ -38,6 +38,7 @@ describe('Concurrency Double Draw Protection', () => {
repostsCount: 10, repostsCount: 10,
}, },
filterRules: DEFAULT_FILTER_RULES, filterRules: DEFAULT_FILTER_RULES,
organizerId: 'usr_conc_draw',
}); });
const snapshot = await repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES); const snapshot = await repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES);

View file

@ -48,6 +48,7 @@ describe('Concurrency: Participants Update vs Snapshot Lock Race', () => {
repostsCount: 0, repostsCount: 0,
}, },
filterRules: DEFAULT_FILTER_RULES, filterRules: DEFAULT_FILTER_RULES,
organizerId: 'usr_conc_part_snap',
}); });
await repo.saveParticipants(gw.id, initialParticipants); await repo.saveParticipants(gw.id, initialParticipants);
@ -84,6 +85,7 @@ describe('Concurrency: Participants Update vs Snapshot Lock Race', () => {
repostsCount: 0, repostsCount: 0,
}, },
filterRules: DEFAULT_FILTER_RULES, filterRules: DEFAULT_FILTER_RULES,
organizerId: 'usr_conc_part_snap',
}); });
await repo.saveParticipants(gw.id, initialParticipants); await repo.saveParticipants(gw.id, initialParticipants);

View file

@ -50,6 +50,7 @@ describe('Concurrency Snapshot Locking & Participant Isolation', () => {
repostsCount: 1, repostsCount: 1,
}, },
filterRules: DEFAULT_FILTER_RULES, filterRules: DEFAULT_FILTER_RULES,
organizerId: 'usr_conc_snap',
}); });
// Valid transition: READY -> SNAPSHOT_LOCKED -> DRAWN // Valid transition: READY -> SNAPSHOT_LOCKED -> DRAWN
@ -77,6 +78,7 @@ describe('Concurrency Snapshot Locking & Participant Isolation', () => {
repostsCount: 1, repostsCount: 1,
}, },
filterRules: DEFAULT_FILTER_RULES, filterRules: DEFAULT_FILTER_RULES,
organizerId: 'usr_conc_snap',
}); });
await repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES); await repo.createAndLockSnapshot(gw.id, participants, DEFAULT_FILTER_RULES);

View file

@ -34,6 +34,7 @@ describe('Payload Scalability & Pagination', () => {
repostsCount: 10, repostsCount: 10,
}, },
filterRules: DEFAULT_FILTER_RULES, filterRules: DEFAULT_FILTER_RULES,
organizerId: 'usr_payload_test',
}); });
await repo.saveParticipants(gw.id, participants); await repo.saveParticipants(gw.id, participants);
@ -65,6 +66,7 @@ describe('Payload Scalability & Pagination', () => {
repostsCount: 10, repostsCount: 10,
}, },
filterRules: DEFAULT_FILTER_RULES, filterRules: DEFAULT_FILTER_RULES,
organizerId: 'usr_payload_test',
}); });
await repo.saveParticipants(gw.id, participants); await repo.saveParticipants(gw.id, participants);

View file

@ -67,6 +67,7 @@ describe('Repository Persistence & Lifecycle Scenario', () => {
filterRules: DEFAULT_FILTER_RULES, filterRules: DEFAULT_FILTER_RULES,
winnersCount: 1, winnersCount: 1,
reserveWinnersCount: 1, reserveWinnersCount: 1,
organizerId: 'usr_persist_test',
}); });
expect(gw.status).toBe('READY'); expect(gw.status).toBe('READY');

View file

@ -102,6 +102,7 @@ describe('Security: VK_SERVICE_TOKEN handling', () => {
excludeBlacklistedIds: [], excludeBlacklistedIds: [],
excludeDuplicateComments: true, excludeDuplicateComments: true,
}, },
organizerId: 'usr_security_test',
}); });
const json = JSON.stringify(gw); const json = JSON.stringify(gw);

View file

@ -69,6 +69,7 @@ describe('DrawResult Snapshot Binding Regression Tests', () => {
repostsCount: 10, repostsCount: 10,
}, },
filterRules: DEFAULT_FILTER_RULES, filterRules: DEFAULT_FILTER_RULES,
organizerId: 'usr_snap_bind_test',
}); });
// 2. Create snapshot V1 // 2. Create snapshot V1

View file

@ -65,6 +65,7 @@ describe('Storage Driver Policy & No Silent Fallback', () => {
repostsCount: 2, repostsCount: 2,
}, },
filterRules: {} as any, filterRules: {} as any,
organizerId: 'usr_test_driver',
}) })
).rejects.toThrow(/Can't reach database server/); ).rejects.toThrow(/Can't reach database server/);