@rebasepro/server-postgresql 0.2.3 → 0.2.5
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/dist/common/src/collections/default-collections.d.ts +9 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/util/permissions.d.ts +1 -0
- package/dist/index.es.js +1075 -470
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +1071 -466
- package/dist/index.umd.js.map +1 -1
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +3 -1
- package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +1 -0
- package/dist/server-postgresql/src/auth/services.d.ts +48 -31
- package/dist/server-postgresql/src/connection.d.ts +25 -0
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +2135 -41
- package/dist/server-postgresql/src/services/EntityFetchService.d.ts +4 -0
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +4 -0
- package/dist/server-postgresql/src/services/entityService.d.ts +6 -0
- package/dist/server-postgresql/src/services/realtimeService.d.ts +20 -0
- package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +18 -0
- package/dist/types/src/controllers/auth.d.ts +4 -26
- package/dist/types/src/controllers/client.d.ts +25 -43
- package/dist/types/src/controllers/collection_registry.d.ts +1 -1
- package/dist/types/src/controllers/data.d.ts +4 -0
- package/dist/types/src/controllers/data_driver.d.ts +23 -0
- package/dist/types/src/controllers/registry.d.ts +5 -4
- package/dist/types/src/rebase_context.d.ts +1 -1
- package/dist/types/src/types/auth_adapter.d.ts +5 -60
- package/dist/types/src/types/backend.d.ts +2 -2
- package/dist/types/src/types/backend_hooks.d.ts +2 -17
- package/dist/types/src/types/collections.d.ts +0 -4
- package/dist/types/src/types/component_ref.d.ts +1 -1
- package/dist/types/src/types/cron.d.ts +1 -1
- package/dist/types/src/types/entity_views.d.ts +1 -0
- package/dist/types/src/types/export_import.d.ts +1 -1
- package/dist/types/src/types/formex.d.ts +2 -2
- package/dist/types/src/types/properties.d.ts +9 -7
- package/dist/types/src/types/translations.d.ts +28 -12
- package/dist/types/src/types/user_management_delegate.d.ts +22 -57
- package/dist/types/src/users/index.d.ts +0 -1
- package/dist/types/src/users/user.d.ts +0 -1
- package/package.json +6 -6
- package/src/PostgresBackendDriver.ts +14 -2
- package/src/PostgresBootstrapper.ts +30 -20
- package/src/auth/ensure-tables.ts +116 -103
- package/src/auth/services.ts +347 -177
- package/src/connection.ts +77 -0
- package/src/data-transformer.ts +2 -2
- package/src/schema/auth-schema.ts +85 -75
- package/src/schema/doctor.ts +44 -3
- package/src/schema/generate-drizzle-schema-logic.ts +33 -3
- package/src/schema/generate-drizzle-schema.ts +6 -6
- package/src/schema/introspect-db-logic.ts +7 -0
- package/src/services/EntityFetchService.ts +69 -10
- package/src/services/EntityPersistService.ts +9 -0
- package/src/services/entityService.ts +9 -0
- package/src/services/realtimeService.ts +214 -2
- package/src/utils/drizzle-conditions.ts +74 -2
- package/src/websocket.ts +10 -2
- package/test/auth-services.test.ts +10 -166
- package/test/doctor.test.ts +6 -2
- package/test/drizzle-conditions.test.ts +168 -0
- package/vite.config.ts +1 -1
- package/dist/server-postgresql/src/schema/default-collections.d.ts +0 -2
- package/dist/types/src/users/roles.d.ts +0 -22
- package/src/schema/default-collections.ts +0 -69
package/src/auth/services.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { eq, getTableName, sql } from "drizzle-orm";
|
|
2
2
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
3
|
import { getTableConfig, PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
|
|
4
|
-
import { users,
|
|
4
|
+
import { users, refreshTokens, passwordResetTokens, userIdentities } from "../schema/auth-schema";
|
|
5
5
|
import {
|
|
6
6
|
UserRepository,
|
|
7
7
|
RoleRepository,
|
|
8
8
|
TokenRepository,
|
|
9
|
+
MfaRepository,
|
|
9
10
|
AuthRepository,
|
|
10
11
|
UserData,
|
|
11
12
|
CreateUserData,
|
|
@@ -16,6 +17,8 @@ import {
|
|
|
16
17
|
UserIdentityData,
|
|
17
18
|
ListUsersOptions,
|
|
18
19
|
PaginatedUsersResult,
|
|
20
|
+
MfaFactor,
|
|
21
|
+
MfaChallengeInfo,
|
|
19
22
|
RoleData as Role
|
|
20
23
|
// @ts-ignore
|
|
21
24
|
} from "@rebasepro/server-core";
|
|
@@ -25,8 +28,6 @@ export type { Role };
|
|
|
25
28
|
|
|
26
29
|
export interface AuthSchemaTables {
|
|
27
30
|
users: PgTable & Record<string, AnyPgColumn>;
|
|
28
|
-
roles: PgTable & Record<string, AnyPgColumn>;
|
|
29
|
-
userRoles: PgTable & Record<string, AnyPgColumn>;
|
|
30
31
|
refreshTokens: PgTable & Record<string, AnyPgColumn>;
|
|
31
32
|
passwordResetTokens: PgTable & Record<string, AnyPgColumn>;
|
|
32
33
|
appConfig: PgTable & Record<string, AnyPgColumn>;
|
|
@@ -58,25 +59,19 @@ function getColumn(table: (PgTable & Record<string, AnyPgColumn>) | undefined, .
|
|
|
58
59
|
export class UserService implements UserRepository {
|
|
59
60
|
private usersTable: PgTable & Record<string, AnyPgColumn>;
|
|
60
61
|
private userIdentitiesTable: PgTable & Record<string, AnyPgColumn>;
|
|
61
|
-
private userRolesTable: PgTable & Record<string, AnyPgColumn>;
|
|
62
|
-
private rolesTable: PgTable & Record<string, AnyPgColumn>;
|
|
63
62
|
|
|
64
63
|
constructor(
|
|
65
64
|
private db: NodePgDatabase,
|
|
66
65
|
tableOrTables?: (PgTable & Record<string, AnyPgColumn>) | Partial<AuthSchemaTables>
|
|
67
66
|
) {
|
|
68
|
-
if (tableOrTables && ((tableOrTables as Partial<AuthSchemaTables>).users
|
|
67
|
+
if (tableOrTables && ((tableOrTables as Partial<AuthSchemaTables>).users)) {
|
|
69
68
|
const tables = tableOrTables as Partial<AuthSchemaTables>;
|
|
70
69
|
this.usersTable = (tables.users || users) as unknown as PgTable & Record<string, AnyPgColumn>;
|
|
71
70
|
this.userIdentitiesTable = (tables.userIdentities || userIdentities) as unknown as PgTable & Record<string, AnyPgColumn>;
|
|
72
|
-
this.userRolesTable = (tables.userRoles || userRoles) as unknown as PgTable & Record<string, AnyPgColumn>;
|
|
73
|
-
this.rolesTable = (tables.roles || roles) as unknown as PgTable & Record<string, AnyPgColumn>;
|
|
74
71
|
} else {
|
|
75
72
|
const table = tableOrTables as (PgTable & Record<string, AnyPgColumn>) | undefined;
|
|
76
73
|
this.usersTable = table || (users as unknown as PgTable & Record<string, AnyPgColumn>);
|
|
77
74
|
this.userIdentitiesTable = userIdentities as unknown as PgTable & Record<string, AnyPgColumn>;
|
|
78
|
-
this.userRolesTable = userRoles as unknown as PgTable & Record<string, AnyPgColumn>;
|
|
79
|
-
this.rolesTable = roles as unknown as PgTable & Record<string, AnyPgColumn>;
|
|
80
75
|
}
|
|
81
76
|
}
|
|
82
77
|
|
|
@@ -97,6 +92,7 @@ export class UserService implements UserRepository {
|
|
|
97
92
|
const emailVerified = (row.email_verified ?? row.emailVerified ?? false) as boolean;
|
|
98
93
|
const emailVerificationToken = (row.email_verification_token ?? row.emailVerificationToken ?? null) as string | null | undefined;
|
|
99
94
|
const emailVerificationSentAt = (row.email_verification_sent_at ?? row.emailVerificationSentAt ?? null) as string | number | Date | null;
|
|
95
|
+
const isAnonymous = (row.is_anonymous ?? row.isAnonymous ?? false) as boolean;
|
|
100
96
|
const createdAt = (row.created_at ?? row.createdAt) as string | number | Date | undefined;
|
|
101
97
|
const updatedAt = (row.updated_at ?? row.updatedAt) as string | number | Date | undefined;
|
|
102
98
|
|
|
@@ -110,6 +106,8 @@ export class UserService implements UserRepository {
|
|
|
110
106
|
"email_verified", "emailVerified",
|
|
111
107
|
"email_verification_token", "emailVerificationToken",
|
|
112
108
|
"email_verification_sent_at", "emailVerificationSentAt",
|
|
109
|
+
"is_anonymous", "isAnonymous",
|
|
110
|
+
"roles",
|
|
113
111
|
"created_at", "createdAt",
|
|
114
112
|
"updated_at", "updatedAt",
|
|
115
113
|
"metadata"
|
|
@@ -131,6 +129,7 @@ export class UserService implements UserRepository {
|
|
|
131
129
|
emailVerified,
|
|
132
130
|
emailVerificationToken,
|
|
133
131
|
emailVerificationSentAt: emailVerificationSentAt ? new Date(emailVerificationSentAt) : null,
|
|
132
|
+
isAnonymous,
|
|
134
133
|
createdAt: createdAt ? new Date(createdAt) : new Date(),
|
|
135
134
|
updatedAt: updatedAt ? new Date(updatedAt) : new Date(),
|
|
136
135
|
metadata
|
|
@@ -150,6 +149,7 @@ export class UserService implements UserRepository {
|
|
|
150
149
|
const emailVerifiedKey = getColumnKey(this.usersTable, "emailVerified", "email_verified") || "emailVerified";
|
|
151
150
|
const emailVerificationTokenKey = getColumnKey(this.usersTable, "emailVerificationToken", "email_verification_token") || "emailVerificationToken";
|
|
152
151
|
const emailVerificationSentAtKey = getColumnKey(this.usersTable, "emailVerificationSentAt", "email_verification_sent_at") || "emailVerificationSentAt";
|
|
152
|
+
const isAnonymousKey = getColumnKey(this.usersTable, "isAnonymous", "is_anonymous") || "isAnonymous";
|
|
153
153
|
const createdAtKey = getColumnKey(this.usersTable, "createdAt", "created_at") || "createdAt";
|
|
154
154
|
const updatedAtKey = getColumnKey(this.usersTable, "updatedAt", "updated_at") || "updatedAt";
|
|
155
155
|
const metadataKey = getColumnKey(this.usersTable, "metadata") || "metadata";
|
|
@@ -162,6 +162,7 @@ export class UserService implements UserRepository {
|
|
|
162
162
|
if ("emailVerified" in data) payload[emailVerifiedKey] = data.emailVerified;
|
|
163
163
|
if ("emailVerificationToken" in data) payload[emailVerificationTokenKey] = data.emailVerificationToken;
|
|
164
164
|
if ("emailVerificationSentAt" in data) payload[emailVerificationSentAtKey] = data.emailVerificationSentAt;
|
|
165
|
+
if ("isAnonymous" in data) payload[isAnonymousKey] = data.isAnonymous;
|
|
165
166
|
if ("createdAt" in data) payload[createdAtKey] = data.createdAt;
|
|
166
167
|
if ("updatedAt" in data) payload[updatedAtKey] = data.updatedAt;
|
|
167
168
|
|
|
@@ -179,6 +180,7 @@ export class UserService implements UserRepository {
|
|
|
179
180
|
tableColKey !== emailVerifiedKey &&
|
|
180
181
|
tableColKey !== emailVerificationTokenKey &&
|
|
181
182
|
tableColKey !== emailVerificationSentAtKey &&
|
|
183
|
+
tableColKey !== isAnonymousKey &&
|
|
182
184
|
tableColKey !== createdAtKey &&
|
|
183
185
|
tableColKey !== updatedAtKey &&
|
|
184
186
|
tableColKey !== metadataKey) {
|
|
@@ -306,11 +308,9 @@ export class UserService implements UserRepository {
|
|
|
306
308
|
const idColumn = idCol ? idCol.name : "id";
|
|
307
309
|
|
|
308
310
|
const usersTableName = this.getQualifiedUsersTableName();
|
|
309
|
-
const rolesSchema = getTableConfig(this.userRolesTable).schema || "public";
|
|
310
|
-
|
|
311
311
|
const conditions = [];
|
|
312
312
|
if (roleId) {
|
|
313
|
-
conditions.push(sql
|
|
313
|
+
conditions.push(sql`${roleId} = ANY(${sql.raw(usersTableName)}.roles)`);
|
|
314
314
|
}
|
|
315
315
|
if (search) {
|
|
316
316
|
const pattern = `%${search}%`;
|
|
@@ -322,7 +322,7 @@ export class UserService implements UserRepository {
|
|
|
322
322
|
// Sorting: users with roles first if no role filter, then by requested order
|
|
323
323
|
const orderByClause = roleId
|
|
324
324
|
? sql`ORDER BY ${sql.raw(usersTableName)}.${sql.raw(orderColumn)} ${direction}`
|
|
325
|
-
: sql`ORDER BY (
|
|
325
|
+
: sql`ORDER BY array_length(${sql.raw(usersTableName)}.roles, 1) DESC NULLS LAST, ${sql.raw(usersTableName)}.${sql.raw(orderColumn)} ${direction}`;
|
|
326
326
|
|
|
327
327
|
const countResult = await this.db.execute(sql`
|
|
328
328
|
SELECT count(*)::int as total FROM ${sql.raw(usersTableName)}
|
|
@@ -419,24 +419,25 @@ export class UserService implements UserRepository {
|
|
|
419
419
|
}
|
|
420
420
|
|
|
421
421
|
/**
|
|
422
|
-
* Get roles for a user from database
|
|
422
|
+
* Get roles for a user from database (inline TEXT[] column)
|
|
423
423
|
*/
|
|
424
424
|
async getUserRoles(userId: string): Promise<Role[]> {
|
|
425
|
-
const
|
|
425
|
+
const usersTableName = this.getQualifiedUsersTableName();
|
|
426
426
|
const result = await this.db.execute(sql`
|
|
427
|
-
SELECT
|
|
428
|
-
FROM ${sql.raw(`"${rolesSchema}"."roles"`)} r
|
|
429
|
-
INNER JOIN ${sql.raw(`"${rolesSchema}"."user_roles"`)} ur ON r.id = ur.role_id
|
|
430
|
-
WHERE ur.user_id = ${userId}
|
|
427
|
+
SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${userId}
|
|
431
428
|
`);
|
|
432
429
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
430
|
+
if (result.rows.length === 0) return [];
|
|
431
|
+
|
|
432
|
+
const row = result.rows[0] as { roles: string[] | null };
|
|
433
|
+
const roleIds = row.roles ?? [];
|
|
434
|
+
|
|
435
|
+
return roleIds.map(id => ({
|
|
436
|
+
id,
|
|
437
|
+
name: id,
|
|
438
|
+
isAdmin: id === "admin",
|
|
439
|
+
defaultPermissions: null,
|
|
440
|
+
collectionPermissions: null
|
|
440
441
|
}));
|
|
441
442
|
}
|
|
442
443
|
|
|
@@ -444,37 +445,39 @@ export class UserService implements UserRepository {
|
|
|
444
445
|
* Get role IDs for a user
|
|
445
446
|
*/
|
|
446
447
|
async getUserRoleIds(userId: string): Promise<string[]> {
|
|
447
|
-
const
|
|
448
|
-
|
|
448
|
+
const usersTableName = this.getQualifiedUsersTableName();
|
|
449
|
+
const result = await this.db.execute(sql`
|
|
450
|
+
SELECT roles FROM ${sql.raw(usersTableName)} WHERE id = ${userId}
|
|
451
|
+
`);
|
|
452
|
+
|
|
453
|
+
if (result.rows.length === 0) return [];
|
|
454
|
+
|
|
455
|
+
const row = result.rows[0] as { roles: string[] | null };
|
|
456
|
+
return row.roles ?? [];
|
|
449
457
|
}
|
|
450
458
|
|
|
451
459
|
/**
|
|
452
|
-
* Set roles for a user
|
|
460
|
+
* Set roles for a user (replaces existing roles)
|
|
453
461
|
*/
|
|
454
462
|
async setUserRoles(userId: string, roleIds: string[]): Promise<void> {
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
await this.db.execute(sql`
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
INSERT INTO ${sql.raw(`"${rolesSchema}"."user_roles"`)} (user_id, role_id)
|
|
463
|
-
VALUES (${userId}, ${roleId})
|
|
464
|
-
ON CONFLICT DO NOTHING
|
|
465
|
-
`);
|
|
466
|
-
}
|
|
463
|
+
const usersTableName = this.getQualifiedUsersTableName();
|
|
464
|
+
const rolesArray = `{${roleIds.join(",")}}`;
|
|
465
|
+
await this.db.execute(sql`
|
|
466
|
+
UPDATE ${sql.raw(usersTableName)}
|
|
467
|
+
SET roles = ${rolesArray}::text[], updated_at = NOW()
|
|
468
|
+
WHERE id = ${userId}
|
|
469
|
+
`);
|
|
467
470
|
}
|
|
468
471
|
|
|
469
472
|
/**
|
|
470
|
-
* Assign a specific role to new user
|
|
473
|
+
* Assign a specific role to new user (appends if not present)
|
|
471
474
|
*/
|
|
472
475
|
async assignDefaultRole(userId: string, roleId: string): Promise<void> {
|
|
473
|
-
const
|
|
476
|
+
const usersTableName = this.getQualifiedUsersTableName();
|
|
474
477
|
await this.db.execute(sql`
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
+
UPDATE ${sql.raw(usersTableName)}
|
|
479
|
+
SET roles = array_append(roles, ${roleId}), updated_at = NOW()
|
|
480
|
+
WHERE id = ${userId} AND NOT (${roleId} = ANY(roles))
|
|
478
481
|
`);
|
|
479
482
|
}
|
|
480
483
|
|
|
@@ -491,119 +494,7 @@ export class UserService implements UserRepository {
|
|
|
491
494
|
}
|
|
492
495
|
}
|
|
493
496
|
|
|
494
|
-
/**
|
|
495
|
-
* PostgreSQL implementation of RoleRepository.
|
|
496
|
-
* Handles all role-related database operations using Drizzle ORM.
|
|
497
|
-
*/
|
|
498
|
-
export class RoleService implements RoleRepository {
|
|
499
|
-
private rolesTable: PgTable & Record<string, AnyPgColumn>;
|
|
500
497
|
|
|
501
|
-
constructor(
|
|
502
|
-
private db: NodePgDatabase,
|
|
503
|
-
tableOrTables?: (PgTable & Record<string, AnyPgColumn>) | Partial<AuthSchemaTables>
|
|
504
|
-
) {
|
|
505
|
-
if (tableOrTables && ((tableOrTables as Partial<AuthSchemaTables>).roles || (tableOrTables as Partial<AuthSchemaTables>).users)) {
|
|
506
|
-
this.rolesTable = ((tableOrTables as Partial<AuthSchemaTables>).roles || roles) as unknown as PgTable & Record<string, AnyPgColumn>;
|
|
507
|
-
} else {
|
|
508
|
-
this.rolesTable = (tableOrTables as unknown as PgTable & Record<string, AnyPgColumn>) || (roles as unknown as PgTable & Record<string, AnyPgColumn>);
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
private getQualifiedRolesTableName(): string {
|
|
513
|
-
const name = getTableName(this.rolesTable);
|
|
514
|
-
const schema = getTableConfig(this.rolesTable).schema || "public";
|
|
515
|
-
return `"${schema}"."${name}"`;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
async getRoleById(id: string): Promise<Role | null> {
|
|
519
|
-
const tableName = this.getQualifiedRolesTableName();
|
|
520
|
-
const result = await this.db.execute(sql`
|
|
521
|
-
SELECT id, name, is_admin, default_permissions, collection_permissions, config
|
|
522
|
-
FROM ${sql.raw(tableName)}
|
|
523
|
-
WHERE id = ${id}
|
|
524
|
-
`);
|
|
525
|
-
|
|
526
|
-
if (result.rows.length === 0) return null;
|
|
527
|
-
|
|
528
|
-
const row = result.rows[0] as { id: string; name: string; is_admin: boolean; default_permissions: Record<string, boolean> | null; collection_permissions: Record<string, Record<string, boolean>> | null; config: Record<string, unknown> | null };
|
|
529
|
-
return {
|
|
530
|
-
id: row.id,
|
|
531
|
-
name: row.name,
|
|
532
|
-
isAdmin: row.is_admin,
|
|
533
|
-
defaultPermissions: row.default_permissions,
|
|
534
|
-
collectionPermissions: row.collection_permissions,
|
|
535
|
-
config: row.config
|
|
536
|
-
};
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
async listRoles(): Promise<Role[]> {
|
|
540
|
-
const tableName = this.getQualifiedRolesTableName();
|
|
541
|
-
const result = await this.db.execute(sql`
|
|
542
|
-
SELECT id, name, is_admin, default_permissions, collection_permissions, config
|
|
543
|
-
FROM ${sql.raw(tableName)}
|
|
544
|
-
ORDER BY name
|
|
545
|
-
`);
|
|
546
|
-
|
|
547
|
-
return (result.rows as Array<{ id: string; name: string; is_admin: boolean; default_permissions: Record<string, boolean> | null; collection_permissions: Record<string, Record<string, boolean>> | null; config: Record<string, unknown> | null }>).map(row => ({
|
|
548
|
-
id: row.id,
|
|
549
|
-
name: row.name,
|
|
550
|
-
isAdmin: row.is_admin,
|
|
551
|
-
defaultPermissions: row.default_permissions,
|
|
552
|
-
collectionPermissions: row.collection_permissions,
|
|
553
|
-
config: row.config
|
|
554
|
-
}));
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
async createRole(data: Omit<Role, "isAdmin" | "collectionPermissions"> & { isAdmin?: boolean; collectionPermissions?: Role["collectionPermissions"] }): Promise<Role> {
|
|
558
|
-
const tableName = this.getQualifiedRolesTableName();
|
|
559
|
-
const result = await this.db.execute(sql`
|
|
560
|
-
INSERT INTO ${sql.raw(tableName)} (id, name, is_admin, default_permissions, collection_permissions, config)
|
|
561
|
-
VALUES (
|
|
562
|
-
${data.id},
|
|
563
|
-
${data.name},
|
|
564
|
-
${data.isAdmin ?? false},
|
|
565
|
-
${data.defaultPermissions ? JSON.stringify(data.defaultPermissions) : null}::jsonb,
|
|
566
|
-
${data.collectionPermissions ? JSON.stringify(data.collectionPermissions) : null}::jsonb,
|
|
567
|
-
${data.config ? JSON.stringify(data.config) : null}::jsonb
|
|
568
|
-
)
|
|
569
|
-
RETURNING id, name, is_admin, default_permissions, collection_permissions, config
|
|
570
|
-
`);
|
|
571
|
-
|
|
572
|
-
const row = result.rows[0] as { id: string; name: string; is_admin: boolean; default_permissions: Record<string, boolean> | null; collection_permissions: Record<string, Record<string, boolean>> | null; config: Record<string, unknown> | null };
|
|
573
|
-
return {
|
|
574
|
-
id: row.id,
|
|
575
|
-
name: row.name,
|
|
576
|
-
isAdmin: row.is_admin,
|
|
577
|
-
defaultPermissions: row.default_permissions,
|
|
578
|
-
collectionPermissions: row.collection_permissions,
|
|
579
|
-
config: row.config
|
|
580
|
-
};
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
async updateRole(id: string, data: Partial<Omit<Role, "id">>): Promise<Role | null> {
|
|
584
|
-
const existing = await this.getRoleById(id);
|
|
585
|
-
if (!existing) return null;
|
|
586
|
-
|
|
587
|
-
const tableName = this.getQualifiedRolesTableName();
|
|
588
|
-
await this.db.execute(sql`
|
|
589
|
-
UPDATE ${sql.raw(tableName)}
|
|
590
|
-
SET
|
|
591
|
-
name = ${data.name ?? existing.name},
|
|
592
|
-
is_admin = ${data.isAdmin ?? existing.isAdmin},
|
|
593
|
-
default_permissions = ${data.defaultPermissions ? JSON.stringify(data.defaultPermissions) : JSON.stringify(existing.defaultPermissions)}::jsonb,
|
|
594
|
-
collection_permissions = ${data.collectionPermissions !== undefined ? (data.collectionPermissions ? JSON.stringify(data.collectionPermissions) : null) : (existing.collectionPermissions ? JSON.stringify(existing.collectionPermissions) : null)}::jsonb,
|
|
595
|
-
config = ${data.config ? JSON.stringify(data.config) : (existing.config ? JSON.stringify(existing.config) : null)}::jsonb
|
|
596
|
-
WHERE id = ${id}
|
|
597
|
-
`);
|
|
598
|
-
|
|
599
|
-
return this.getRoleById(id);
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
async deleteRole(id: string): Promise<void> {
|
|
603
|
-
const tableName = this.getQualifiedRolesTableName();
|
|
604
|
-
await this.db.execute(sql`DELETE FROM ${sql.raw(tableName)} WHERE id = ${id}`);
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
498
|
|
|
608
499
|
export class RefreshTokenService {
|
|
609
500
|
private refreshTokensTable: PgTable & Record<string, AnyPgColumn>;
|
|
@@ -875,7 +766,6 @@ export class PostgresTokenRepository implements TokenRepository {
|
|
|
875
766
|
*/
|
|
876
767
|
export class PostgresAuthRepository implements AuthRepository {
|
|
877
768
|
private userService: UserService;
|
|
878
|
-
private roleService: RoleService;
|
|
879
769
|
private tokenRepository: PostgresTokenRepository;
|
|
880
770
|
|
|
881
771
|
constructor(
|
|
@@ -883,7 +773,6 @@ export class PostgresAuthRepository implements AuthRepository {
|
|
|
883
773
|
tableOrTables?: (PgTable & Record<string, AnyPgColumn>) | Partial<AuthSchemaTables>
|
|
884
774
|
) {
|
|
885
775
|
this.userService = new UserService(db, tableOrTables);
|
|
886
|
-
this.roleService = new RoleService(db, tableOrTables);
|
|
887
776
|
this.tokenRepository = new PostgresTokenRepository(db, tableOrTables);
|
|
888
777
|
}
|
|
889
778
|
|
|
@@ -965,31 +854,48 @@ export class PostgresAuthRepository implements AuthRepository {
|
|
|
965
854
|
return this.userService.getUserWithRoles(userId);
|
|
966
855
|
}
|
|
967
856
|
|
|
968
|
-
// Role operations (
|
|
857
|
+
// Role operations (roles are inline on users, synthesized from string IDs)
|
|
969
858
|
|
|
970
859
|
async getRoleById(id: string): Promise<RoleData | null> {
|
|
971
|
-
return
|
|
860
|
+
return {
|
|
861
|
+
id,
|
|
862
|
+
name: id,
|
|
863
|
+
isAdmin: id === "admin",
|
|
864
|
+
defaultPermissions: null,
|
|
865
|
+
collectionPermissions: null
|
|
866
|
+
};
|
|
972
867
|
}
|
|
973
868
|
|
|
974
869
|
async listRoles(): Promise<RoleData[]> {
|
|
975
|
-
return
|
|
870
|
+
return [
|
|
871
|
+
{ id: "admin", name: "Admin", isAdmin: true, defaultPermissions: null, collectionPermissions: null },
|
|
872
|
+
{ id: "editor", name: "Editor", isAdmin: false, defaultPermissions: null, collectionPermissions: null },
|
|
873
|
+
{ id: "viewer", name: "Viewer", isAdmin: false, defaultPermissions: null, collectionPermissions: null }
|
|
874
|
+
];
|
|
976
875
|
}
|
|
977
876
|
|
|
978
|
-
async createRole(
|
|
979
|
-
return
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
877
|
+
async createRole(_data: CreateRoleData): Promise<RoleData> {
|
|
878
|
+
return {
|
|
879
|
+
id: _data.id,
|
|
880
|
+
name: _data.name,
|
|
881
|
+
isAdmin: _data.isAdmin ?? false,
|
|
882
|
+
defaultPermissions: _data.defaultPermissions ?? null,
|
|
883
|
+
collectionPermissions: _data.collectionPermissions ?? null
|
|
884
|
+
};
|
|
985
885
|
}
|
|
986
886
|
|
|
987
887
|
async updateRole(id: string, data: Partial<Omit<RoleData, "id">>): Promise<RoleData | null> {
|
|
988
|
-
return
|
|
888
|
+
return {
|
|
889
|
+
id,
|
|
890
|
+
name: data.name ?? id,
|
|
891
|
+
isAdmin: data.isAdmin ?? (id === "admin"),
|
|
892
|
+
defaultPermissions: data.defaultPermissions ?? null,
|
|
893
|
+
collectionPermissions: data.collectionPermissions ?? null
|
|
894
|
+
};
|
|
989
895
|
}
|
|
990
896
|
|
|
991
|
-
async deleteRole(
|
|
992
|
-
|
|
897
|
+
async deleteRole(_id: string): Promise<void> {
|
|
898
|
+
// No-op: roles are inline strings on users
|
|
993
899
|
}
|
|
994
900
|
|
|
995
901
|
// Token operations (delegate to PostgresTokenRepository)
|
|
@@ -1037,6 +943,273 @@ export class PostgresAuthRepository implements AuthRepository {
|
|
|
1037
943
|
async deleteExpiredTokens(): Promise<void> {
|
|
1038
944
|
await this.tokenRepository.deleteExpiredTokens();
|
|
1039
945
|
}
|
|
946
|
+
|
|
947
|
+
// MFA operations (delegate to MfaService)
|
|
948
|
+
|
|
949
|
+
private _mfaService: MfaService | null = null;
|
|
950
|
+
private getMfaService(): MfaService {
|
|
951
|
+
if (!this._mfaService) {
|
|
952
|
+
this._mfaService = new MfaService(this.db);
|
|
953
|
+
}
|
|
954
|
+
return this._mfaService;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
async createMfaFactor(userId: string, factorType: "totp", secretEncrypted: string, friendlyName?: string): Promise<MfaFactor> {
|
|
958
|
+
return this.getMfaService().createMfaFactor(userId, factorType, secretEncrypted, friendlyName);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
async getMfaFactors(userId: string): Promise<MfaFactor[]> {
|
|
962
|
+
return this.getMfaService().getMfaFactors(userId);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
async getMfaFactorById(factorId: string): Promise<(MfaFactor & { secretEncrypted: string }) | null> {
|
|
966
|
+
return this.getMfaService().getMfaFactorById(factorId);
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async verifyMfaFactor(factorId: string): Promise<void> {
|
|
970
|
+
return this.getMfaService().verifyMfaFactor(factorId);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
async deleteMfaFactor(factorId: string, userId: string): Promise<void> {
|
|
974
|
+
return this.getMfaService().deleteMfaFactor(factorId, userId);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async createMfaChallenge(factorId: string, ipAddress?: string): Promise<MfaChallengeInfo> {
|
|
978
|
+
return this.getMfaService().createMfaChallenge(factorId, ipAddress);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
async getMfaChallengeById(challengeId: string): Promise<MfaChallengeInfo | null> {
|
|
982
|
+
return this.getMfaService().getMfaChallengeById(challengeId);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async verifyMfaChallenge(challengeId: string): Promise<void> {
|
|
986
|
+
return this.getMfaService().verifyMfaChallenge(challengeId);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
async createRecoveryCodes(userId: string, codeHashes: string[]): Promise<void> {
|
|
990
|
+
return this.getMfaService().createRecoveryCodes(userId, codeHashes);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
async useRecoveryCode(userId: string, codeHash: string): Promise<boolean> {
|
|
994
|
+
return this.getMfaService().useRecoveryCode(userId, codeHash);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
async getUnusedRecoveryCodeCount(userId: string): Promise<number> {
|
|
998
|
+
return this.getMfaService().getUnusedRecoveryCodeCount(userId);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
async deleteAllRecoveryCodes(userId: string): Promise<void> {
|
|
1002
|
+
return this.getMfaService().deleteAllRecoveryCodes(userId);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async hasVerifiedMfaFactors(userId: string): Promise<boolean> {
|
|
1006
|
+
return this.getMfaService().hasVerifiedMfaFactors(userId);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// =============================================================================
|
|
1011
|
+
// MFA SERVICE
|
|
1012
|
+
// =============================================================================
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* PostgreSQL implementation of MfaRepository.
|
|
1016
|
+
* Handles all MFA-related database operations.
|
|
1017
|
+
*/
|
|
1018
|
+
export class MfaService implements MfaRepository {
|
|
1019
|
+
constructor(private db: NodePgDatabase, private schemaName: string = "rebase") {}
|
|
1020
|
+
|
|
1021
|
+
private qualify(tableName: string): string {
|
|
1022
|
+
return `"${this.schemaName}"."${tableName}"`;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
async createMfaFactor(
|
|
1026
|
+
userId: string,
|
|
1027
|
+
factorType: "totp",
|
|
1028
|
+
secretEncrypted: string,
|
|
1029
|
+
friendlyName?: string
|
|
1030
|
+
): Promise<MfaFactor> {
|
|
1031
|
+
const tableName = this.qualify("mfa_factors");
|
|
1032
|
+
const result = await this.db.execute(sql`
|
|
1033
|
+
INSERT INTO ${sql.raw(tableName)} (user_id, factor_type, secret_encrypted, friendly_name)
|
|
1034
|
+
VALUES (${userId}, ${factorType}, ${secretEncrypted}, ${friendlyName ?? null})
|
|
1035
|
+
RETURNING id, user_id, factor_type, friendly_name, verified, created_at, updated_at
|
|
1036
|
+
`);
|
|
1037
|
+
|
|
1038
|
+
const row = result.rows[0] as Record<string, unknown>;
|
|
1039
|
+
return {
|
|
1040
|
+
id: row.id as string,
|
|
1041
|
+
userId: row.user_id as string,
|
|
1042
|
+
factorType: row.factor_type as "totp",
|
|
1043
|
+
friendlyName: (row.friendly_name as string | null) ?? undefined,
|
|
1044
|
+
verified: row.verified as boolean,
|
|
1045
|
+
createdAt: new Date(row.created_at as string),
|
|
1046
|
+
updatedAt: new Date(row.updated_at as string)
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
async getMfaFactors(userId: string): Promise<MfaFactor[]> {
|
|
1051
|
+
const tableName = this.qualify("mfa_factors");
|
|
1052
|
+
const result = await this.db.execute(sql`
|
|
1053
|
+
SELECT id, user_id, factor_type, friendly_name, verified, created_at, updated_at
|
|
1054
|
+
FROM ${sql.raw(tableName)}
|
|
1055
|
+
WHERE user_id = ${userId}
|
|
1056
|
+
ORDER BY created_at
|
|
1057
|
+
`);
|
|
1058
|
+
|
|
1059
|
+
return (result.rows as Array<Record<string, unknown>>).map(row => ({
|
|
1060
|
+
id: row.id as string,
|
|
1061
|
+
userId: row.user_id as string,
|
|
1062
|
+
factorType: row.factor_type as "totp",
|
|
1063
|
+
friendlyName: (row.friendly_name as string | null) ?? undefined,
|
|
1064
|
+
verified: row.verified as boolean,
|
|
1065
|
+
createdAt: new Date(row.created_at as string),
|
|
1066
|
+
updatedAt: new Date(row.updated_at as string)
|
|
1067
|
+
}));
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
async getMfaFactorById(factorId: string): Promise<(MfaFactor & { secretEncrypted: string }) | null> {
|
|
1071
|
+
const tableName = this.qualify("mfa_factors");
|
|
1072
|
+
const result = await this.db.execute(sql`
|
|
1073
|
+
SELECT id, user_id, factor_type, secret_encrypted, friendly_name, verified, created_at, updated_at
|
|
1074
|
+
FROM ${sql.raw(tableName)}
|
|
1075
|
+
WHERE id = ${factorId}
|
|
1076
|
+
`);
|
|
1077
|
+
|
|
1078
|
+
if (result.rows.length === 0) return null;
|
|
1079
|
+
|
|
1080
|
+
const row = result.rows[0] as Record<string, unknown>;
|
|
1081
|
+
return {
|
|
1082
|
+
id: row.id as string,
|
|
1083
|
+
userId: row.user_id as string,
|
|
1084
|
+
factorType: row.factor_type as "totp",
|
|
1085
|
+
secretEncrypted: row.secret_encrypted as string,
|
|
1086
|
+
friendlyName: (row.friendly_name as string | null) ?? undefined,
|
|
1087
|
+
verified: row.verified as boolean,
|
|
1088
|
+
createdAt: new Date(row.created_at as string),
|
|
1089
|
+
updatedAt: new Date(row.updated_at as string)
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
async verifyMfaFactor(factorId: string): Promise<void> {
|
|
1094
|
+
const tableName = this.qualify("mfa_factors");
|
|
1095
|
+
await this.db.execute(sql`
|
|
1096
|
+
UPDATE ${sql.raw(tableName)}
|
|
1097
|
+
SET verified = TRUE, updated_at = NOW()
|
|
1098
|
+
WHERE id = ${factorId}
|
|
1099
|
+
`);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
async deleteMfaFactor(factorId: string, userId: string): Promise<void> {
|
|
1103
|
+
const tableName = this.qualify("mfa_factors");
|
|
1104
|
+
await this.db.execute(sql`
|
|
1105
|
+
DELETE FROM ${sql.raw(tableName)}
|
|
1106
|
+
WHERE id = ${factorId} AND user_id = ${userId}
|
|
1107
|
+
`);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
async createMfaChallenge(factorId: string, ipAddress?: string): Promise<MfaChallengeInfo> {
|
|
1111
|
+
const tableName = this.qualify("mfa_challenges");
|
|
1112
|
+
// Challenges expire in 5 minutes
|
|
1113
|
+
const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
|
|
1114
|
+
const result = await this.db.execute(sql`
|
|
1115
|
+
INSERT INTO ${sql.raw(tableName)} (factor_id, ip_address, expires_at)
|
|
1116
|
+
VALUES (${factorId}, ${ipAddress ?? null}, ${expiresAt})
|
|
1117
|
+
RETURNING id, factor_id, created_at, verified_at, ip_address
|
|
1118
|
+
`);
|
|
1119
|
+
|
|
1120
|
+
const row = result.rows[0] as Record<string, unknown>;
|
|
1121
|
+
return {
|
|
1122
|
+
id: row.id as string,
|
|
1123
|
+
factorId: row.factor_id as string,
|
|
1124
|
+
createdAt: new Date(row.created_at as string),
|
|
1125
|
+
verifiedAt: row.verified_at ? new Date(row.verified_at as string) : undefined,
|
|
1126
|
+
ipAddress: (row.ip_address as string | null) ?? undefined
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
async getMfaChallengeById(challengeId: string): Promise<MfaChallengeInfo | null> {
|
|
1131
|
+
const tableName = this.qualify("mfa_challenges");
|
|
1132
|
+
const result = await this.db.execute(sql`
|
|
1133
|
+
SELECT id, factor_id, created_at, verified_at, ip_address, expires_at
|
|
1134
|
+
FROM ${sql.raw(tableName)}
|
|
1135
|
+
WHERE id = ${challengeId} AND expires_at > NOW() AND verified_at IS NULL
|
|
1136
|
+
`);
|
|
1137
|
+
|
|
1138
|
+
if (result.rows.length === 0) return null;
|
|
1139
|
+
|
|
1140
|
+
const row = result.rows[0] as Record<string, unknown>;
|
|
1141
|
+
return {
|
|
1142
|
+
id: row.id as string,
|
|
1143
|
+
factorId: row.factor_id as string,
|
|
1144
|
+
createdAt: new Date(row.created_at as string),
|
|
1145
|
+
verifiedAt: row.verified_at ? new Date(row.verified_at as string) : undefined,
|
|
1146
|
+
ipAddress: (row.ip_address as string | null) ?? undefined
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
async verifyMfaChallenge(challengeId: string): Promise<void> {
|
|
1151
|
+
const tableName = this.qualify("mfa_challenges");
|
|
1152
|
+
await this.db.execute(sql`
|
|
1153
|
+
UPDATE ${sql.raw(tableName)}
|
|
1154
|
+
SET verified_at = NOW()
|
|
1155
|
+
WHERE id = ${challengeId}
|
|
1156
|
+
`);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
async createRecoveryCodes(userId: string, codeHashes: string[]): Promise<void> {
|
|
1160
|
+
const tableName = this.qualify("recovery_codes");
|
|
1161
|
+
// Delete existing codes first
|
|
1162
|
+
await this.db.execute(sql`
|
|
1163
|
+
DELETE FROM ${sql.raw(tableName)} WHERE user_id = ${userId}
|
|
1164
|
+
`);
|
|
1165
|
+
|
|
1166
|
+
// Insert new codes
|
|
1167
|
+
for (const hash of codeHashes) {
|
|
1168
|
+
await this.db.execute(sql`
|
|
1169
|
+
INSERT INTO ${sql.raw(tableName)} (user_id, code_hash)
|
|
1170
|
+
VALUES (${userId}, ${hash})
|
|
1171
|
+
`);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
async useRecoveryCode(userId: string, codeHash: string): Promise<boolean> {
|
|
1176
|
+
const tableName = this.qualify("recovery_codes");
|
|
1177
|
+
const result = await this.db.execute(sql`
|
|
1178
|
+
UPDATE ${sql.raw(tableName)}
|
|
1179
|
+
SET used_at = NOW()
|
|
1180
|
+
WHERE user_id = ${userId} AND code_hash = ${codeHash} AND used_at IS NULL
|
|
1181
|
+
RETURNING id
|
|
1182
|
+
`);
|
|
1183
|
+
|
|
1184
|
+
return result.rows.length > 0;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
async getUnusedRecoveryCodeCount(userId: string): Promise<number> {
|
|
1188
|
+
const tableName = this.qualify("recovery_codes");
|
|
1189
|
+
const result = await this.db.execute(sql`
|
|
1190
|
+
SELECT COUNT(*)::int as count FROM ${sql.raw(tableName)}
|
|
1191
|
+
WHERE user_id = ${userId} AND used_at IS NULL
|
|
1192
|
+
`);
|
|
1193
|
+
|
|
1194
|
+
return (result.rows[0] as { count: number }).count;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
async deleteAllRecoveryCodes(userId: string): Promise<void> {
|
|
1198
|
+
const tableName = this.qualify("recovery_codes");
|
|
1199
|
+
await this.db.execute(sql`
|
|
1200
|
+
DELETE FROM ${sql.raw(tableName)} WHERE user_id = ${userId}
|
|
1201
|
+
`);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
async hasVerifiedMfaFactors(userId: string): Promise<boolean> {
|
|
1205
|
+
const tableName = this.qualify("mfa_factors");
|
|
1206
|
+
const result = await this.db.execute(sql`
|
|
1207
|
+
SELECT COUNT(*)::int as count FROM ${sql.raw(tableName)}
|
|
1208
|
+
WHERE user_id = ${userId} AND verified = TRUE
|
|
1209
|
+
`);
|
|
1210
|
+
|
|
1211
|
+
return (result.rows[0] as { count: number }).count > 0;
|
|
1212
|
+
}
|
|
1040
1213
|
}
|
|
1041
1214
|
|
|
1042
1215
|
// =============================================================================
|
|
@@ -1045,6 +1218,3 @@ export class PostgresAuthRepository implements AuthRepository {
|
|
|
1045
1218
|
|
|
1046
1219
|
/** PostgreSQL user repository implementation */
|
|
1047
1220
|
export type PostgresUserRepository = UserService;
|
|
1048
|
-
|
|
1049
|
-
/** PostgreSQL role repository implementation */
|
|
1050
|
-
export type PostgresRoleRepository = RoleService;
|