@aphexcms/cms-core 0.1.0 → 0.1.3
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.
- package/package.json +22 -5
- package/src/api/assets.ts +0 -75
- package/src/api/client.ts +0 -150
- package/src/api/documents.ts +0 -102
- package/src/api/index.ts +0 -7
- package/src/api/organizations.ts +0 -154
- package/src/api/types.ts +0 -34
- package/src/app.d.ts +0 -19
- package/src/auth/MULTI_TENANCY_PLAN.md +0 -1183
- package/src/auth/auth-errors.ts +0 -23
- package/src/auth/auth-hooks.ts +0 -132
- package/src/auth/provider.ts +0 -25
- package/src/client/index.ts +0 -47
- package/src/components/AdminApp.svelte +0 -1078
- package/src/components/admin/AdminLayout.svelte +0 -115
- package/src/components/admin/DocumentEditor.svelte +0 -795
- package/src/components/admin/DocumentTypesList.svelte +0 -97
- package/src/components/admin/ObjectModal.svelte +0 -135
- package/src/components/admin/SchemaField.svelte +0 -171
- package/src/components/admin/fields/ArrayField.svelte +0 -266
- package/src/components/admin/fields/BooleanField.svelte +0 -35
- package/src/components/admin/fields/ImageField.svelte +0 -284
- package/src/components/admin/fields/NumberField.svelte +0 -82
- package/src/components/admin/fields/ReferenceField.svelte +0 -260
- package/src/components/admin/fields/SlugField.svelte +0 -74
- package/src/components/admin/fields/StringField.svelte +0 -40
- package/src/components/admin/fields/TextareaField.svelte +0 -40
- package/src/components/fields/index.ts +0 -9
- package/src/components/index.ts +0 -16
- package/src/components/layout/OrganizationSwitcher.svelte +0 -218
- package/src/components/layout/Sidebar.svelte +0 -88
- package/src/components/layout/sidebar/AppSidebar.svelte +0 -63
- package/src/components/layout/sidebar/NavMain.svelte +0 -95
- package/src/components/layout/sidebar/NavSecondary.svelte +0 -69
- package/src/components/layout/sidebar/NavUser.svelte +0 -85
- package/src/config.ts +0 -18
- package/src/db/adapters/index.ts +0 -3
- package/src/db/index.ts +0 -5
- package/src/db/interfaces/asset.ts +0 -61
- package/src/db/interfaces/document.ts +0 -53
- package/src/db/interfaces/index.ts +0 -98
- package/src/db/interfaces/organization.ts +0 -51
- package/src/db/interfaces/schema.ts +0 -13
- package/src/db/interfaces/user.ts +0 -16
- package/src/db/utils/reference-resolver.ts +0 -119
- package/src/define.ts +0 -7
- package/src/email/index.ts +0 -5
- package/src/email/interfaces/email.ts +0 -45
- package/src/engine.ts +0 -85
- package/src/field-validation/rule.ts +0 -287
- package/src/field-validation/utils.ts +0 -91
- package/src/hooks.ts +0 -142
- package/src/index.ts +0 -5
- package/src/lib/is-mobile.svelte.ts +0 -9
- package/src/lib/utils.ts +0 -13
- package/src/plugins/README.md +0 -154
- package/src/routes/assets-by-id.ts +0 -161
- package/src/routes/assets-cdn.ts +0 -185
- package/src/routes/assets.ts +0 -116
- package/src/routes/documents-by-id.ts +0 -188
- package/src/routes/documents-publish.ts +0 -211
- package/src/routes/documents.ts +0 -172
- package/src/routes/index.ts +0 -13
- package/src/routes/organizations-by-id.ts +0 -258
- package/src/routes/organizations-invitations.ts +0 -183
- package/src/routes/organizations-members.ts +0 -301
- package/src/routes/organizations-switch.ts +0 -74
- package/src/routes/organizations.ts +0 -146
- package/src/routes/schemas-by-type.ts +0 -35
- package/src/routes/schemas.ts +0 -19
- package/src/routes-exports.ts +0 -42
- package/src/schema-context.svelte.ts +0 -24
- package/src/schema-utils/cleanup.ts +0 -116
- package/src/schema-utils/index.ts +0 -4
- package/src/schema-utils/utils.ts +0 -47
- package/src/schema-utils/validator.ts +0 -58
- package/src/server/index.ts +0 -40
- package/src/services/asset-service.ts +0 -256
- package/src/services/index.ts +0 -6
- package/src/storage/adapters/index.ts +0 -2
- package/src/storage/adapters/local-storage-adapter.ts +0 -215
- package/src/storage/index.ts +0 -8
- package/src/storage/interfaces/index.ts +0 -2
- package/src/storage/interfaces/storage.ts +0 -114
- package/src/storage/providers/storage.ts +0 -83
- package/src/types/asset.ts +0 -81
- package/src/types/auth.ts +0 -80
- package/src/types/config.ts +0 -45
- package/src/types/document.ts +0 -38
- package/src/types/index.ts +0 -8
- package/src/types/organization.ts +0 -119
- package/src/types/schemas.ts +0 -151
- package/src/types/sidebar.ts +0 -37
- package/src/types/user.ts +0 -17
- package/src/utils/content-hash.ts +0 -75
- package/src/utils/image-url.ts +0 -204
- package/src/utils/index.ts +0 -12
- package/src/utils/slug.ts +0 -33
|
@@ -1,1183 +0,0 @@
|
|
|
1
|
-
# Multi-Tenancy Implementation Plan
|
|
2
|
-
|
|
3
|
-
**Goal**: Transform Aphex CMS from single-tenant to multi-tenant, enabling agencies to manage multiple client organizations with complete data isolation.
|
|
4
|
-
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
## ⚠️ Important: Soft Multi-Tenancy
|
|
8
|
-
|
|
9
|
-
**This implementation uses "soft multi-tenancy" (shared database with row-level filtering), NOT "true multi-tenancy" (database-per-tenant).**
|
|
10
|
-
|
|
11
|
-
### What This Means:
|
|
12
|
-
|
|
13
|
-
- ✅ All organizations share the same database and compute resources
|
|
14
|
-
- ✅ Data isolation is enforced at the **application level** via `organizationId` filtering
|
|
15
|
-
- ✅ Suitable for 90% of use cases (agencies, freelancers, small-to-medium businesses)
|
|
16
|
-
- ❌ NOT suitable for enterprises requiring database-level isolation or dedicated infrastructure
|
|
17
|
-
- ❌ Noisy neighbor effects possible (one org's heavy queries can slow others)
|
|
18
|
-
- ❌ Cannot guarantee data residency (all data in same database/region)
|
|
19
|
-
|
|
20
|
-
### When to Upgrade to True Multi-Tenancy:
|
|
21
|
-
|
|
22
|
-
- Enterprise clients with compliance requirements (GDPR, HIPAA)
|
|
23
|
-
- Organizations requiring guaranteed SLAs and dedicated resources
|
|
24
|
-
- High-security environments requiring database-level isolation
|
|
25
|
-
- Clients willing to pay 10-40x more for dedicated infrastructure
|
|
26
|
-
|
|
27
|
-
See [Enterprise Multi-Tenancy Considerations](#enterprise-multi-tenancy-considerations) for future evolution paths.
|
|
28
|
-
|
|
29
|
-
---
|
|
30
|
-
|
|
31
|
-
## Overview
|
|
32
|
-
|
|
33
|
-
### Key Principles
|
|
34
|
-
|
|
35
|
-
- ✅ **Separate Organizations** - Each client gets their own isolated workspace
|
|
36
|
-
- ✅ **Super Admin Pattern** - First/designated users can create organizations
|
|
37
|
-
- ✅ **Many-to-Many** - Users can belong to multiple organizations with different roles
|
|
38
|
-
- ✅ **Active Organization** - Users work in one organization at a time (switchable)
|
|
39
|
-
- ✅ **Role-Based Access** - Owner > Admin > Editor > Viewer per organization
|
|
40
|
-
- ✅ **Complete Isolation** - Documents/assets are scoped by organization
|
|
41
|
-
- ✅ **Don't Touch Better Auth** - All extensions go in CMS tables
|
|
42
|
-
|
|
43
|
-
### Architecture
|
|
44
|
-
|
|
45
|
-
```
|
|
46
|
-
Better Auth (App Layer) CMS Core (Package Layer)
|
|
47
|
-
├── user (authentication) ├── cms_organizations
|
|
48
|
-
├── session ├── cms_organization_members
|
|
49
|
-
└── apikey ├── cms_invitations
|
|
50
|
-
├── cms_user_sessions
|
|
51
|
-
├── cms_documents (+ organizationId)
|
|
52
|
-
└── cms_assets (+ organizationId)
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
---
|
|
56
|
-
|
|
57
|
-
## Phase 1: Database Schema
|
|
58
|
-
|
|
59
|
-
### New Tables
|
|
60
|
-
|
|
61
|
-
#### 1. Organizations
|
|
62
|
-
|
|
63
|
-
```typescript
|
|
64
|
-
cms_organizations {
|
|
65
|
-
id: uuid PRIMARY KEY;
|
|
66
|
-
name: varchar(200) NOT NULL; // "Client A", "Agency Internal"
|
|
67
|
-
slug: varchar(100) UNIQUE NOT NULL; // "client-a", "agency-internal"
|
|
68
|
-
metadata: jsonb; // { logo, theme, website, settings }
|
|
69
|
-
createdBy: text NOT NULL; // User ID (super admin who created it)
|
|
70
|
-
createdAt: timestamp;
|
|
71
|
-
updatedAt: timestamp;
|
|
72
|
-
}
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
**Purpose**: Store organization (client/project) data with branding/settings.
|
|
76
|
-
|
|
77
|
-
#### 2. Organization Members (Many-to-Many)
|
|
78
|
-
|
|
79
|
-
```typescript
|
|
80
|
-
cms_organization_members {
|
|
81
|
-
id: uuid PRIMARY KEY;
|
|
82
|
-
organizationId: uuid NOT NULL → cms_organizations(id) CASCADE;
|
|
83
|
-
userId: text NOT NULL; // References Better Auth user
|
|
84
|
-
role: enum('owner', 'admin', 'editor', 'viewer') NOT NULL;
|
|
85
|
-
preferences: jsonb; // Org-specific user preferences
|
|
86
|
-
invitedBy: text; // User ID who invited this member
|
|
87
|
-
createdAt: timestamp;
|
|
88
|
-
updatedAt: timestamp;
|
|
89
|
-
|
|
90
|
-
UNIQUE(organizationId, userId); // One role per user per org
|
|
91
|
-
}
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
**Purpose**: Junction table linking users to organizations with roles.
|
|
95
|
-
|
|
96
|
-
#### 3. Invitations
|
|
97
|
-
|
|
98
|
-
```typescript
|
|
99
|
-
cms_invitations {
|
|
100
|
-
id: uuid PRIMARY KEY;
|
|
101
|
-
organizationId: uuid NOT NULL → cms_organizations(id) CASCADE;
|
|
102
|
-
email: varchar(255) NOT NULL;
|
|
103
|
-
role: enum('owner', 'admin', 'editor', 'viewer') NOT NULL;
|
|
104
|
-
token: text UNIQUE NOT NULL; // Crypto-random token (32 bytes)
|
|
105
|
-
invitedBy: text NOT NULL; // User ID of inviter
|
|
106
|
-
expiresAt: timestamp NOT NULL; // Default: now() + 7 days
|
|
107
|
-
acceptedAt: timestamp; // Null until accepted
|
|
108
|
-
createdAt: timestamp;
|
|
109
|
-
|
|
110
|
-
UNIQUE(organizationId, email); // Can't invite same email twice
|
|
111
|
-
}
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
**Purpose**: Pending invitations with secure tokens.
|
|
115
|
-
|
|
116
|
-
#### 4. User Sessions (Active Organization Tracking)
|
|
117
|
-
|
|
118
|
-
```typescript
|
|
119
|
-
cms_user_sessions {
|
|
120
|
-
userId: text PRIMARY KEY; // References Better Auth user
|
|
121
|
-
activeOrganizationId: uuid → cms_organizations(id);
|
|
122
|
-
updatedAt: timestamp;
|
|
123
|
-
}
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
**Purpose**: Track which organization user is currently working in.
|
|
127
|
-
|
|
128
|
-
### Modified Tables
|
|
129
|
-
|
|
130
|
-
#### 5. Documents (Add Organization Scoping)
|
|
131
|
-
|
|
132
|
-
```typescript
|
|
133
|
-
cms_documents {
|
|
134
|
-
// ... existing fields
|
|
135
|
-
organizationId: uuid NOT NULL → cms_organizations(id) CASCADE; // NEW
|
|
136
|
-
}
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
#### 6. Assets (Add Organization Scoping)
|
|
140
|
-
|
|
141
|
-
```typescript
|
|
142
|
-
cms_assets {
|
|
143
|
-
// ... existing fields
|
|
144
|
-
organizationId: uuid NOT NULL → cms_organizations(id) CASCADE; // NEW
|
|
145
|
-
}
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
### Keep Existing Tables
|
|
149
|
-
|
|
150
|
-
#### 7. User Profiles (Keep for Global Preferences)
|
|
151
|
-
|
|
152
|
-
```typescript
|
|
153
|
-
cms_user_profiles {
|
|
154
|
-
userId: text PRIMARY KEY;
|
|
155
|
-
preferences: jsonb; // Global preferences (theme, language)
|
|
156
|
-
createdAt: timestamp;
|
|
157
|
-
updatedAt: timestamp;
|
|
158
|
-
}
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
**Note**: This stores global user preferences. Organization-specific preferences go in `cms_organization_members`.
|
|
162
|
-
|
|
163
|
-
---
|
|
164
|
-
|
|
165
|
-
## Phase 2: Core Types & Interfaces
|
|
166
|
-
|
|
167
|
-
### New Types
|
|
168
|
-
|
|
169
|
-
**Location**: `packages/cms-core/src/types/`
|
|
170
|
-
|
|
171
|
-
```typescript
|
|
172
|
-
// types/organization.ts
|
|
173
|
-
export interface Organization {
|
|
174
|
-
id: string;
|
|
175
|
-
name: string;
|
|
176
|
-
slug: string;
|
|
177
|
-
metadata?: {
|
|
178
|
-
logo?: string;
|
|
179
|
-
theme?: { primaryColor: string; fontFamily: string; logoUrl: string };
|
|
180
|
-
website?: string;
|
|
181
|
-
settings?: Record<string, any>;
|
|
182
|
-
};
|
|
183
|
-
createdBy: string;
|
|
184
|
-
createdAt: Date;
|
|
185
|
-
updatedAt: Date;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export interface OrganizationMember {
|
|
189
|
-
id: string;
|
|
190
|
-
organizationId: string;
|
|
191
|
-
userId: string;
|
|
192
|
-
role: 'owner' | 'admin' | 'editor' | 'viewer';
|
|
193
|
-
preferences?: Record<string, any>;
|
|
194
|
-
invitedBy?: string;
|
|
195
|
-
createdAt: Date;
|
|
196
|
-
updatedAt: Date;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
export interface OrganizationMembership {
|
|
200
|
-
organization: Organization; // Full org data
|
|
201
|
-
member: OrganizationMember; // Membership record
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export interface Invitation {
|
|
205
|
-
id: string;
|
|
206
|
-
organizationId: string;
|
|
207
|
-
email: string;
|
|
208
|
-
role: 'owner' | 'admin' | 'editor' | 'viewer';
|
|
209
|
-
token: string;
|
|
210
|
-
invitedBy: string;
|
|
211
|
-
expiresAt: Date;
|
|
212
|
-
acceptedAt?: Date;
|
|
213
|
-
createdAt: Date;
|
|
214
|
-
}
|
|
215
|
-
```
|
|
216
|
-
|
|
217
|
-
### Updated Types
|
|
218
|
-
|
|
219
|
-
```typescript
|
|
220
|
-
// types/user.ts (UPDATED)
|
|
221
|
-
export interface CMSUser extends AuthUser {
|
|
222
|
-
// Super admin flag
|
|
223
|
-
isSuperAdmin: boolean;
|
|
224
|
-
|
|
225
|
-
// Current active organization (what they're working in now)
|
|
226
|
-
activeOrganization?: {
|
|
227
|
-
id: string;
|
|
228
|
-
name: string;
|
|
229
|
-
role: 'owner' | 'admin' | 'editor' | 'viewer';
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
// All organizations user belongs to (for switcher)
|
|
233
|
-
organizations?: OrganizationMembership[];
|
|
234
|
-
|
|
235
|
-
// Global preferences
|
|
236
|
-
preferences?: Record<string, any>;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// types/auth.ts (UPDATED)
|
|
240
|
-
export interface SessionAuth {
|
|
241
|
-
type: 'session';
|
|
242
|
-
user: CMSUser; // Now includes org context
|
|
243
|
-
session: {
|
|
244
|
-
id: string;
|
|
245
|
-
expiresAt: Date;
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
export interface ApiKeyAuth {
|
|
250
|
-
type: 'api_key';
|
|
251
|
-
keyId: string;
|
|
252
|
-
name: string;
|
|
253
|
-
permissions: ('read' | 'write')[];
|
|
254
|
-
organizationId: string; // NEW: All API keys are org-scoped
|
|
255
|
-
createdBy?: string;
|
|
256
|
-
lastUsedAt?: Date;
|
|
257
|
-
}
|
|
258
|
-
```
|
|
259
|
-
|
|
260
|
-
### New Adapter Interface
|
|
261
|
-
|
|
262
|
-
```typescript
|
|
263
|
-
// db/interfaces/organization.ts
|
|
264
|
-
export interface OrganizationAdapter {
|
|
265
|
-
// Organization CRUD
|
|
266
|
-
createOrganization(data: CreateOrganizationData): Promise<Organization>;
|
|
267
|
-
findOrganizationById(id: string): Promise<Organization | null>;
|
|
268
|
-
findOrganizationBySlug(slug: string): Promise<Organization | null>;
|
|
269
|
-
updateOrganization(id: string, data: UpdateOrganizationData): Promise<Organization>;
|
|
270
|
-
deleteOrganization(id: string): Promise<boolean>;
|
|
271
|
-
|
|
272
|
-
// Member management
|
|
273
|
-
addMember(data: AddMemberData): Promise<OrganizationMember>;
|
|
274
|
-
removeMember(organizationId: string, userId: string): Promise<boolean>;
|
|
275
|
-
updateMemberRole(
|
|
276
|
-
organizationId: string,
|
|
277
|
-
userId: string,
|
|
278
|
-
role: string
|
|
279
|
-
): Promise<OrganizationMember>;
|
|
280
|
-
findUserMembership(userId: string, organizationId: string): Promise<OrganizationMember | null>;
|
|
281
|
-
findUserOrganizations(userId: string): Promise<OrganizationMembership[]>;
|
|
282
|
-
findOrganizationMembers(organizationId: string): Promise<OrganizationMember[]>;
|
|
283
|
-
|
|
284
|
-
// Invitation management
|
|
285
|
-
createInvitation(data: CreateInvitationData): Promise<Invitation>;
|
|
286
|
-
findInvitationByToken(token: string): Promise<Invitation | null>;
|
|
287
|
-
findOrganizationInvitations(organizationId: string): Promise<Invitation[]>;
|
|
288
|
-
acceptInvitation(token: string, userId: string): Promise<OrganizationMember>;
|
|
289
|
-
deleteInvitation(id: string): Promise<boolean>;
|
|
290
|
-
cleanupExpiredInvitations(): Promise<number>;
|
|
291
|
-
|
|
292
|
-
// User session management
|
|
293
|
-
updateUserSession(userId: string, organizationId: string): Promise<void>;
|
|
294
|
-
findUserSession(userId: string): Promise<{ activeOrganizationId: string } | null>;
|
|
295
|
-
}
|
|
296
|
-
```
|
|
297
|
-
|
|
298
|
-
### Updated Adapter Interfaces
|
|
299
|
-
|
|
300
|
-
```typescript
|
|
301
|
-
// DocumentAdapter - Add organizationId parameter
|
|
302
|
-
interface DocumentAdapter {
|
|
303
|
-
list(organizationId: string, filters?: DocumentFilters): Promise<Document[]>;
|
|
304
|
-
create(organizationId: string, data: CreateDocumentData): Promise<Document>;
|
|
305
|
-
update(organizationId: string, id: string, data: UpdateDocumentData): Promise<Document>;
|
|
306
|
-
delete(organizationId: string, id: string): Promise<boolean>;
|
|
307
|
-
// ... all methods need organizationId
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// AssetAdapter - Add organizationId parameter
|
|
311
|
-
interface AssetAdapter {
|
|
312
|
-
list(organizationId: string, filters?: AssetFilters): Promise<Asset[]>;
|
|
313
|
-
create(organizationId: string, data: CreateAssetData): Promise<Asset>;
|
|
314
|
-
delete(organizationId: string, id: string): Promise<boolean>;
|
|
315
|
-
// ... all methods need organizationId
|
|
316
|
-
}
|
|
317
|
-
```
|
|
318
|
-
|
|
319
|
-
---
|
|
320
|
-
|
|
321
|
-
## Phase 3: Auth Service Updates
|
|
322
|
-
|
|
323
|
-
### Super Admin Detection
|
|
324
|
-
|
|
325
|
-
**Don't modify Better Auth `user` table**. Use one of these approaches:
|
|
326
|
-
|
|
327
|
-
**Option 1: Environment Variable (Recommended)**
|
|
328
|
-
|
|
329
|
-
```typescript
|
|
330
|
-
// apps/studio/src/lib/server/auth/service.ts
|
|
331
|
-
const SUPER_ADMIN_EMAILS = process.env.SUPER_ADMIN_EMAILS?.split(',') || [];
|
|
332
|
-
|
|
333
|
-
async function isSuperAdmin(email: string): boolean {
|
|
334
|
-
return SUPER_ADMIN_EMAILS.includes(email);
|
|
335
|
-
}
|
|
336
|
-
```
|
|
337
|
-
|
|
338
|
-
**Option 2: Separate CMS Table**
|
|
339
|
-
|
|
340
|
-
```typescript
|
|
341
|
-
// Create cms_super_admins table
|
|
342
|
-
cms_super_admins {
|
|
343
|
-
userId: text PRIMARY KEY;
|
|
344
|
-
createdAt: timestamp;
|
|
345
|
-
}
|
|
346
|
-
```
|
|
347
|
-
|
|
348
|
-
### Enrich authService.getSession()
|
|
349
|
-
|
|
350
|
-
**Location**: `apps/studio/src/lib/server/auth/service.ts`
|
|
351
|
-
|
|
352
|
-
```typescript
|
|
353
|
-
async getSession(request: Request, db: DatabaseAdapter): Promise<SessionAuth | null> {
|
|
354
|
-
// 1. Get Better Auth session (authentication)
|
|
355
|
-
const session = await auth.api.getSession({ headers: request.headers });
|
|
356
|
-
if (!session) return null;
|
|
357
|
-
|
|
358
|
-
// 2. Check super admin status
|
|
359
|
-
const isSuperAdmin = SUPER_ADMIN_EMAILS.includes(session.user.email);
|
|
360
|
-
|
|
361
|
-
// 3. Get active organization from cms_user_sessions
|
|
362
|
-
const userSession = await db.findUserSession(session.user.id);
|
|
363
|
-
const activeOrgId = userSession?.activeOrganizationId;
|
|
364
|
-
|
|
365
|
-
// 4. If activeOrgId exists, get membership details
|
|
366
|
-
let activeOrganization;
|
|
367
|
-
if (activeOrgId) {
|
|
368
|
-
const membership = await db.findUserMembership(session.user.id, activeOrgId);
|
|
369
|
-
if (membership) {
|
|
370
|
-
activeOrganization = {
|
|
371
|
-
id: membership.organization.id,
|
|
372
|
-
name: membership.organization.name,
|
|
373
|
-
role: membership.member.role
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
// 5. Get all user organizations (for switcher)
|
|
379
|
-
const organizations = await db.findUserOrganizations(session.user.id);
|
|
380
|
-
|
|
381
|
-
// 6. Get global preferences (from cms_user_profiles)
|
|
382
|
-
const userProfile = await db.findUserProfileById(session.user.id);
|
|
383
|
-
|
|
384
|
-
// 7. Build enriched CMSUser
|
|
385
|
-
const cmsUser: CMSUser = {
|
|
386
|
-
id: session.user.id,
|
|
387
|
-
email: session.user.email,
|
|
388
|
-
name: session.user.name,
|
|
389
|
-
image: session.user.image,
|
|
390
|
-
isSuperAdmin,
|
|
391
|
-
activeOrganization,
|
|
392
|
-
organizations,
|
|
393
|
-
preferences: userProfile?.preferences || {}
|
|
394
|
-
};
|
|
395
|
-
|
|
396
|
-
return {
|
|
397
|
-
type: 'session',
|
|
398
|
-
user: cmsUser,
|
|
399
|
-
session: {
|
|
400
|
-
id: session.session.id,
|
|
401
|
-
expiresAt: session.session.expiresAt
|
|
402
|
-
}
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
```
|
|
406
|
-
|
|
407
|
-
---
|
|
408
|
-
|
|
409
|
-
## Phase 4: API Endpoints
|
|
410
|
-
|
|
411
|
-
### Organization Management
|
|
412
|
-
|
|
413
|
-
```typescript
|
|
414
|
-
POST /api/organizations // Create org (super admin only)
|
|
415
|
-
GET /api/organizations // List user's organizations
|
|
416
|
-
GET /api/organizations/:id // Get org details
|
|
417
|
-
PATCH /api/organizations/:id // Update org (owner only)
|
|
418
|
-
DELETE /api/organizations/:id // Delete org (owner only)
|
|
419
|
-
POST /api/auth/switch-organization // Switch active org
|
|
420
|
-
|
|
421
|
-
// Super admin only
|
|
422
|
-
GET /api/admin/organizations // List all orgs
|
|
423
|
-
GET /api/admin/organizations/:id/stats // Org stats
|
|
424
|
-
```
|
|
425
|
-
|
|
426
|
-
### Member Management
|
|
427
|
-
|
|
428
|
-
```typescript
|
|
429
|
-
GET /api/organizations/:id/members // List members
|
|
430
|
-
PATCH /api/organizations/:id/members/:userId // Update member role
|
|
431
|
-
DELETE /api/organizations/:id/members/:userId // Remove member
|
|
432
|
-
```
|
|
433
|
-
|
|
434
|
-
### Invitation System
|
|
435
|
-
|
|
436
|
-
```typescript
|
|
437
|
-
// Create invitation (owner/admin only)
|
|
438
|
-
POST /api/organizations/:id/invitations
|
|
439
|
-
{
|
|
440
|
-
email: "user@example.com",
|
|
441
|
-
role: "editor",
|
|
442
|
-
expiresInDays: 7 // Optional, default 7
|
|
443
|
-
}
|
|
444
|
-
// Returns: { id, token, inviteLink: "/invite/token" }
|
|
445
|
-
|
|
446
|
-
// List pending invitations (owner/admin only)
|
|
447
|
-
GET /api/organizations/:id/invitations
|
|
448
|
-
|
|
449
|
-
// Cancel invitation (owner/admin only)
|
|
450
|
-
DELETE /api/invitations/:id
|
|
451
|
-
|
|
452
|
-
// Smart redirect (no UI, just logic)
|
|
453
|
-
GET /invite/:token
|
|
454
|
-
// If authenticated → auto-accept and redirect
|
|
455
|
-
// If not authenticated → redirect to login/signup with ?invite=token
|
|
456
|
-
```
|
|
457
|
-
|
|
458
|
-
### Updated Endpoints
|
|
459
|
-
|
|
460
|
-
```typescript
|
|
461
|
-
// All now automatically scoped by activeOrganizationId
|
|
462
|
-
GET /api/documents
|
|
463
|
-
POST /api/documents
|
|
464
|
-
GET /api/assets
|
|
465
|
-
POST /api/assets
|
|
466
|
-
|
|
467
|
-
// API keys now require organizationId
|
|
468
|
-
POST /api/auth/api-keys
|
|
469
|
-
{
|
|
470
|
-
name: "Production Key",
|
|
471
|
-
permissions: ["read", "write"],
|
|
472
|
-
organizationId: "org-123" // REQUIRED
|
|
473
|
-
}
|
|
474
|
-
```
|
|
475
|
-
|
|
476
|
-
---
|
|
477
|
-
|
|
478
|
-
## Phase 5: Invitation Flow
|
|
479
|
-
|
|
480
|
-
### Complete Flow (Corrected)
|
|
481
|
-
|
|
482
|
-
```
|
|
483
|
-
1. Owner/Admin creates invitation
|
|
484
|
-
↓
|
|
485
|
-
2. Email sent to user with /invite/token link
|
|
486
|
-
↓
|
|
487
|
-
3. User clicks link → GET /invite/:token
|
|
488
|
-
↓
|
|
489
|
-
4. Route checks authentication:
|
|
490
|
-
|
|
491
|
-
If authenticated:
|
|
492
|
-
→ Auto-accept invitation
|
|
493
|
-
→ Redirect to /admin (in new org)
|
|
494
|
-
|
|
495
|
-
If NOT authenticated:
|
|
496
|
-
→ Check if user exists (by email)
|
|
497
|
-
|
|
498
|
-
If user exists:
|
|
499
|
-
→ Redirect to /login?invite=token
|
|
500
|
-
|
|
501
|
-
If user doesn't exist:
|
|
502
|
-
→ Redirect to /signup?invite=token
|
|
503
|
-
↓
|
|
504
|
-
5. After login/signup:
|
|
505
|
-
→ Check for ?invite= param
|
|
506
|
-
→ Auto-accept invitation
|
|
507
|
-
→ Redirect to /admin (in new org)
|
|
508
|
-
```
|
|
509
|
-
|
|
510
|
-
### Implementation
|
|
511
|
-
|
|
512
|
-
```typescript
|
|
513
|
-
// apps/studio/src/routes/invite/[token]/+page.server.ts
|
|
514
|
-
export const load: PageServerLoad = async ({ params, locals }) => {
|
|
515
|
-
const { token } = params;
|
|
516
|
-
|
|
517
|
-
// Get invitation
|
|
518
|
-
const invitation = await db.findInvitationByToken(token);
|
|
519
|
-
if (!invitation) throw error(404, 'Invitation not found');
|
|
520
|
-
if (invitation.expiresAt < new Date()) throw error(410, 'Invitation expired');
|
|
521
|
-
if (invitation.acceptedAt) throw error(410, 'Invitation already accepted');
|
|
522
|
-
|
|
523
|
-
// Check if user is authenticated
|
|
524
|
-
const auth = locals.auth;
|
|
525
|
-
|
|
526
|
-
if (auth && auth.type === 'session') {
|
|
527
|
-
// User is logged in → auto-accept
|
|
528
|
-
await db.acceptInvitation(token, auth.user.id);
|
|
529
|
-
|
|
530
|
-
// Set as active organization
|
|
531
|
-
await db.updateUserSession(auth.user.id, invitation.organizationId);
|
|
532
|
-
|
|
533
|
-
throw redirect(302, '/admin');
|
|
534
|
-
} else {
|
|
535
|
-
// User not logged in → redirect to login/signup
|
|
536
|
-
// Check if user exists
|
|
537
|
-
const userExists = await checkUserExists(invitation.email);
|
|
538
|
-
|
|
539
|
-
if (userExists) {
|
|
540
|
-
throw redirect(302, `/login?invite=${token}`);
|
|
541
|
-
} else {
|
|
542
|
-
throw redirect(302, `/signup?invite=${token}`);
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
};
|
|
546
|
-
```
|
|
547
|
-
|
|
548
|
-
```typescript
|
|
549
|
-
// Update login/signup to handle invite param
|
|
550
|
-
// apps/studio/src/routes/login/+page.server.ts (or form action)
|
|
551
|
-
export const actions = {
|
|
552
|
-
default: async ({ request, locals, url }) => {
|
|
553
|
-
// ... perform login
|
|
554
|
-
|
|
555
|
-
// Check for invite token
|
|
556
|
-
const inviteToken = url.searchParams.get('invite');
|
|
557
|
-
if (inviteToken) {
|
|
558
|
-
await db.acceptInvitation(inviteToken, userId);
|
|
559
|
-
await db.updateUserSession(userId, invitation.organizationId);
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
throw redirect(302, '/admin');
|
|
563
|
-
}
|
|
564
|
-
};
|
|
565
|
-
```
|
|
566
|
-
|
|
567
|
-
### Email Integration
|
|
568
|
-
|
|
569
|
-
```typescript
|
|
570
|
-
// Send invitation email (use Resend, SendGrid, etc.)
|
|
571
|
-
const invitation = await db.createInvitation({
|
|
572
|
-
organizationId: org.id,
|
|
573
|
-
email: 'user@example.com',
|
|
574
|
-
role: 'editor',
|
|
575
|
-
invitedBy: currentUser.id,
|
|
576
|
-
expiresInDays: 7
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
await emailService.send({
|
|
580
|
-
to: invitation.email,
|
|
581
|
-
subject: `You've been invited to ${org.name}`,
|
|
582
|
-
template: 'invitation',
|
|
583
|
-
data: {
|
|
584
|
-
orgName: org.name,
|
|
585
|
-
inviterName: currentUser.name,
|
|
586
|
-
role: invitation.role,
|
|
587
|
-
inviteLink: `https://yourdomain.com/invite/${invitation.token}`,
|
|
588
|
-
expiresAt: invitation.expiresAt
|
|
589
|
-
}
|
|
590
|
-
});
|
|
591
|
-
```
|
|
592
|
-
|
|
593
|
-
---
|
|
594
|
-
|
|
595
|
-
## Phase 6: Frontend Components
|
|
596
|
-
|
|
597
|
-
### Organization Switcher
|
|
598
|
-
|
|
599
|
-
```svelte
|
|
600
|
-
<!-- apps/studio/src/lib/components/OrganizationSwitcher.svelte -->
|
|
601
|
-
<script lang="ts">
|
|
602
|
-
import { page } from '$app/stores';
|
|
603
|
-
|
|
604
|
-
let user = $derived($page.data.auth?.user);
|
|
605
|
-
let activeOrg = $derived(user?.activeOrganization);
|
|
606
|
-
let organizations = $derived(user?.organizations || []);
|
|
607
|
-
let isSuperAdmin = $derived(user?.isSuperAdmin || false);
|
|
608
|
-
|
|
609
|
-
async function switchOrganization(orgId: string) {
|
|
610
|
-
await fetch('/api/auth/switch-organization', {
|
|
611
|
-
method: 'POST',
|
|
612
|
-
headers: { 'Content-Type': 'application/json' },
|
|
613
|
-
body: JSON.stringify({ organizationId: orgId })
|
|
614
|
-
});
|
|
615
|
-
window.location.reload();
|
|
616
|
-
}
|
|
617
|
-
</script>
|
|
618
|
-
|
|
619
|
-
{#if isSuperAdmin}
|
|
620
|
-
<span class="badge">Super Admin</span>
|
|
621
|
-
{/if}
|
|
622
|
-
|
|
623
|
-
<select value={activeOrg?.id} onchange={(e) => switchOrganization(e.currentTarget.value)}>
|
|
624
|
-
{#if !activeOrg}
|
|
625
|
-
<option value="">Select Organization</option>
|
|
626
|
-
{/if}
|
|
627
|
-
{#each organizations as { organization, member }}
|
|
628
|
-
<option value={organization.id}>
|
|
629
|
-
{organization.name} ({member.role})
|
|
630
|
-
</option>
|
|
631
|
-
{/each}
|
|
632
|
-
</select>
|
|
633
|
-
|
|
634
|
-
{#if isSuperAdmin}
|
|
635
|
-
<a href="/admin/organizations/new">+ Create Organization</a>
|
|
636
|
-
{/if}
|
|
637
|
-
```
|
|
638
|
-
|
|
639
|
-
### Key UI Updates
|
|
640
|
-
|
|
641
|
-
- **Header/Sidebar**: Show active organization and switcher
|
|
642
|
-
- **Organization Settings**: Only visible to owners/admins
|
|
643
|
-
- **Invite Members Form**: Only visible to owners/admins
|
|
644
|
-
- **Pending Invitations List**: Show in members page
|
|
645
|
-
- **Login/Signup Pages**: Handle `?invite=` parameter
|
|
646
|
-
- **No Accept Invite Page**: `/invite/:token` is just a smart redirect
|
|
647
|
-
|
|
648
|
-
---
|
|
649
|
-
|
|
650
|
-
## Phase 7: Public Content & Landing Pages
|
|
651
|
-
|
|
652
|
-
### Organization-Based Routing
|
|
653
|
-
|
|
654
|
-
Each organization can have landing pages with custom branding:
|
|
655
|
-
|
|
656
|
-
**Option A: Subdomain**
|
|
657
|
-
|
|
658
|
-
```
|
|
659
|
-
https://client-a.yourdomain.com/product-launch
|
|
660
|
-
https://client-b.yourdomain.com/services
|
|
661
|
-
```
|
|
662
|
-
|
|
663
|
-
**Option B: Path**
|
|
664
|
-
|
|
665
|
-
```
|
|
666
|
-
https://yourdomain.com/client-a/product-launch
|
|
667
|
-
https://yourdomain.com/client-b/services
|
|
668
|
-
```
|
|
669
|
-
|
|
670
|
-
### Implementation
|
|
671
|
-
|
|
672
|
-
```typescript
|
|
673
|
-
// Landing pages are just documents scoped by organizationId
|
|
674
|
-
await db.createDocument(clientAOrgId, {
|
|
675
|
-
schemaType: 'landingPage',
|
|
676
|
-
data: {
|
|
677
|
-
title: 'Product Launch',
|
|
678
|
-
slug: 'product-launch',
|
|
679
|
-
hero: { ... },
|
|
680
|
-
sections: [ ... ],
|
|
681
|
-
status: 'published'
|
|
682
|
-
}
|
|
683
|
-
});
|
|
684
|
-
|
|
685
|
-
// Route resolves org from subdomain/path
|
|
686
|
-
const org = await db.findOrganizationBySlug(subdomain);
|
|
687
|
-
const pages = await db.findDocuments(org.id, {
|
|
688
|
-
schemaType: 'landingPage',
|
|
689
|
-
filters: { 'data.slug': pageSlug, 'data.status': 'published' }
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
// Apply org theme from metadata
|
|
693
|
-
const theme = org.metadata?.theme || defaultTheme;
|
|
694
|
-
```
|
|
695
|
-
|
|
696
|
-
---
|
|
697
|
-
|
|
698
|
-
## Phase 8: Migration Strategy
|
|
699
|
-
|
|
700
|
-
### Step 1: Add Schema
|
|
701
|
-
|
|
702
|
-
```sql
|
|
703
|
-
-- Add new tables
|
|
704
|
-
CREATE TABLE cms_organizations (...);
|
|
705
|
-
CREATE TABLE cms_organization_members (...);
|
|
706
|
-
CREATE TABLE cms_invitations (...);
|
|
707
|
-
CREATE TABLE cms_user_sessions (...);
|
|
708
|
-
|
|
709
|
-
-- Add organizationId to existing tables
|
|
710
|
-
ALTER TABLE cms_documents ADD COLUMN organization_id UUID;
|
|
711
|
-
ALTER TABLE cms_assets ADD COLUMN organization_id UUID;
|
|
712
|
-
```
|
|
713
|
-
|
|
714
|
-
### Step 2: Migrate Data
|
|
715
|
-
|
|
716
|
-
```sql
|
|
717
|
-
-- Create default organization
|
|
718
|
-
INSERT INTO cms_organizations (id, name, slug, created_by)
|
|
719
|
-
VALUES (
|
|
720
|
-
gen_random_uuid(),
|
|
721
|
-
'Default Organization',
|
|
722
|
-
'default',
|
|
723
|
-
(SELECT id FROM user WHERE email = 'admin@example.com')
|
|
724
|
-
);
|
|
725
|
-
|
|
726
|
-
-- Migrate user profiles to organization members
|
|
727
|
-
INSERT INTO cms_organization_members (organization_id, user_id, role)
|
|
728
|
-
SELECT
|
|
729
|
-
(SELECT id FROM cms_organizations WHERE slug = 'default'),
|
|
730
|
-
user_id,
|
|
731
|
-
'editor' -- Default role
|
|
732
|
-
FROM cms_user_profiles;
|
|
733
|
-
|
|
734
|
-
-- Assign all documents to default org
|
|
735
|
-
UPDATE cms_documents
|
|
736
|
-
SET organization_id = (SELECT id FROM cms_organizations WHERE slug = 'default');
|
|
737
|
-
|
|
738
|
-
-- Assign all assets to default org
|
|
739
|
-
UPDATE cms_assets
|
|
740
|
-
SET organization_id = (SELECT id FROM cms_organizations WHERE slug = 'default');
|
|
741
|
-
```
|
|
742
|
-
|
|
743
|
-
### Step 3: Deploy Code
|
|
744
|
-
|
|
745
|
-
- Update adapters
|
|
746
|
-
- Update auth service
|
|
747
|
-
- Update UI
|
|
748
|
-
|
|
749
|
-
### Step 4: Make organizationId NOT NULL
|
|
750
|
-
|
|
751
|
-
```sql
|
|
752
|
-
ALTER TABLE cms_documents ALTER COLUMN organization_id SET NOT NULL;
|
|
753
|
-
ALTER TABLE cms_assets ALTER COLUMN organization_id SET NOT NULL;
|
|
754
|
-
```
|
|
755
|
-
|
|
756
|
-
---
|
|
757
|
-
|
|
758
|
-
## Key Architecture Decisions
|
|
759
|
-
|
|
760
|
-
### ✅ What We're Doing
|
|
761
|
-
|
|
762
|
-
1. **Don't Modify Better Auth Tables**
|
|
763
|
-
- Store super admin list in env var or separate table
|
|
764
|
-
- Store active org in `cms_user_sessions` (not `session` table)
|
|
765
|
-
|
|
766
|
-
2. **Keep User Profiles Table**
|
|
767
|
-
- For global user preferences (theme, language)
|
|
768
|
-
- Organization-specific preferences go in `cms_organization_members`
|
|
769
|
-
|
|
770
|
-
3. **Many-to-Many User-Organization**
|
|
771
|
-
- Users can belong to multiple organizations
|
|
772
|
-
- Different role in each organization
|
|
773
|
-
- One active organization at a time
|
|
774
|
-
|
|
775
|
-
4. **Smart Invite Flow**
|
|
776
|
-
- No separate "accept invite" page
|
|
777
|
-
- `/invite/:token` auto-redirects to login/signup
|
|
778
|
-
- Auto-accept after authentication
|
|
779
|
-
|
|
780
|
-
5. **Organization Scoping**
|
|
781
|
-
- All documents/assets require `organizationId`
|
|
782
|
-
- API keys are organization-scoped
|
|
783
|
-
- Complete data isolation
|
|
784
|
-
|
|
785
|
-
### ❌ What We're NOT Doing
|
|
786
|
-
|
|
787
|
-
1. **Not modifying Better Auth schema** - All extensions in CMS tables
|
|
788
|
-
2. **Not adding fine-grained permissions yet** - Start with role-based access
|
|
789
|
-
3. **Not implementing datasets yet** - Can add later (dev/staging/prod)
|
|
790
|
-
4. **Not using Better Auth hooks for sync** - Using lazy sync in authService
|
|
791
|
-
|
|
792
|
-
---
|
|
793
|
-
|
|
794
|
-
## Summary Checklist
|
|
795
|
-
|
|
796
|
-
- [ ] Phase 1: Create database schema (4 new tables, 2 modified)
|
|
797
|
-
- [ ] Phase 2: Define types and interfaces
|
|
798
|
-
- [ ] Phase 3: Implement OrganizationAdapter (PostgreSQL)
|
|
799
|
-
- [ ] Phase 4: Update DocumentAdapter and AssetAdapter signatures
|
|
800
|
-
- [ ] Phase 5: Update authService.getSession() to enrich with org data
|
|
801
|
-
- [ ] Phase 6: Create organization management endpoints
|
|
802
|
-
- [ ] Phase 7: Implement invitation system (create, accept, email)
|
|
803
|
-
- [ ] Phase 8: Build organization switcher UI
|
|
804
|
-
- [ ] Phase 9: Update login/signup to handle invite params
|
|
805
|
-
- [ ] Phase 10: Add organization settings pages
|
|
806
|
-
- [ ] Phase 11: Test multi-tenancy isolation
|
|
807
|
-
- [ ] Phase 12: Run migration on existing data
|
|
808
|
-
- [ ] Phase 13: (Optional) Add landing page routing
|
|
809
|
-
- [ ] Phase 14: (Optional) Set up email service for invitations
|
|
810
|
-
|
|
811
|
-
---
|
|
812
|
-
|
|
813
|
-
**Estimated Time**: 2-3 weeks for core multi-tenancy (Phases 1-12)
|
|
814
|
-
|
|
815
|
-
**Next Steps**: Start with Phase 1 (Database Schema) - create the migration file.
|
|
816
|
-
|
|
817
|
-
---
|
|
818
|
-
|
|
819
|
-
## Best Practices & Guardrails for Soft Multi-Tenancy
|
|
820
|
-
|
|
821
|
-
Since this implementation uses soft multi-tenancy (shared database), it's critical to have proper guardrails to prevent data leaks and noisy neighbor issues.
|
|
822
|
-
|
|
823
|
-
### ✅ Current Safeguards in This Plan
|
|
824
|
-
|
|
825
|
-
#### 1. **Database-Level Constraints**
|
|
826
|
-
|
|
827
|
-
```sql
|
|
828
|
-
-- Foreign key ensures organizationId is valid
|
|
829
|
-
ALTER TABLE cms_documents
|
|
830
|
-
ADD CONSTRAINT fk_documents_organization
|
|
831
|
-
FOREIGN KEY (organization_id)
|
|
832
|
-
REFERENCES cms_organizations(id)
|
|
833
|
-
ON DELETE CASCADE;
|
|
834
|
-
|
|
835
|
-
-- NOT NULL prevents missing organizationId
|
|
836
|
-
ALTER TABLE cms_documents
|
|
837
|
-
ALTER COLUMN organization_id SET NOT NULL;
|
|
838
|
-
|
|
839
|
-
-- Index for fast filtering (prevents slow queries)
|
|
840
|
-
CREATE INDEX idx_documents_organization ON cms_documents(organization_id);
|
|
841
|
-
CREATE INDEX idx_assets_organization ON cms_assets(organization_id);
|
|
842
|
-
```
|
|
843
|
-
|
|
844
|
-
**Why**: These constraints ensure you CANNOT create documents without an organization, and queries filter efficiently.
|
|
845
|
-
|
|
846
|
-
#### 2. **Adapter-Level Enforcement**
|
|
847
|
-
|
|
848
|
-
```typescript
|
|
849
|
-
// ALL adapter methods REQUIRE organizationId parameter
|
|
850
|
-
interface DocumentAdapter {
|
|
851
|
-
// ✅ Good: organizationId required
|
|
852
|
-
list(organizationId: string, filters?: Filters): Promise<Document[]>;
|
|
853
|
-
|
|
854
|
-
// ❌ Bad: organizationId optional or missing
|
|
855
|
-
list(filters?: Filters): Promise<Document[]>;
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
// Implementation ensures filtering
|
|
859
|
-
async list(organizationId: string, filters) {
|
|
860
|
-
return await db.select()
|
|
861
|
-
.from(documents)
|
|
862
|
-
.where(eq(documents.organizationId, organizationId)); // ALWAYS filtered
|
|
863
|
-
}
|
|
864
|
-
```
|
|
865
|
-
|
|
866
|
-
**Why**: Makes it impossible to forget filtering by organizationId - it's a required parameter.
|
|
867
|
-
|
|
868
|
-
#### 3. **Auth Hook Enforcement**
|
|
869
|
-
|
|
870
|
-
```typescript
|
|
871
|
-
// Auth hook sets organizationId in event.locals
|
|
872
|
-
if (auth.type === 'session') {
|
|
873
|
-
if (!auth.user.activeOrganization) {
|
|
874
|
-
// ✅ No org selected → block access
|
|
875
|
-
throw redirect(302, '/select-organization');
|
|
876
|
-
}
|
|
877
|
-
event.locals.organizationId = auth.user.activeOrganization.id;
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
// API routes use locals.organizationId
|
|
881
|
-
const orgId = locals.organizationId; // Set by auth hook
|
|
882
|
-
const docs = await db.findDocuments(orgId, filters);
|
|
883
|
-
```
|
|
884
|
-
|
|
885
|
-
**Why**: Centralized enforcement - organizationId is set once in middleware, used everywhere.
|
|
886
|
-
|
|
887
|
-
#### 4. **Type Safety**
|
|
888
|
-
|
|
889
|
-
```typescript
|
|
890
|
-
// TypeScript ensures organizationId is provided
|
|
891
|
-
const docs = await db.findDocuments(orgId, { schemaType: 'post' });
|
|
892
|
-
// ^^^^^ Required, won't compile without it
|
|
893
|
-
```
|
|
894
|
-
|
|
895
|
-
**Why**: Compile-time safety prevents accidental cross-org queries.
|
|
896
|
-
|
|
897
|
-
---
|
|
898
|
-
|
|
899
|
-
### ⚠️ Recommended Additional Safeguards
|
|
900
|
-
|
|
901
|
-
#### 1. **Row-Level Security (RLS) - Database Level** (HIGHLY RECOMMENDED)
|
|
902
|
-
|
|
903
|
-
Add PostgreSQL Row-Level Security as a backup:
|
|
904
|
-
|
|
905
|
-
```sql
|
|
906
|
-
-- Enable RLS on tables
|
|
907
|
-
ALTER TABLE cms_documents ENABLE ROW LEVEL SECURITY;
|
|
908
|
-
ALTER TABLE cms_assets ENABLE ROW LEVEL SECURITY;
|
|
909
|
-
|
|
910
|
-
-- Create policy: users can only see their org's data
|
|
911
|
-
CREATE POLICY documents_org_isolation ON cms_documents
|
|
912
|
-
USING (organization_id = current_setting('app.current_organization_id')::uuid);
|
|
913
|
-
|
|
914
|
-
CREATE POLICY assets_org_isolation ON cms_assets
|
|
915
|
-
USING (organization_id = current_setting('app.current_organization_id')::uuid);
|
|
916
|
-
|
|
917
|
-
-- Set org context per request
|
|
918
|
-
SET app.current_organization_id = 'org-123';
|
|
919
|
-
SELECT * FROM cms_documents; -- Only sees org-123's documents
|
|
920
|
-
```
|
|
921
|
-
|
|
922
|
-
**Implementation**:
|
|
923
|
-
|
|
924
|
-
```typescript
|
|
925
|
-
// In adapter, set session variable before querying
|
|
926
|
-
async list(organizationId: string, filters) {
|
|
927
|
-
// Set RLS context
|
|
928
|
-
await db.execute(sql`SET app.current_organization_id = ${organizationId}`);
|
|
929
|
-
|
|
930
|
-
// Even if we forget WHERE clause, RLS protects us
|
|
931
|
-
return await db.select().from(documents);
|
|
932
|
-
}
|
|
933
|
-
```
|
|
934
|
-
|
|
935
|
-
**Benefit**: Even if application code has a bug, database-level RLS prevents cross-org access.
|
|
936
|
-
|
|
937
|
-
---
|
|
938
|
-
|
|
939
|
-
#### 2. **Query Timeouts** (Prevent Noisy Neighbor)
|
|
940
|
-
|
|
941
|
-
```typescript
|
|
942
|
-
// Set statement timeout per request
|
|
943
|
-
async executeQuery(orgId: string, query: SQL) {
|
|
944
|
-
await db.execute(sql`SET statement_timeout = '30s'`); // Kill after 30s
|
|
945
|
-
|
|
946
|
-
try {
|
|
947
|
-
return await db.execute(query);
|
|
948
|
-
} catch (error) {
|
|
949
|
-
if (error.code === '57014') { // Query timeout
|
|
950
|
-
throw new Error('Query took too long - please optimize your filters');
|
|
951
|
-
}
|
|
952
|
-
throw error;
|
|
953
|
-
}
|
|
954
|
-
}
|
|
955
|
-
```
|
|
956
|
-
|
|
957
|
-
**Benefit**: Prevents one org's bad query from locking the database.
|
|
958
|
-
|
|
959
|
-
---
|
|
960
|
-
|
|
961
|
-
#### 3. **Connection Pool Limits Per Organization**
|
|
962
|
-
|
|
963
|
-
```typescript
|
|
964
|
-
// Track active connections per org
|
|
965
|
-
const orgConnectionCount = new Map<string, number>();
|
|
966
|
-
|
|
967
|
-
async function getConnection(orgId: string) {
|
|
968
|
-
const current = orgConnectionCount.get(orgId) || 0;
|
|
969
|
-
|
|
970
|
-
// Limit to 5 concurrent connections per org
|
|
971
|
-
if (current >= 5) {
|
|
972
|
-
throw new Error('Too many concurrent requests - please try again');
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
orgConnectionCount.set(orgId, current + 1);
|
|
976
|
-
|
|
977
|
-
const conn = await pool.connect();
|
|
978
|
-
|
|
979
|
-
conn.on('release', () => {
|
|
980
|
-
orgConnectionCount.set(orgId, (orgConnectionCount.get(orgId) || 1) - 1);
|
|
981
|
-
});
|
|
982
|
-
|
|
983
|
-
return conn;
|
|
984
|
-
}
|
|
985
|
-
```
|
|
986
|
-
|
|
987
|
-
**Benefit**: Prevents one org from exhausting the connection pool.
|
|
988
|
-
|
|
989
|
-
---
|
|
990
|
-
|
|
991
|
-
#### 4. **API Rate Limiting** ✅ Already Handled by Better Auth
|
|
992
|
-
|
|
993
|
-
**Current Implementation:**
|
|
994
|
-
|
|
995
|
-
```typescript
|
|
996
|
-
// apps/studio/src/lib/server/auth/better-auth/instance.ts
|
|
997
|
-
plugins: [
|
|
998
|
-
apiKey({
|
|
999
|
-
apiKeyHeaders: ['x-api-key'],
|
|
1000
|
-
rateLimit: {
|
|
1001
|
-
enabled: true,
|
|
1002
|
-
timeWindow: 1000 * 60 * 60 * 24, // 24 hours (adjustable)
|
|
1003
|
-
maxRequests: 10000 // 10k requests/day (adjustable)
|
|
1004
|
-
},
|
|
1005
|
-
enableMetadata: true
|
|
1006
|
-
})
|
|
1007
|
-
];
|
|
1008
|
-
```
|
|
1009
|
-
|
|
1010
|
-
**Benefit**:
|
|
1011
|
-
|
|
1012
|
-
- ✅ API keys already rate-limited (10k requests/day by default)
|
|
1013
|
-
- ✅ Configurable per deployment (adjust timeWindow and maxRequests)
|
|
1014
|
-
- ✅ Handled by Better Auth (automatic enforcement)
|
|
1015
|
-
- ✅ Prevents API abuse without additional infrastructure
|
|
1016
|
-
- ✅ No Redis/Upstash needed for basic rate limiting
|
|
1017
|
-
|
|
1018
|
-
**Note**: This is per-key rate limiting. Each API key is limited independently. For organization-level aggregated limits (e.g., "Org A gets 100k requests/day across all keys"), you'd need custom rate limiting, but per-key limits are sufficient for most use cases.
|
|
1019
|
-
|
|
1020
|
-
---
|
|
1021
|
-
|
|
1022
|
-
#### 5. **Monitoring & Alerting Per Organization**
|
|
1023
|
-
|
|
1024
|
-
```typescript
|
|
1025
|
-
// Track query performance per org
|
|
1026
|
-
async function executeQuery(orgId: string, query: SQL) {
|
|
1027
|
-
const start = Date.now();
|
|
1028
|
-
|
|
1029
|
-
try {
|
|
1030
|
-
const result = await db.execute(query);
|
|
1031
|
-
const duration = Date.now() - start;
|
|
1032
|
-
|
|
1033
|
-
// Log slow queries
|
|
1034
|
-
if (duration > 1000) {
|
|
1035
|
-
console.warn(`[Org ${orgId}] Slow query (${duration}ms):`, query);
|
|
1036
|
-
|
|
1037
|
-
// Alert if consistently slow
|
|
1038
|
-
await metrics.increment('slow_queries', { organizationId: orgId });
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
return result;
|
|
1042
|
-
} catch (error) {
|
|
1043
|
-
// Track errors per org
|
|
1044
|
-
await metrics.increment('query_errors', { organizationId: orgId });
|
|
1045
|
-
throw error;
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
```
|
|
1049
|
-
|
|
1050
|
-
**Benefit**: Identify problematic organizations before they affect others.
|
|
1051
|
-
|
|
1052
|
-
---
|
|
1053
|
-
|
|
1054
|
-
#### 6. **Audit Logging**
|
|
1055
|
-
|
|
1056
|
-
```typescript
|
|
1057
|
-
// Log all data access
|
|
1058
|
-
cms_audit_logs {
|
|
1059
|
-
id: uuid;
|
|
1060
|
-
organizationId: uuid;
|
|
1061
|
-
userId: text;
|
|
1062
|
-
action: enum('read', 'create', 'update', 'delete');
|
|
1063
|
-
resourceType: string; // 'document', 'asset'
|
|
1064
|
-
resourceId: uuid;
|
|
1065
|
-
timestamp: timestamp;
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
// In adapter
|
|
1069
|
-
async create(orgId: string, data: CreateDocumentData) {
|
|
1070
|
-
const doc = await db.insert(documents).values({
|
|
1071
|
-
...data,
|
|
1072
|
-
organizationId: orgId
|
|
1073
|
-
});
|
|
1074
|
-
|
|
1075
|
-
// Audit log
|
|
1076
|
-
await db.insert(auditLogs).values({
|
|
1077
|
-
organizationId: orgId,
|
|
1078
|
-
userId: currentUser.id,
|
|
1079
|
-
action: 'create',
|
|
1080
|
-
resourceType: 'document',
|
|
1081
|
-
resourceId: doc.id,
|
|
1082
|
-
timestamp: new Date()
|
|
1083
|
-
});
|
|
1084
|
-
|
|
1085
|
-
return doc;
|
|
1086
|
-
}
|
|
1087
|
-
```
|
|
1088
|
-
|
|
1089
|
-
**Benefit**: Track and investigate any cross-org access attempts.
|
|
1090
|
-
|
|
1091
|
-
---
|
|
1092
|
-
|
|
1093
|
-
#### 7. **Regular Security Audits**
|
|
1094
|
-
|
|
1095
|
-
```typescript
|
|
1096
|
-
// Automated check: Find documents without organizationId (shouldn't exist)
|
|
1097
|
-
async function auditOrganizationIsolation() {
|
|
1098
|
-
const orphanedDocs = await db.select().from(documents).where(isNull(documents.organizationId));
|
|
1099
|
-
|
|
1100
|
-
if (orphanedDocs.length > 0) {
|
|
1101
|
-
console.error(`SECURITY ALERT: ${orphanedDocs.length} documents without organizationId!`);
|
|
1102
|
-
// Alert admin
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
// Check for cross-org references
|
|
1106
|
-
const invalidRefs = await db.execute(sql`
|
|
1107
|
-
SELECT d.id, d.organization_id, a.organization_id as asset_org_id
|
|
1108
|
-
FROM cms_documents d
|
|
1109
|
-
JOIN cms_assets a ON d.data->>'imageId' = a.id::text
|
|
1110
|
-
WHERE d.organization_id != a.organization_id
|
|
1111
|
-
`);
|
|
1112
|
-
|
|
1113
|
-
if (invalidRefs.length > 0) {
|
|
1114
|
-
console.error(`SECURITY ALERT: ${invalidRefs.length} cross-org references!`);
|
|
1115
|
-
}
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
// Run daily
|
|
1119
|
-
setInterval(auditOrganizationIsolation, 24 * 60 * 60 * 1000);
|
|
1120
|
-
```
|
|
1121
|
-
|
|
1122
|
-
**Benefit**: Catch isolation bugs early before they become security issues.
|
|
1123
|
-
|
|
1124
|
-
---
|
|
1125
|
-
|
|
1126
|
-
### ✅ Summary: Is This Plan Following Best Practices?
|
|
1127
|
-
|
|
1128
|
-
**YES**, the current plan has good safeguards:
|
|
1129
|
-
|
|
1130
|
-
| Safeguard | Status | Notes |
|
|
1131
|
-
| ------------------------------------- | --------------- | ---------------------------------------- |
|
|
1132
|
-
| **Required organizationId parameter** | ✅ Built-in | Adapter interface enforces it |
|
|
1133
|
-
| **Database foreign keys** | ✅ Built-in | Prevents invalid organizationId |
|
|
1134
|
-
| **NOT NULL constraints** | ✅ Built-in | Prevents missing organizationId |
|
|
1135
|
-
| **API key rate limiting** | ✅ Built-in | Better Auth plugin (10k/day, adjustable) |
|
|
1136
|
-
| **Indexes on organizationId** | ⚠️ Add this | Ensure fast queries (performance) |
|
|
1137
|
-
| **Row-Level Security (RLS)** | ❌ Not included | HIGHLY RECOMMENDED to add |
|
|
1138
|
-
| **Query timeouts** | ❌ Not included | Recommended for production |
|
|
1139
|
-
| **Connection pool limits** | ❌ Not included | Recommended for scale |
|
|
1140
|
-
| **Audit logging** | ❌ Not included | Recommended for compliance |
|
|
1141
|
-
|
|
1142
|
-
### Recommendations:
|
|
1143
|
-
|
|
1144
|
-
**Must Have (Before Production)**:
|
|
1145
|
-
|
|
1146
|
-
1. ✅ Add database indexes on `organizationId` columns
|
|
1147
|
-
2. ✅ Implement Row-Level Security (RLS) in PostgreSQL
|
|
1148
|
-
3. ✅ Add query timeouts
|
|
1149
|
-
|
|
1150
|
-
**Should Have (For Scale)**: 4. ✅ Add connection pool limits per organization 5. ✅ Set up monitoring per organization 6. ✅ Consider session-based rate limiting (for UI users, not API keys)
|
|
1151
|
-
|
|
1152
|
-
**Nice to Have (For Enterprise)**: 7. ✅ Audit logging 8. ✅ Automated security audits 9. ✅ Backup/restore per organization 10. ✅ Organization-level aggregated rate limits (across all API keys)
|
|
1153
|
-
|
|
1154
|
-
---
|
|
1155
|
-
|
|
1156
|
-
## Enterprise Multi-Tenancy Considerations
|
|
1157
|
-
|
|
1158
|
-
For clients requiring true database isolation, consider these evolution paths:
|
|
1159
|
-
|
|
1160
|
-
### **Tier 2: Schema-per-Tenant** (Intermediate)
|
|
1161
|
-
|
|
1162
|
-
- Each org gets a PostgreSQL schema (same database, isolated tables)
|
|
1163
|
-
- Better isolation than row-level filtering
|
|
1164
|
-
- Still shares compute resources
|
|
1165
|
-
- ~4x price increase
|
|
1166
|
-
|
|
1167
|
-
### **Tier 3: Database-per-Tenant** (Advanced)
|
|
1168
|
-
|
|
1169
|
-
- Each org gets a separate database (different DBs, same server)
|
|
1170
|
-
- Complete database isolation
|
|
1171
|
-
- Independent backups/restores
|
|
1172
|
-
- Can support data residency requirements
|
|
1173
|
-
- ~10x price increase
|
|
1174
|
-
|
|
1175
|
-
### **Tier 4: Fully Isolated** (Enterprise)
|
|
1176
|
-
|
|
1177
|
-
- Each org gets dedicated compute + database + storage
|
|
1178
|
-
- No noisy neighbor effects
|
|
1179
|
-
- Custom SLAs and scaling
|
|
1180
|
-
- Suitable for Fortune 500 clients
|
|
1181
|
-
- ~40x+ price increase
|
|
1182
|
-
|
|
1183
|
-
**Implementation Note**: Current architecture supports gradual migration - start with soft multi-tenancy, upgrade specific orgs to higher tiers as needed.
|