Skip to content
Open
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
60 changes: 60 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ deletedAt DateTime? @map("deleted_at")
id String @id @default(uuid())
creatorId String @map("creator_id")
creator User @relation(fields: [creatorId], references: [id])
organizationId String? @map("organization_id") @db.Uuid
amount String
startDate DateTime @default(now()) @map("start_date")
endDate DateTime @map("end_date")
Expand All @@ -75,6 +76,65 @@ deletedAt DateTime? @map("deleted_at")
@@map("vaults")
}

model Organization {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
slug String @unique
metadata Json?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @map("updated_at") @updatedAt
memberships Membership[]
teams Team[]

@@map("organizations")
}

model Team {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String
slug String
organizationId String @map("organization_id") @db.Uuid
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
metadata Json?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @map("updated_at") @updatedAt
memberships Membership[]

@@unique([organizationId, slug])
@@map("teams")
}

model Membership {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
userId String @map("user_id")
organizationId String @map("organization_id") @db.Uuid
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
teamId String? @map("team_id") @db.Uuid
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
role String @default("member")
createdAt DateTime @default(now()) @map("created_at")

@@unique([userId, organizationId, teamId])
@@map("memberships")
}

model OrgVaultSearch {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
orgId String @map("org_id")
name String
queryDefinition Json @map("query_definition")
alertsEnabled Boolean @default(false) @map("alerts_enabled")
alertRecipient String? @map("alert_recipient")
alertFrequencyMs Int @default(3600000) @map("alert_frequency_ms")
lastEvaluatedAt DateTime? @map("last_evaluated_at")
lastResultHash String? @map("last_result_hash")
createdBy String @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @map("updated_at") @updatedAt

@@map("org_vault_searches")
}

model Notification {
id String @id @default(uuid())
userId String @map("user_id")
Expand Down
83 changes: 81 additions & 2 deletions src/lib/prisma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,85 @@
prisma: PrismaClient | undefined
}

export const prisma = globalForPrisma.prisma ?? new PrismaClient()
const basePrisma = globalForPrisma.prisma ?? new PrismaClient()

export const prisma = basePrisma.$extends({
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
let prismaStorageModule;
try {
prismaStorageModule = await import('./prismaScope.js');
} catch {
// Fallback if imported from another context
}

const store = prismaStorageModule?.prismaStorage.getStore();
const orgId = store?.orgId;

if (orgId) {
let orgFieldName: string | null = null;
if (model === 'Vault' || model === 'Team' || model === 'Membership') {
orgFieldName = 'organizationId';
} else if (model === 'AnalyticsReport' || model === 'AnalyticsReportQuota' || model === 'OrgVaultSearch') {
orgFieldName = 'orgId';
} else if (model === 'Organization') {
orgFieldName = 'id';
}

if (orgFieldName) {
// 1. Enforce on where clauses (for findUnique, findFirst, findMany, update, updateMany, delete, deleteMany)
if (args.where) {

Check failure on line 35 in src/lib/prisma.ts

View workflow job for this annotation

GitHub Actions / test-and-migrate

Property 'where' does not exist on type 'UserUpdateArgs<InternalArgs & DefaultArgs> | UserFindUniqueArgs<InternalArgs & DefaultArgs> | ... 184 more ... | AnalyticsReportQuotaCountArgs<...>'.
const currentFilter = args.where[orgFieldName];

Check failure on line 36 in src/lib/prisma.ts

View workflow job for this annotation

GitHub Actions / test-and-migrate

Property 'where' does not exist on type 'UserUpdateArgs<InternalArgs & DefaultArgs> | UserFindUniqueArgs<InternalArgs & DefaultArgs> | ... 184 more ... | AnalyticsReportQuotaCountArgs<...>'.
if (currentFilter !== undefined) {
if (currentFilter !== orgId) {
throw new Error(`Cross-organization data exposure prevented: query on ${model} requested ${orgFieldName} ${currentFilter} but active orgId is ${orgId}`);
}
} else {
args.where[orgFieldName] = orgId;

Check failure on line 42 in src/lib/prisma.ts

View workflow job for this annotation

GitHub Actions / test-and-migrate

Property 'where' does not exist on type 'UserUpdateArgs<InternalArgs & DefaultArgs> | UserFindUniqueArgs<InternalArgs & DefaultArgs> | ... 184 more ... | AnalyticsReportQuotaCountArgs<...>'.
}
} else if (operation !== 'create' && operation !== 'createMany') {
args.where = { [orgFieldName]: orgId };

Check failure on line 45 in src/lib/prisma.ts

View workflow job for this annotation

GitHub Actions / test-and-migrate

Property 'where' does not exist on type 'UserUpdateArgs<InternalArgs & DefaultArgs> | UserFindUniqueArgs<InternalArgs & DefaultArgs> | ... 162 more ... | AnalyticsReportQuotaCountArgs<...>'.
}

// 2. Enforce on data/create fields (for create, update, upsert)
if (args.data) {

Check failure on line 49 in src/lib/prisma.ts

View workflow job for this annotation

GitHub Actions / test-and-migrate

Property 'data' does not exist on type 'UserUpdateArgs<InternalArgs & DefaultArgs> | UserFindUniqueArgs<InternalArgs & DefaultArgs> | ... 184 more ... | AnalyticsReportQuotaCountArgs<...>'.
if (Array.isArray(args.data)) {

Check failure on line 50 in src/lib/prisma.ts

View workflow job for this annotation

GitHub Actions / test-and-migrate

Property 'data' does not exist on type 'UserUpdateArgs<InternalArgs & DefaultArgs> | UserFindUniqueArgs<InternalArgs & DefaultArgs> | ... 184 more ... | AnalyticsReportQuotaCountArgs<...>'.
for (const item of args.data) {

Check failure on line 51 in src/lib/prisma.ts

View workflow job for this annotation

GitHub Actions / test-and-migrate

Property 'data' does not exist on type 'UserUpdateArgs<InternalArgs & DefaultArgs> | UserFindUniqueArgs<InternalArgs & DefaultArgs> | ... 184 more ... | AnalyticsReportQuotaCountArgs<...>'.
if (item[orgFieldName] !== undefined && item[orgFieldName] !== orgId) {
throw new Error(`Cross-organization data exposure prevented: write on ${model} requested ${orgFieldName} ${item[orgFieldName]} but active orgId is ${orgId}`);
}
item[orgFieldName] = orgId;
}
} else {
if (args.data[orgFieldName] !== undefined && args.data[orgFieldName] !== orgId) {

Check failure on line 58 in src/lib/prisma.ts

View workflow job for this annotation

GitHub Actions / test-and-migrate

Property 'data' does not exist on type 'UserUpdateArgs<InternalArgs & DefaultArgs> | UserFindUniqueArgs<InternalArgs & DefaultArgs> | ... 184 more ... | AnalyticsReportQuotaCountArgs<...>'.

Check failure on line 58 in src/lib/prisma.ts

View workflow job for this annotation

GitHub Actions / test-and-migrate

Property 'data' does not exist on type 'UserUpdateArgs<InternalArgs & DefaultArgs> | UserFindUniqueArgs<InternalArgs & DefaultArgs> | ... 184 more ... | AnalyticsReportQuotaCountArgs<...>'.
throw new Error(`Cross-organization data exposure prevented: write on ${model} requested ${orgFieldName} ${args.data[orgFieldName]} but active orgId is ${orgId}`);
}
args.data[orgFieldName] = orgId;
}
}

// 3. Enforce on upsert fields
if (args.create) {
if (args.create[orgFieldName] !== undefined && args.create[orgFieldName] !== orgId) {
throw new Error(`Cross-organization data exposure prevented: upsert create on ${model} requested ${orgFieldName} ${args.create[orgFieldName]} but active orgId is ${orgId}`);
}
args.create[orgFieldName] = orgId;
}
if (args.update) {
if (args.update[orgFieldName] !== undefined && args.update[orgFieldName] !== orgId) {
throw new Error(`Cross-organization data exposure prevented: upsert update on ${model} requested ${orgFieldName} ${args.update[orgFieldName]} but active orgId is ${orgId}`);
}
args.update[orgFieldName] = orgId;
}
}
}

return query(args)
}
}
}
}) as unknown as PrismaClient

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = basePrisma

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
5 changes: 4 additions & 1 deletion src/lib/prismaScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { AsyncLocalStorage } from 'node:async_hooks'
import { PrismaClient } from '@prisma/client'
import { prisma as singletonPrisma } from './prisma.js'

export type PrismaScope = { prisma: PrismaClient }
export type PrismaScope = {
prisma: PrismaClient
orgId?: string
}

export const prismaStorage = new AsyncLocalStorage<PrismaScope>()

Expand Down
71 changes: 54 additions & 17 deletions src/middleware/orgAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import { Request, Response, NextFunction } from "express";
import { AuthenticatedRequest, getAuthenticatedUserId } from "./auth.js";
import { AppError } from "./errorHandler.js";
import type { OrgRole } from "../models/organizations.js";
import db from "../db/index.js";
import { getPrisma, prismaStorage } from "../lib/prismaScope.js";

export type { OrgRole } from "../models/organizations.js";

/**
* DB-backed org access middleware.
* Checks org existence and membership via the organizations and org_members tables.
* Checks org existence and membership via the organizations and memberships tables.
*/
export function requireOrgAccess(...allowedRoles: (OrgRole | string)[]) {
return async (
Expand All @@ -25,16 +25,22 @@ export function requireOrgAccess(...allowedRoles: (OrgRole | string)[]) {
}

try {
const org = await db("organizations").where({ id: orgId }).first();
const org = await getPrisma().organization.findUnique({
where: { id: orgId },
});
if (!org) {
next(AppError.notFound("Organization not found"));
return;
}
(req as any).orgId = orgId;

const membership = await db("org_members")
.where({ org_id: orgId, user_id: userId })
.first();
const membership = await getPrisma().membership.findFirst({
where: {
organizationId: orgId,
userId: userId,
teamId: null,
},
});

if (!membership) {
next(
Expand All @@ -52,7 +58,13 @@ export function requireOrgAccess(...allowedRoles: (OrgRole | string)[]) {
return;
}

next();
const store = prismaStorage.getStore();
if (store) {
store.orgId = orgId;
next();
} else {
prismaStorage.run({ prisma: getPrisma(), orgId }, next);
}
} catch (err) {
next(err);
}
Expand Down Expand Up @@ -80,15 +92,21 @@ export const requireOrgRole = (roles: (OrgRole | string)[]) => {
// Prove the target org exists before looking up membership, so a
// cross-organization reference cannot be mistaken for an authorization
// denial (or leak existence only as a 403).
const org = await db("organizations").where({ id: orgId }).first();
const org = await getPrisma().organization.findUnique({
where: { id: orgId },
});
if (!org) {
res.status(404).json({ error: "Organization not found" });
return;
}

const membership = await db("org_members")
.where({ org_id: orgId, user_id: userId })
.first();
const membership = await getPrisma().membership.findFirst({
where: {
organizationId: orgId,
userId: userId,
teamId: null,
},
});
// Missing membership is a normal no-row result (does not throw) → 403.
if (!membership || !roles.includes(membership.role)) {
res
Expand All @@ -98,7 +116,14 @@ export const requireOrgRole = (roles: (OrgRole | string)[]) => {
});
return;
}
next();

const store = prismaStorage.getStore();
if (store) {
store.orgId = orgId;
next();
} else {
prismaStorage.run({ prisma: getPrisma(), orgId }, next);
}
} catch (err) {
// Unexpected DB/infra failures must not look like authorization denials.
next(err);
Expand All @@ -124,15 +149,20 @@ export const requireTeamRole = (roles: (OrgRole | string)[]) => {
}

try {
const team = await db("teams").where({ id: teamId }).first();
const team = await getPrisma().team.findUnique({
where: { id: teamId },
});
if (!team) {
res.status(404).json({ error: "Team not found" });
return;
}

const membership = await db("team_members")
.where({ team_id: teamId, user_id: userId })
.first();
const membership = await getPrisma().membership.findFirst({
where: {
teamId: teamId,
userId: userId,
},
});
// Missing membership is a normal no-row result (does not throw) → 403.
if (!membership || !roles.includes(membership.role)) {
res
Expand All @@ -142,7 +172,14 @@ export const requireTeamRole = (roles: (OrgRole | string)[]) => {
});
return;
}
next();

const store = prismaStorage.getStore();
if (store) {
store.orgId = team.organizationId;
next();
} else {
prismaStorage.run({ prisma: getPrisma(), orgId: team.organizationId }, next);
}
} catch (err) {
// Unexpected DB/infra failures must not look like authorization denials.
next(err);
Expand Down
6 changes: 3 additions & 3 deletions src/repositories/transactionRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('TransactionRepository', () => {

describe('create', () => {
it('should successfully insert a new transaction', async () => {
const tx = { tx_hash: 'hash-1', user_id: 'user-1' };
const tx = { tx_hash: 'hash-1', user_id: 'user-1', vault_id: 'vault-1', type: 'creation' };
const result = await repo.create(tx);
expect(result).toEqual({ id: 'tx-1', tx_hash: 'hash-1' });

Expand All @@ -52,7 +52,7 @@ describe('TransactionRepository', () => {
// simulate .returning() returning empty array due to .ignore()
mockDb().returning.mockResolvedValueOnce([]);

const tx = { tx_hash: 'hash-1', user_id: 'user-1' };
const tx = { tx_hash: 'hash-1', user_id: 'user-1', vault_id: 'vault-1', type: 'creation' };
const result = await repo.create(tx);

expect(result).toEqual({ id: 'tx-1', tx_hash: 'hash-1' });
Expand All @@ -66,7 +66,7 @@ describe('TransactionRepository', () => {
mockDb().returning.mockResolvedValueOnce([]);
mockDb().first.mockResolvedValueOnce(undefined);

const tx = { tx_hash: 'hash-unknown' };
const tx = { tx_hash: 'hash-unknown', user_id: 'user-1', vault_id: 'vault-1', type: 'creation' };
await expect(repo.create(tx)).rejects.toThrow(/Failed to create or retrieve/);
});
});
Expand Down
20 changes: 10 additions & 10 deletions src/repositories/transactionRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,7 @@ export class TransactionRepository {
const safeLim = clampLimit(limit);

let query = this.db('transactions')
.where({ user_id: userId.trim() })
.orderBy('stellar_timestamp', 'desc')
.orderBy('id', 'desc')
.limit(safeLim + 1);
.where({ user_id: userId.trim() });

if (filters.vaultId) {
query = query.where({ vault_id: filters.vaultId.trim() });
Expand Down Expand Up @@ -260,7 +257,10 @@ export class TransactionRepository {
}
}

const transactions = await query;
const transactions = await query
.orderBy('stellar_timestamp', 'desc')
.orderBy('id', 'desc')
.limit(safeLim + 1);
const hasMore = transactions.length > safeLim;
const data = hasMore ? transactions.slice(0, safeLim) : transactions;

Expand Down Expand Up @@ -395,10 +395,7 @@ export class TransactionRepository {
validateTransactionFilters(filters);

let query = this.db('transactions')
.where('vault_id', vaultId.trim())
.orderBy('stellar_timestamp', 'desc')
.orderBy('id', 'desc')
.limit(safeLim + 1);
.where('vault_id', vaultId.trim());

if (filters.type) {
query = query.where('type', filters.type.trim());
Expand Down Expand Up @@ -434,7 +431,10 @@ export class TransactionRepository {
}
}

const transactions = await query;
const transactions = await query
.orderBy('stellar_timestamp', 'desc')
.orderBy('id', 'desc')
.limit(safeLim + 1);
const hasMore = transactions.length > safeLim;
const data = hasMore ? transactions.slice(0, safeLim) : transactions;

Expand Down
Loading
Loading