diff --git a/app/backend/prisma/migrations/20260330000000_rbac_indexes_soft_delete/migration.sql b/app/backend/prisma/migrations/20260330000000_rbac_indexes_soft_delete/migration.sql new file mode 100644 index 0000000..2fb8d89 --- /dev/null +++ b/app/backend/prisma/migrations/20260330000000_rbac_indexes_soft_delete/migration.sql @@ -0,0 +1,20 @@ +-- Add ngoId to Campaign and ApiKey (issue #239: multi-NGO RBAC) +ALTER TABLE "Campaign" ADD COLUMN "ngoId" TEXT; +ALTER TABLE "ApiKey" ADD COLUMN "ngoId" TEXT; + +-- Soft-delete columns (issue #236: soft deletes) +ALTER TABLE "Campaign" ADD COLUMN "deletedAt" TIMESTAMP(3); +ALTER TABLE "Claim" ADD COLUMN "deletedAt" TIMESTAMP(3); + +-- Performance indexes on Claim (issue #236) +CREATE INDEX "Claim_status_idx" ON "Claim"("status"); +CREATE INDEX "Claim_campaignId_idx" ON "Claim"("campaignId"); +CREATE INDEX "Claim_createdAt_idx" ON "Claim"("createdAt"); +CREATE INDEX "Claim_deletedAt_idx" ON "Claim"("deletedAt"); + +-- Performance indexes on Campaign (issue #236 + #239) +CREATE INDEX "Campaign_ngoId_idx" ON "Campaign"("ngoId"); +CREATE INDEX "Campaign_deletedAt_idx" ON "Campaign"("deletedAt"); + +-- Index on ApiKey.ngoId (issue #239) +CREATE INDEX "ApiKey_ngoId_idx" ON "ApiKey"("ngoId"); diff --git a/app/backend/prisma/schema.prisma b/app/backend/prisma/schema.prisma index 097a13e..5b78a88 100644 --- a/app/backend/prisma/schema.prisma +++ b/app/backend/prisma/schema.prisma @@ -70,6 +70,7 @@ model Claim { id String @id @default(cuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + deletedAt DateTime? status ClaimStatus @default(requested) @@ -80,6 +81,11 @@ model Claim { recipientRef String evidenceRef String? + + @@index([status]) + @@index([campaignId]) + @@index([createdAt]) + @@index([deletedAt]) } model AuditLog { @@ -106,7 +112,10 @@ model Campaign { metadata Json? + ngoId String? + archivedAt DateTime? + deletedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -115,6 +124,8 @@ model Campaign { @@index([status]) @@index([archivedAt]) + @@index([ngoId]) + @@index([deletedAt]) } model Role { @@ -133,7 +144,10 @@ model ApiKey { id String @id @default(cuid()) key String @unique role AppRole + ngoId String? description String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@index([ngoId]) } diff --git a/app/backend/src/campaigns/campaigns.controller.ts b/app/backend/src/campaigns/campaigns.controller.ts index 2e31865..26fab88 100644 --- a/app/backend/src/campaigns/campaigns.controller.ts +++ b/app/backend/src/campaigns/campaigns.controller.ts @@ -8,6 +8,8 @@ import { Patch, Post, Query, + Req, + UseGuards, } from '@nestjs/common'; import { ApiBody, @@ -21,12 +23,14 @@ import { ApiTags, ApiBearerAuth, } from '@nestjs/swagger'; +import { Request } from 'express'; import { CampaignsService } from './campaigns.service'; import { CreateCampaignDto } from './dto/create-campaign.dto'; import { UpdateCampaignDto } from './dto/update-campaign.dto'; import { ApiResponseDto } from '../common/dto/api-response.dto'; import { Roles } from 'src/auth/roles.decorator'; import { AppRole } from 'src/auth/app-role.enum'; +import { OrgOwnershipGuard } from '../common/guards/org-ownership.guard'; @ApiTags('Campaigns') @ApiBearerAuth('JWT-auth') @@ -36,6 +40,7 @@ export class CampaignsController { @Post() @Roles(AppRole.admin, AppRole.ngo) + @UseGuards(OrgOwnershipGuard) @ApiOperation({ summary: 'Create a campaign' }) @ApiBody({ type: CreateCampaignDto }) @ApiCreatedResponse({ description: 'Campaign created successfully.' }) @@ -48,15 +53,16 @@ export class CampaignsController { @ApiForbiddenResponse({ description: 'Access denied - insufficient permissions for this operation.', }) - async create(@Body() dto: CreateCampaignDto) { - const campaign = await this.campaigns.create(dto); + async create(@Body() dto: CreateCampaignDto, @Req() req: Request) { + const campaign = await this.campaigns.create(dto, req.user?.ngoId); return ApiResponseDto.ok(campaign, 'Campaigns created successfully'); } @Get() @ApiOperation({ summary: 'List all campaigns', - description: 'Retrieves a list of all active or archived campaigns.', + description: + "Retrieves campaigns. NGO operators only see their own organization's campaigns.", }) @ApiOkResponse({ description: 'List of campaigns retrieved successfully.' }) @ApiUnauthorizedResponse({ @@ -65,8 +71,11 @@ export class CampaignsController { async list( @Query('includeArchived', new DefaultValuePipe(false), ParseBoolPipe) includeArchived: boolean, + @Req() req: Request, ) { - const campaigns = await this.campaigns.findAll(includeArchived); + // Scope to ngoId for NGO role; admins/operators see all + const ngoId = req.user?.role === AppRole.ngo ? req.user.ngoId : undefined; + const campaigns = await this.campaigns.findAll(includeArchived, ngoId); return ApiResponseDto.ok(campaigns, 'Campaigns fetched successfully'); } @@ -86,6 +95,7 @@ export class CampaignsController { } @Patch(':id') + @UseGuards(OrgOwnershipGuard) @ApiOperation({ summary: 'Update a campaign', description: @@ -108,6 +118,7 @@ export class CampaignsController { } @Patch(':id/archive') + @UseGuards(OrgOwnershipGuard) @ApiOperation({ summary: 'Archive campaign (soft archive)', description: diff --git a/app/backend/src/campaigns/campaigns.service.spec.ts b/app/backend/src/campaigns/campaigns.service.spec.ts index 967c166..6a27c28 100644 --- a/app/backend/src/campaigns/campaigns.service.spec.ts +++ b/app/backend/src/campaigns/campaigns.service.spec.ts @@ -11,6 +11,19 @@ describe('CampaignsService', () => { const now = new Date('2026-01-25T00:00:00.000Z'); + const baseCampaign: Campaign = { + id: 'c1', + name: 'Winter Relief 2026', + status: CampaignStatus.draft, + budget: new Prisma.Decimal('1000.00'), + metadata: { region: 'Lagos' } as Prisma.JsonValue, + ngoId: null, + archivedAt: null, + deletedAt: null, + createdAt: now, + updatedAt: now, + }; + beforeEach(async () => { jest.clearAllMocks(); prismaMock = mockDeep(); @@ -26,18 +39,7 @@ describe('CampaignsService', () => { }); it('create(): creates a campaign with Decimal budget', async () => { - const mockCreated: Campaign = { - id: 'c1', - name: 'Winter Relief 2026', - status: CampaignStatus.draft, - budget: new Prisma.Decimal('1000.00'), - metadata: { region: 'Lagos' } as Prisma.JsonValue, - archivedAt: null, - createdAt: now, - updatedAt: now, - }; - - prismaMock.campaign.create.mockResolvedValue(mockCreated); + prismaMock.campaign.create.mockResolvedValue(baseCampaign); const created = await service.create({ name: 'Winter Relief 2026', @@ -46,7 +48,6 @@ describe('CampaignsService', () => { status: CampaignStatus.draft, }); - // Avoid @typescript-eslint/unbound-method: inspect calls instead of asserting on method ref const createArgs = prismaMock.campaign.create.mock.calls[0]?.[0]; expect(createArgs).toEqual( expect.objectContaining({ @@ -58,21 +59,37 @@ describe('CampaignsService', () => { }), ); - expect(created).toEqual(mockCreated); - expect(created.id).toBe('c1'); + expect(created).toEqual(baseCampaign); + }); + + it('create(): attaches ngoId when provided', async () => { + prismaMock.campaign.create.mockResolvedValue({ + ...baseCampaign, + ngoId: 'ngo-1', + }); + + await service.create({ name: 'Test', budget: 100 }, 'ngo-1'); + + const createArgs = prismaMock.campaign.create.mock.calls[0]?.[0]; + expect(createArgs?.data).toMatchObject({ ngoId: 'ngo-1' }); }); - it('findAll(): excludes archived campaigns by default', async () => { + it('findAll(): excludes archived and deleted campaigns by default', async () => { prismaMock.campaign.findMany.mockResolvedValue([]); await service.findAll(false); const args = prismaMock.campaign.findMany.mock.calls[0]?.[0]; - expect(args).toEqual( - expect.objectContaining({ - where: { archivedAt: null }, - }), - ); + expect(args?.where).toMatchObject({ archivedAt: null, deletedAt: null }); + }); + + it('findAll(): scopes by ngoId when provided', async () => { + prismaMock.campaign.findMany.mockResolvedValue([]); + + await service.findAll(false, 'ngo-42'); + + const args = prismaMock.campaign.findMany.mock.calls[0]?.[0]; + expect(args?.where).toMatchObject({ ngoId: 'ngo-42' }); }); it('findAll(true): includes archived campaigns', async () => { @@ -81,11 +98,7 @@ describe('CampaignsService', () => { await service.findAll(true); const args = prismaMock.campaign.findMany.mock.calls[0]?.[0]; - expect(args).toEqual( - expect.objectContaining({ - where: undefined, - }), - ); + expect(args?.where).not.toHaveProperty('archivedAt'); }); it('findOne(): throws NotFoundException when missing', async () => { @@ -94,12 +107,16 @@ describe('CampaignsService', () => { await expect(service.findOne('missing')).rejects.toBeInstanceOf( NotFoundException, ); + }); - const args = prismaMock.campaign.findUnique.mock.calls[0]?.[0]; - expect(args).toEqual( - expect.objectContaining({ - where: { id: 'missing' }, - }), + it('findOne(): throws NotFoundException when soft-deleted', async () => { + prismaMock.campaign.findUnique.mockResolvedValue({ + ...baseCampaign, + deletedAt: now, + }); + + await expect(service.findOne('c1')).rejects.toBeInstanceOf( + NotFoundException, ); }); @@ -113,30 +130,30 @@ describe('CampaignsService', () => { expect(prismaMock.campaign.update.mock.calls.length).toBe(0); }); - it('archive(): idempotent when already archived (does not call update)', async () => { - const alreadyArchived: Campaign = { - id: 'c1', - name: 'A', + it('archive(): idempotent when already archived', async () => { + prismaMock.campaign.findUnique.mockResolvedValue({ + ...baseCampaign, status: CampaignStatus.archived, - budget: new Prisma.Decimal('10.00'), - metadata: null, archivedAt: now, - createdAt: now, - updatedAt: now, - }; - - prismaMock.campaign.findUnique.mockResolvedValue(alreadyArchived); + }); const result = await service.archive('c1'); expect(result.alreadyArchived).toBe(true); expect(prismaMock.campaign.update.mock.calls.length).toBe(0); + }); - const args = prismaMock.campaign.findUnique.mock.calls[0]?.[0]; - expect(args).toEqual( - expect.objectContaining({ - where: { id: 'c1' }, - }), - ); + it('softDelete(): sets deletedAt on the campaign', async () => { + prismaMock.campaign.findUnique.mockResolvedValue(baseCampaign); + prismaMock.campaign.update.mockResolvedValue({ + ...baseCampaign, + deletedAt: now, + }); + + const result = await service.softDelete('c1'); + + const updateArgs = prismaMock.campaign.update.mock.calls[0]?.[0]; + expect(updateArgs?.data).toMatchObject({ deletedAt: expect.any(Date) }); + expect(result.deletedAt).not.toBeNull(); }); }); diff --git a/app/backend/src/campaigns/campaigns.service.ts b/app/backend/src/campaigns/campaigns.service.ts index 93da062..d0d5936 100644 --- a/app/backend/src/campaigns/campaigns.service.ts +++ b/app/backend/src/campaigns/campaigns.service.ts @@ -15,27 +15,38 @@ export class CampaignsService { return metadata as Prisma.InputJsonValue; } - async create(dto: CreateCampaignDto) { + async create(dto: CreateCampaignDto, ngoId?: string | null) { return this.prisma.campaign.create({ data: { name: dto.name, status: dto.status ?? CampaignStatus.draft, budget: dto.budget, metadata: this.sanitizeMetadata(dto.metadata), + ngoId: ngoId ?? null, }, }); } - async findAll(includeArchived = false) { + async findAll(includeArchived = false, ngoId?: string | null) { + const where: Prisma.CampaignWhereInput = { + deletedAt: null, + ...(includeArchived ? {} : { archivedAt: null }), + ...(ngoId ? { ngoId } : {}), + }; + return this.prisma.campaign.findMany({ - where: includeArchived ? undefined : { archivedAt: null }, + where, orderBy: { createdAt: 'desc' }, }); } async findOne(id: string) { - const campaign = await this.prisma.campaign.findUnique({ where: { id } }); - if (!campaign) throw new NotFoundException('Campaign not found'); + const campaign = await this.prisma.campaign.findUnique({ + where: { id }, + }); + if (!campaign || campaign.deletedAt) { + throw new NotFoundException('Campaign not found'); + } return campaign; } @@ -70,4 +81,13 @@ export class CampaignsService { return { campaign: updated, alreadyArchived: false }; } + + /** Soft-delete a campaign (sets deletedAt). */ + async softDelete(id: string) { + await this.findOne(id); + return this.prisma.campaign.update({ + where: { id }, + data: { deletedAt: new Date() }, + }); + } } diff --git a/app/backend/src/claims/claims.service.ts b/app/backend/src/claims/claims.service.ts index e8f4709..e4b8acf 100644 --- a/app/backend/src/claims/claims.service.ts +++ b/app/backend/src/claims/claims.service.ts @@ -74,6 +74,7 @@ export class ClaimsService { async findAll() { const claims = await this.prisma.claim.findMany({ + where: { deletedAt: null }, include: { campaign: true, }, @@ -91,7 +92,7 @@ export class ClaimsService { campaign: true, }, }); - if (!claim) { + if (!claim || claim.deletedAt) { throw new NotFoundException('Claim not found'); } return { diff --git a/app/backend/src/common/guards/api-key.guard.ts b/app/backend/src/common/guards/api-key.guard.ts index d84b281..b5e86f1 100644 --- a/app/backend/src/common/guards/api-key.guard.ts +++ b/app/backend/src/common/guards/api-key.guard.ts @@ -46,7 +46,7 @@ export class ApiKeyGuard implements CanActivate { }); if (record) { - request.user = { role: record.role }; + request.user = { role: record.role, ngoId: record.ngoId }; return true; } diff --git a/app/backend/src/common/guards/org-ownership.guard.spec.ts b/app/backend/src/common/guards/org-ownership.guard.spec.ts new file mode 100644 index 0000000..e826bce --- /dev/null +++ b/app/backend/src/common/guards/org-ownership.guard.spec.ts @@ -0,0 +1,58 @@ +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { AppRole } from '../../auth/app-role.enum'; +import { OrgOwnershipGuard } from './org-ownership.guard'; + +const makeContext = (user: any, params: any = {}, body: any = {}) => + ({ + switchToHttp: () => ({ + getRequest: () => ({ user, params, body, query: {} }), + }), + getHandler: () => ({}), + getClass: () => ({}), + }) as unknown as ExecutionContext; + +describe('OrgOwnershipGuard', () => { + const guard = new OrgOwnershipGuard(); + + it('allows admin regardless of ngoId', () => { + const ctx = makeContext( + { role: AppRole.admin, ngoId: null }, + { ngoId: 'ngo-other' }, + ); + expect(guard.canActivate(ctx)).toBe(true); + }); + + it('allows ngo when ngoId matches', () => { + const ctx = makeContext( + { role: AppRole.ngo, ngoId: 'ngo-1' }, + { ngoId: 'ngo-1' }, + ); + expect(guard.canActivate(ctx)).toBe(true); + }); + + it('denies ngo when ngoId does not match', () => { + const ctx = makeContext( + { role: AppRole.ngo, ngoId: 'ngo-1' }, + { ngoId: 'ngo-2' }, + ); + expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException); + }); + + it('allows ngo when no ngoId on resource (listing)', () => { + const ctx = makeContext({ role: AppRole.ngo, ngoId: 'ngo-1' }); + expect(guard.canActivate(ctx)).toBe(true); + }); + + it('throws when user is not set', () => { + const ctx = makeContext(undefined); + expect(() => guard.canActivate(ctx)).toThrow(ForbiddenException); + }); + + it('allows operator without ngoId check', () => { + const ctx = makeContext( + { role: AppRole.operator, ngoId: null }, + { ngoId: 'ngo-1' }, + ); + expect(guard.canActivate(ctx)).toBe(true); + }); +}); diff --git a/app/backend/src/common/guards/org-ownership.guard.ts b/app/backend/src/common/guards/org-ownership.guard.ts new file mode 100644 index 0000000..61a4cf1 --- /dev/null +++ b/app/backend/src/common/guards/org-ownership.guard.ts @@ -0,0 +1,47 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Request } from 'express'; +import { AppRole } from '../../auth/app-role.enum'; + +/** + * Ensures NGO operators can only access resources belonging to their organization. + * Admins bypass this check. Attach after ApiKeyGuard + RolesGuard. + * + * Reads `ngoId` from: + * - request.user.ngoId (set by ApiKeyGuard from the ApiKey record) + * - request.params.ngoId OR request.body.ngoId OR request.query.ngoId + */ +@Injectable() +export class OrgOwnershipGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user) throw new ForbiddenException('Not authenticated'); + + // Admins can access any org's data + if (user.role === AppRole.admin) return true; + + // Non-NGO roles (operator, client) are not org-scoped by this guard + if (user.role !== AppRole.ngo) return true; + + const resourceNgoId: string | undefined = + (request.params as Record)['ngoId'] ?? + (request.body as Record)?.['ngoId'] ?? + (request.query as Record)['ngoId']; + + if (!resourceNgoId) return true; // no ngoId on resource — allow (listing is scoped in service) + + if (!user.ngoId || user.ngoId !== resourceNgoId) { + throw new ForbiddenException( + 'Access denied: resource belongs to a different organization', + ); + } + + return true; + } +} diff --git a/app/backend/src/types/express.d.ts b/app/backend/src/types/express.d.ts index ab5135c..e233c4e 100644 --- a/app/backend/src/types/express.d.ts +++ b/app/backend/src/types/express.d.ts @@ -3,7 +3,7 @@ import { AppRole } from '../auth/app-role.enum'; declare global { namespace Express { interface Request { - user?: { role: AppRole }; + user?: { role: AppRole; ngoId?: string | null }; } } }