feat(02-01): Prisma schema expansion, RLS migration, and PrismaModule
- Add User, Role enum, PasswordResetToken, LdapConfig, LdapFieldMapping models - Expand Tenant model with isActive, users relation, ldapConfig relation - Create RLS migration with tenant isolation policies on all tenant-scoped tables - Create PrismaModule (global), PrismaService, and forTenant extension - Add JWT_SECRET, TESSERA_ADMIN_*, TESSERA_FORCE_CHANGE env vars to docker-compose - Install @nestjs/jwt, @nestjs/passport, passport, argon2, class-validator deps
This commit is contained in:
+18
-8
@@ -9,20 +9,30 @@
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"rxjs": "^7.0.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@prisma/client": "^6.0.0",
|
||||
"@tessera/shared": "workspace:*"
|
||||
"@tessera/shared": "workspace:*",
|
||||
"argon2": "^0.44.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^6.0.0",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/express": "^5.0.0"
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"prisma": "^6.0.0",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Role" AS ENUM ('SUPER_ADMIN', 'ADMIN', 'USER');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tenant" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Tenant_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT,
|
||||
"displayName" TEXT,
|
||||
"role" "Role" NOT NULL DEFAULT 'USER',
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"mustChangePassword" BOOLEAN NOT NULL DEFAULT false,
|
||||
"ldapDn" TEXT,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"lastLoginAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PasswordResetToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"usedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LdapConfig" (
|
||||
"id" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"serverUrl" TEXT NOT NULL,
|
||||
"baseDn" TEXT NOT NULL,
|
||||
"bindDn" TEXT NOT NULL,
|
||||
"bindPassword" TEXT NOT NULL,
|
||||
"searchFilter" TEXT NOT NULL DEFAULT '(objectClass=person)',
|
||||
"syncIntervalMin" INTEGER NOT NULL DEFAULT 60,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"lastSyncAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LdapConfig_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LdapFieldMapping" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ldapConfigId" TEXT NOT NULL,
|
||||
"ldapField" TEXT NOT NULL,
|
||||
"tesseraField" TEXT NOT NULL,
|
||||
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "LdapFieldMapping_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Tenant_slug_key" ON "Tenant"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_tenantId_idx" ON "User"("tenantId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_username_idx" ON "User"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_email_idx" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LdapConfig_tenantId_key" ON "LdapConfig"("tenantId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LdapFieldMapping_ldapConfigId_ldapField_key" ON "LdapFieldMapping"("ldapConfigId", "ldapField");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LdapConfig" ADD CONSTRAINT "LdapConfig_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LdapFieldMapping" ADD CONSTRAINT "LdapFieldMapping_ldapConfigId_fkey" FOREIGN KEY ("ldapConfigId") REFERENCES "LdapConfig"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Create function to get current tenant from session variable
|
||||
CREATE OR REPLACE FUNCTION current_tenant_id() RETURNS TEXT AS $$
|
||||
SELECT current_setting('app.current_tenant', true);
|
||||
$$ LANGUAGE sql STABLE;
|
||||
|
||||
-- Enable RLS on User table
|
||||
ALTER TABLE "User" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "User" FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation_policy ON "User"
|
||||
USING ("tenantId" = current_tenant_id());
|
||||
|
||||
-- Enable RLS on PasswordResetToken table (via userId -> User -> tenantId)
|
||||
-- PasswordResetToken does not have a direct tenantId column, so we join through User
|
||||
ALTER TABLE "PasswordResetToken" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "PasswordResetToken" FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation_policy ON "PasswordResetToken"
|
||||
USING ("userId" IN (
|
||||
SELECT "id" FROM "User" WHERE "tenantId" = current_tenant_id()
|
||||
));
|
||||
|
||||
-- Enable RLS on LdapConfig table
|
||||
ALTER TABLE "LdapConfig" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "LdapConfig" FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation_policy ON "LdapConfig"
|
||||
USING ("tenantId" = current_tenant_id());
|
||||
|
||||
-- Enable RLS on LdapFieldMapping table (via ldapConfigId -> LdapConfig -> tenantId)
|
||||
ALTER TABLE "LdapFieldMapping" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "LdapFieldMapping" FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY tenant_isolation_policy ON "LdapFieldMapping"
|
||||
USING ("ldapConfigId" IN (
|
||||
SELECT "id" FROM "LdapConfig" WHERE "tenantId" = current_tenant_id()
|
||||
));
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -11,6 +11,76 @@ model Tenant {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
slug String @unique
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
users User[]
|
||||
ldapConfig LdapConfig?
|
||||
}
|
||||
|
||||
enum Role {
|
||||
SUPER_ADMIN
|
||||
ADMIN
|
||||
USER
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
username String @unique
|
||||
email String @unique
|
||||
passwordHash String?
|
||||
displayName String?
|
||||
role Role @default(USER)
|
||||
isActive Boolean @default(true)
|
||||
mustChangePassword Boolean @default(false)
|
||||
ldapDn String?
|
||||
tenantId String
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
lastLoginAt DateTime?
|
||||
passwordResetTokens PasswordResetToken[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([username])
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model PasswordResetToken {
|
||||
id String @id @default(uuid())
|
||||
token String @unique
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
expiresAt DateTime
|
||||
usedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model LdapConfig {
|
||||
id String @id @default(uuid())
|
||||
tenantId String @unique
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
serverUrl String
|
||||
baseDn String
|
||||
bindDn String
|
||||
bindPassword String
|
||||
searchFilter String @default("(objectClass=person)")
|
||||
syncIntervalMin Int @default(60)
|
||||
isActive Boolean @default(true)
|
||||
lastSyncAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
fieldMappings LdapFieldMapping[]
|
||||
}
|
||||
|
||||
model LdapFieldMapping {
|
||||
id String @id @default(uuid())
|
||||
ldapConfigId String
|
||||
ldapConfig LdapConfig @relation(fields: [ldapConfigId], references: [id], onDelete: Cascade)
|
||||
ldapField String
|
||||
tesseraField String
|
||||
isDefault Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([ldapConfigId, ldapField])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Creates a tenant-scoped Prisma client that sets the app.current_tenant
|
||||
* PostgreSQL session variable before every query via RLS.
|
||||
*
|
||||
* Uses parameterized set_config to prevent SQL injection (T-02-05).
|
||||
*/
|
||||
export function forTenant(prisma: PrismaClient, tenantId: string) {
|
||||
return prisma.$extends({
|
||||
query: {
|
||||
$allOperations({ args, query }) {
|
||||
return (prisma as any).$transaction(async (tx: any) => {
|
||||
// Use parameterized query to avoid SQL injection
|
||||
await tx.$executeRawUnsafe(
|
||||
`SELECT set_config('app.current_tenant', $1, true)`,
|
||||
tenantId,
|
||||
);
|
||||
return query(args);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user