@valentine-efagene/qshelter-common 2.0.88 → 2.0.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/generated/client/browser.d.ts +10 -2
  2. package/dist/generated/client/client.d.ts +10 -2
  3. package/dist/generated/client/commonInputTypes.d.ts +30 -0
  4. package/dist/generated/client/enums.d.ts +5 -0
  5. package/dist/generated/client/enums.js +4 -0
  6. package/dist/generated/client/internal/class.d.ts +11 -0
  7. package/dist/generated/client/internal/class.js +2 -2
  8. package/dist/generated/client/internal/prismaNamespace.d.ts +113 -16
  9. package/dist/generated/client/internal/prismaNamespace.js +38 -14
  10. package/dist/generated/client/internal/prismaNamespaceBrowser.d.ts +41 -15
  11. package/dist/generated/client/internal/prismaNamespaceBrowser.js +38 -14
  12. package/dist/generated/client/models/Permission.d.ts +333 -68
  13. package/dist/generated/client/models/Role.d.ts +403 -3
  14. package/dist/generated/client/models/Tenant.d.ts +761 -4
  15. package/dist/generated/client/models/TenantMembership.d.ts +1395 -0
  16. package/dist/generated/client/models/TenantMembership.js +1 -0
  17. package/dist/generated/client/models/User.d.ts +375 -0
  18. package/dist/generated/client/models/UserRole.d.ts +2 -1
  19. package/dist/generated/client/models/index.d.ts +1 -0
  20. package/dist/generated/client/models/index.js +1 -0
  21. package/dist/generated/client/models.d.ts +1 -0
  22. package/dist/src/events/policies/policy-event.d.ts +50 -5
  23. package/dist/src/events/policies/policy-event.js +4 -0
  24. package/dist/src/events/policies/policy-publisher.d.ts +13 -1
  25. package/dist/src/events/policies/policy-publisher.js +24 -0
  26. package/dist/src/prisma/tenant.js +13 -5
  27. package/package.json +1 -1
  28. package/prisma/migrations/20260111091027_rbac_redesign_federated_users/migration.sql +79 -0
  29. package/prisma/schema.prisma +84 -20
@@ -2,7 +2,8 @@ import type * as runtime from "@prisma/client/runtime/client";
2
2
  import type * as Prisma from "../internal/prismaNamespace.js";
3
3
  /**
4
4
  * Model UserRole
5
- *
5
+ * Legacy: Direct user-role assignment (global, not tenant-scoped)
6
+ * @deprecated Use TenantMembership for tenant-scoped role assignments
6
7
  */
7
8
  export type UserRoleModel = runtime.Types.Result.DefaultSelection<Prisma.$UserRolePayload>;
8
9
  export type AggregateUserRole = {
@@ -53,6 +53,7 @@ export * from './Settings';
53
53
  export * from './Social';
54
54
  export * from './StepEventAttachment';
55
55
  export * from './Tenant';
56
+ export * from './TenantMembership';
56
57
  export * from './Transaction';
57
58
  export * from './User';
58
59
  export * from './UserRole';
@@ -53,6 +53,7 @@ export * from './Settings';
53
53
  export * from './Social';
54
54
  export * from './StepEventAttachment';
55
55
  export * from './Tenant';
56
+ export * from './TenantMembership';
56
57
  export * from './Transaction';
57
58
  export * from './User';
58
59
  export * from './UserRole';
@@ -3,6 +3,7 @@ export type * from './models/Role.js';
3
3
  export type * from './models/Permission.js';
4
4
  export type * from './models/RolePermission.js';
5
5
  export type * from './models/UserRole.js';
6
+ export type * from './models/TenantMembership.js';
6
7
  export type * from './models/Tenant.js';
7
8
  export type * from './models/ApiKey.js';
8
9
  export type * from './models/RefreshToken.js';
@@ -13,29 +13,61 @@ export declare enum PolicyEventType {
13
13
  PERMISSION_DELETED = "POLICY.PERMISSION_DELETED",
14
14
  ROLE_PERMISSION_ASSIGNED = "POLICY.ROLE_PERMISSION_ASSIGNED",
15
15
  ROLE_PERMISSION_REVOKED = "POLICY.ROLE_PERMISSION_REVOKED",
16
+ TENANT_MEMBERSHIP_CREATED = "POLICY.TENANT_MEMBERSHIP_CREATED",
17
+ TENANT_MEMBERSHIP_UPDATED = "POLICY.TENANT_MEMBERSHIP_UPDATED",
18
+ TENANT_MEMBERSHIP_DELETED = "POLICY.TENANT_MEMBERSHIP_DELETED",
16
19
  FULL_SYNC_REQUESTED = "POLICY.FULL_SYNC_REQUESTED"
17
20
  }
21
+ /**
22
+ * Role data with tenant scoping
23
+ */
18
24
  export interface RoleData {
19
25
  id: string;
20
26
  name: string;
21
27
  description?: string | null;
28
+ tenantId?: string | null;
29
+ isSystem?: boolean;
30
+ isActive?: boolean;
22
31
  }
32
+ /**
33
+ * Permission with path-based authorization
34
+ * Matches the authorizer's expected policy structure
35
+ */
23
36
  export interface PermissionData {
24
37
  id: string;
25
38
  name: string;
26
39
  description?: string | null;
27
- resource: string;
28
- action: string;
40
+ path: string;
41
+ methods: string[];
42
+ effect: 'ALLOW' | 'DENY';
43
+ tenantId?: string | null;
29
44
  }
45
+ /**
46
+ * Role with full permission details for policy sync
47
+ */
30
48
  export interface RolePermissionData {
31
49
  roleId: string;
32
50
  roleName: string;
51
+ tenantId?: string | null;
33
52
  permissions: Array<{
34
53
  id: string;
35
- resource: string;
36
- action: string;
54
+ path: string;
55
+ methods: string[];
56
+ effect: 'ALLOW' | 'DENY';
37
57
  }>;
38
58
  }
59
+ /**
60
+ * Tenant membership data for federated users
61
+ */
62
+ export interface TenantMembershipData {
63
+ id: string;
64
+ userId: string;
65
+ tenantId: string;
66
+ roleId: string;
67
+ roleName: string;
68
+ isActive: boolean;
69
+ isDefault: boolean;
70
+ }
39
71
  export interface PolicyEventMeta {
40
72
  source: string;
41
73
  timestamp: string;
@@ -86,4 +118,17 @@ export interface FullSyncRequestedEvent extends PolicyEvent<{
86
118
  }> {
87
119
  eventType: PolicyEventType.FULL_SYNC_REQUESTED;
88
120
  }
89
- export type AnyPolicyEvent = RoleCreatedEvent | RoleUpdatedEvent | RoleDeletedEvent | PermissionCreatedEvent | PermissionUpdatedEvent | PermissionDeletedEvent | RolePermissionAssignedEvent | RolePermissionRevokedEvent | FullSyncRequestedEvent;
121
+ export interface TenantMembershipCreatedEvent extends PolicyEvent<TenantMembershipData> {
122
+ eventType: PolicyEventType.TENANT_MEMBERSHIP_CREATED;
123
+ }
124
+ export interface TenantMembershipUpdatedEvent extends PolicyEvent<TenantMembershipData> {
125
+ eventType: PolicyEventType.TENANT_MEMBERSHIP_UPDATED;
126
+ }
127
+ export interface TenantMembershipDeletedEvent extends PolicyEvent<{
128
+ membershipId: string;
129
+ userId: string;
130
+ tenantId: string;
131
+ }> {
132
+ eventType: PolicyEventType.TENANT_MEMBERSHIP_DELETED;
133
+ }
134
+ export type AnyPolicyEvent = RoleCreatedEvent | RoleUpdatedEvent | RoleDeletedEvent | PermissionCreatedEvent | PermissionUpdatedEvent | PermissionDeletedEvent | RolePermissionAssignedEvent | RolePermissionRevokedEvent | FullSyncRequestedEvent | TenantMembershipCreatedEvent | TenantMembershipUpdatedEvent | TenantMembershipDeletedEvent;
@@ -17,6 +17,10 @@ export var PolicyEventType;
17
17
  // Role-Permission association events
18
18
  PolicyEventType["ROLE_PERMISSION_ASSIGNED"] = "POLICY.ROLE_PERMISSION_ASSIGNED";
19
19
  PolicyEventType["ROLE_PERMISSION_REVOKED"] = "POLICY.ROLE_PERMISSION_REVOKED";
20
+ // Tenant membership events (for federated users)
21
+ PolicyEventType["TENANT_MEMBERSHIP_CREATED"] = "POLICY.TENANT_MEMBERSHIP_CREATED";
22
+ PolicyEventType["TENANT_MEMBERSHIP_UPDATED"] = "POLICY.TENANT_MEMBERSHIP_UPDATED";
23
+ PolicyEventType["TENANT_MEMBERSHIP_DELETED"] = "POLICY.TENANT_MEMBERSHIP_DELETED";
20
24
  // Bulk sync events
21
25
  PolicyEventType["FULL_SYNC_REQUESTED"] = "POLICY.FULL_SYNC_REQUESTED";
22
26
  })(PolicyEventType || (PolicyEventType = {}));
@@ -1,4 +1,4 @@
1
- import { PolicyEventType, PolicyEventMeta, RoleData, PermissionData, RolePermissionData } from './policy-event';
1
+ import { PolicyEventType, PolicyEventMeta, RoleData, PermissionData, RolePermissionData, TenantMembershipData } from './policy-event';
2
2
  /**
3
3
  * Configuration for the policy event publisher
4
4
  */
@@ -52,6 +52,18 @@ export declare class PolicyEventPublisher {
52
52
  * Publish role permission revoked event
53
53
  */
54
54
  publishRolePermissionRevoked(data: RolePermissionData, meta?: Partial<PolicyEventMeta>): Promise<string>;
55
+ /**
56
+ * Publish tenant membership created event
57
+ */
58
+ publishTenantMembershipCreated(data: TenantMembershipData, meta?: Partial<PolicyEventMeta>): Promise<string>;
59
+ /**
60
+ * Publish tenant membership updated event
61
+ */
62
+ publishTenantMembershipUpdated(data: TenantMembershipData, meta?: Partial<PolicyEventMeta>): Promise<string>;
63
+ /**
64
+ * Publish tenant membership deleted event
65
+ */
66
+ publishTenantMembershipDeleted(membershipId: string, userId: string, tenantId: string, meta?: Partial<PolicyEventMeta>): Promise<string>;
55
67
  /**
56
68
  * Publish full sync requested event
57
69
  */
@@ -119,6 +119,30 @@ export class PolicyEventPublisher {
119
119
  async publishRolePermissionRevoked(data, meta) {
120
120
  return this.publish(PolicyEventType.ROLE_PERMISSION_REVOKED, data, meta);
121
121
  }
122
+ /**
123
+ * Publish tenant membership created event
124
+ */
125
+ async publishTenantMembershipCreated(data, meta) {
126
+ return this.publish(PolicyEventType.TENANT_MEMBERSHIP_CREATED, data, {
127
+ ...meta,
128
+ tenantId: data.tenantId,
129
+ });
130
+ }
131
+ /**
132
+ * Publish tenant membership updated event
133
+ */
134
+ async publishTenantMembershipUpdated(data, meta) {
135
+ return this.publish(PolicyEventType.TENANT_MEMBERSHIP_UPDATED, data, {
136
+ ...meta,
137
+ tenantId: data.tenantId,
138
+ });
139
+ }
140
+ /**
141
+ * Publish tenant membership deleted event
142
+ */
143
+ async publishTenantMembershipDeleted(membershipId, userId, tenantId, meta) {
144
+ return this.publish(PolicyEventType.TENANT_MEMBERSHIP_DELETED, { membershipId, userId, tenantId }, { ...meta, tenantId });
145
+ }
122
146
  /**
123
147
  * Publish full sync requested event
124
148
  */
@@ -9,16 +9,18 @@
9
9
  * These models either:
10
10
  * - Don't have a tenantId field (system tables)
11
11
  * - Have optional tenantId but are designed to work across tenants (User)
12
+ * - Are cross-tenant lookup/join tables (TenantMembership)
12
13
  */
13
14
  const GLOBAL_MODELS = [
14
- // User can exist across tenants or without a tenant
15
+ // User can exist across tenants or without a tenant (federated)
15
16
  "user",
17
+ // TenantMembership is the user-tenant join table (queries by userId or tenantId)
18
+ "tenantMembership",
16
19
  // System/infrastructure tables without tenantId
17
20
  "tenant",
18
- "role",
19
- "permission",
20
- "rolePermission",
21
+ // Legacy role assignment (global, not tenant-scoped)
21
22
  "userRole",
23
+ "rolePermission",
22
24
  "refreshToken",
23
25
  "passwordReset",
24
26
  "wallet",
@@ -29,7 +31,13 @@ const GLOBAL_MODELS = [
29
31
  * These can be global templates (tenantId = null) or tenant-specific.
30
32
  * Queries will return both global AND tenant-specific records.
31
33
  */
32
- const OPTIONAL_TENANT_MODELS = ["paymentPlan"];
34
+ const OPTIONAL_TENANT_MODELS = [
35
+ "paymentPlan",
36
+ // Role can be global template (tenantId = null) or tenant-specific
37
+ "role",
38
+ // Permission can be global template or tenant-specific
39
+ "permission",
40
+ ];
33
41
  function isGlobalModel(model) {
34
42
  return GLOBAL_MODELS.includes(model);
35
43
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valentine-efagene/qshelter-common",
3
- "version": "2.0.88",
3
+ "version": "2.0.90",
4
4
  "description": "Shared database schemas and utilities for QShelter services",
5
5
  "main": "dist/src/index.js",
6
6
  "types": "dist/src/index.d.ts",
@@ -0,0 +1,79 @@
1
+ /*
2
+ Warnings:
3
+
4
+ - You are about to drop the column `action` on the `permissions` table. All the data in the column will be lost.
5
+ - You are about to drop the column `resource` on the `permissions` table. All the data in the column will be lost.
6
+ - A unique constraint covering the columns `[path,tenantId]` on the table `permissions` will be added. If there are existing duplicate values, this will fail.
7
+ - A unique constraint covering the columns `[name,tenantId]` on the table `roles` will be added. If there are existing duplicate values, this will fail.
8
+ - Added the required column `path` to the `permissions` table without a default value. This is not possible if the table is not empty.
9
+
10
+ */
11
+ -- DropIndex
12
+ DROP INDEX `permissions_name_key` ON `permissions`;
13
+
14
+ -- DropIndex
15
+ DROP INDEX `permissions_resource_action_key` ON `permissions`;
16
+
17
+ -- DropIndex
18
+ DROP INDEX `permissions_resource_idx` ON `permissions`;
19
+
20
+ -- DropIndex
21
+ DROP INDEX `roles_name_key` ON `roles`;
22
+
23
+ -- AlterTable
24
+ ALTER TABLE `permissions` DROP COLUMN `action`,
25
+ DROP COLUMN `resource`,
26
+ ADD COLUMN `effect` ENUM('ALLOW', 'DENY') NOT NULL DEFAULT 'ALLOW',
27
+ ADD COLUMN `isSystem` BOOLEAN NOT NULL DEFAULT false,
28
+ ADD COLUMN `methods` JSON NOT NULL,
29
+ ADD COLUMN `path` VARCHAR(191) NOT NULL,
30
+ ADD COLUMN `tenantId` VARCHAR(191) NULL;
31
+
32
+ -- AlterTable
33
+ ALTER TABLE `roles` ADD COLUMN `isActive` BOOLEAN NOT NULL DEFAULT true,
34
+ ADD COLUMN `isSystem` BOOLEAN NOT NULL DEFAULT false,
35
+ ADD COLUMN `tenantId` VARCHAR(191) NULL;
36
+
37
+ -- CreateTable
38
+ CREATE TABLE `tenant_memberships` (
39
+ `id` VARCHAR(191) NOT NULL,
40
+ `userId` VARCHAR(191) NOT NULL,
41
+ `tenantId` VARCHAR(191) NOT NULL,
42
+ `roleId` VARCHAR(191) NOT NULL,
43
+ `isActive` BOOLEAN NOT NULL DEFAULT true,
44
+ `isDefault` BOOLEAN NOT NULL DEFAULT false,
45
+ `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
46
+ `updatedAt` DATETIME(3) NOT NULL,
47
+
48
+ INDEX `tenant_memberships_tenantId_idx`(`tenantId`),
49
+ INDEX `tenant_memberships_userId_idx`(`userId`),
50
+ UNIQUE INDEX `tenant_memberships_userId_tenantId_key`(`userId`, `tenantId`),
51
+ PRIMARY KEY (`id`)
52
+ ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
53
+
54
+ -- CreateIndex
55
+ CREATE INDEX `permissions_tenantId_idx` ON `permissions`(`tenantId`);
56
+
57
+ -- CreateIndex
58
+ CREATE UNIQUE INDEX `permissions_path_tenantId_key` ON `permissions`(`path`, `tenantId`);
59
+
60
+ -- CreateIndex
61
+ CREATE INDEX `roles_tenantId_idx` ON `roles`(`tenantId`);
62
+
63
+ -- CreateIndex
64
+ CREATE UNIQUE INDEX `roles_name_tenantId_key` ON `roles`(`name`, `tenantId`);
65
+
66
+ -- AddForeignKey
67
+ ALTER TABLE `roles` ADD CONSTRAINT `roles_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
68
+
69
+ -- AddForeignKey
70
+ ALTER TABLE `permissions` ADD CONSTRAINT `permissions_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
71
+
72
+ -- AddForeignKey
73
+ ALTER TABLE `tenant_memberships` ADD CONSTRAINT `tenant_memberships_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
74
+
75
+ -- AddForeignKey
76
+ ALTER TABLE `tenant_memberships` ADD CONSTRAINT `tenant_memberships_tenantId_fkey` FOREIGN KEY (`tenantId`) REFERENCES `tenants`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
77
+
78
+ -- AddForeignKey
79
+ ALTER TABLE `tenant_memberships` ADD CONSTRAINT `tenant_memberships_roleId_fkey` FOREIGN KEY (`roleId`) REFERENCES `roles`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -292,29 +292,39 @@ enum ExecutionStatus {
292
292
  SKIPPED
293
293
  }
294
294
 
295
+ /// Permission effect (Allow/Deny)
296
+ enum PermissionEffect {
297
+ ALLOW
298
+ DENY
299
+ }
300
+
295
301
  // =============================================================================
296
302
  // USER & AUTH DOMAIN
297
303
  // =============================================================================
298
304
 
299
305
  model User {
300
- id String @id @default(cuid())
301
- email String @unique
306
+ id String @id @default(cuid())
307
+ email String @unique
302
308
  password String?
303
- phone String? @unique
309
+ phone String? @unique
304
310
  firstName String?
305
311
  lastName String?
306
- isActive Boolean @default(true)
307
- isEmailVerified Boolean @default(false)
312
+ isActive Boolean @default(true)
313
+ isEmailVerified Boolean @default(false)
308
314
  googleId String?
309
315
  avatar String?
316
+ // Legacy: Optional direct tenant association (for backward compatibility)
317
+ // New: Use tenantMemberships for multi-tenant federation
310
318
  tenantId String?
311
- tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
312
- // Support multiple roles via explicit join table `UserRole`
319
+ tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: SetNull)
320
+ // Federated: User can belong to multiple tenants with different roles
321
+ tenantMemberships TenantMembership[]
322
+ // Legacy: Support multiple roles via explicit join table `UserRole`
313
323
  userRoles UserRole[]
314
- walletId String? @unique
315
- wallet Wallet? @relation(fields: [walletId], references: [id])
316
- createdAt DateTime @default(now())
317
- updatedAt DateTime @updatedAt
324
+ walletId String? @unique
325
+ wallet Wallet? @relation(fields: [walletId], references: [id])
326
+ createdAt DateTime @default(now())
327
+ updatedAt DateTime @updatedAt
318
328
  emailVerifiedAt DateTime?
319
329
  emailVerificationToken String?
320
330
  lastLoginAt DateTime?
@@ -368,29 +378,51 @@ model User {
368
378
  }
369
379
 
370
380
  model Role {
371
- id String @id @default(cuid())
372
- name String @unique
381
+ id String @id @default(cuid())
382
+ name String
373
383
  description String?
384
+ // Tenant-scoping: NULL = global template, set = tenant-specific role
385
+ tenantId String?
386
+ tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
387
+ // System roles cannot be deleted (admin, user, etc.)
388
+ isSystem Boolean @default(false)
389
+ isActive Boolean @default(true)
390
+ // Legacy: UserRole for backward compatibility
374
391
  userRoles UserRole[]
392
+ // New: TenantMembership for federated users
393
+ memberships TenantMembership[]
375
394
  permissions RolePermission[]
376
- createdAt DateTime @default(now())
377
- updatedAt DateTime @updatedAt
395
+ createdAt DateTime @default(now())
396
+ updatedAt DateTime @updatedAt
378
397
 
398
+ @@unique([name, tenantId]) // Unique name per tenant (null tenantId = global)
399
+ @@index([tenantId])
379
400
  @@map("roles")
380
401
  }
381
402
 
403
+ /// Permission defines a path pattern + HTTP methods + effect
404
+ /// Supports path-based authorization matching the authorizer's policy structure
382
405
  model Permission {
383
406
  id String @id @default(cuid())
384
- name String @unique
407
+ name String // Descriptive name: "Read Users", "Manage Properties"
385
408
  description String?
386
- resource String
387
- action String
409
+ // Path pattern: /users, /users/:id, /properties/*, etc.
410
+ path String
411
+ // HTTP methods: ["GET"], ["GET", "POST"], ["*"] - stored as JSON
412
+ methods Json @default("[]")
413
+ // Allow or Deny this path/methods
414
+ effect PermissionEffect @default(ALLOW)
415
+ // Tenant-scoping: NULL = global template, set = tenant-specific
416
+ tenantId String?
417
+ tenant Tenant? @relation(fields: [tenantId], references: [id], onDelete: Cascade)
418
+ // System permissions cannot be deleted
419
+ isSystem Boolean @default(false)
388
420
  roles RolePermission[]
389
421
  createdAt DateTime @default(now())
390
422
  updatedAt DateTime @updatedAt
391
423
 
392
- @@unique([resource, action])
393
- @@index([resource])
424
+ @@unique([path, tenantId]) // Unique path per tenant
425
+ @@index([tenantId])
394
426
  @@map("permissions")
395
427
  }
396
428
 
@@ -405,6 +437,8 @@ model RolePermission {
405
437
  @@map("role_permissions")
406
438
  }
407
439
 
440
+ /// Legacy: Direct user-role assignment (global, not tenant-scoped)
441
+ /// @deprecated Use TenantMembership for tenant-scoped role assignments
408
442
  model UserRole {
409
443
  userId String
410
444
  roleId String
@@ -416,6 +450,30 @@ model UserRole {
416
450
  @@map("user_roles")
417
451
  }
418
452
 
453
+ /// Tenant Membership: Links users to tenants with specific roles
454
+ /// Enables federated users across multiple tenants with different roles per tenant
455
+ model TenantMembership {
456
+ id String @id @default(cuid())
457
+ userId String
458
+ tenantId String
459
+ roleId String
460
+ // Whether this membership is active
461
+ isActive Boolean @default(true)
462
+ // Whether this is the user's default tenant (for login without specifying tenant)
463
+ isDefault Boolean @default(false)
464
+ createdAt DateTime @default(now())
465
+ updatedAt DateTime @updatedAt
466
+
467
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
468
+ tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
469
+ role Role @relation(fields: [roleId], references: [id], onDelete: Restrict)
470
+
471
+ @@unique([userId, tenantId]) // User can only have one membership per tenant
472
+ @@index([tenantId])
473
+ @@index([userId])
474
+ @@map("tenant_memberships")
475
+ }
476
+
419
477
  model Tenant {
420
478
  id String @id @default(cuid())
421
479
  name String
@@ -431,6 +489,12 @@ model Tenant {
431
489
  paymentMethods PropertyPaymentMethod[]
432
490
  contracts Contract[]
433
491
 
492
+ // RBAC: Tenant-scoped roles and permissions
493
+ roles Role[]
494
+ permissions Permission[]
495
+ // Federated user memberships
496
+ memberships TenantMembership[]
497
+
434
498
  // Payment method changes
435
499
  paymentMethodChangeRequests PaymentMethodChangeRequest[]
436
500
  documentRequirementRules DocumentRequirementRule[]