@scryan7371/sdr-security 0.1.1 → 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/README.md +216 -13
- package/dist/api/contracts.d.ts +12 -2
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/migrations/1739500000000-create-security-identity.d.ts +1 -1
- package/dist/api/migrations/1739500000000-create-security-identity.js +9 -35
- package/dist/api/migrations/1739510000000-create-security-roles.d.ts +1 -1
- package/dist/api/migrations/1739510000000-create-security-roles.js +1 -67
- package/dist/api/migrations/1739515000000-create-security-user-roles.d.ts +9 -0
- package/dist/api/migrations/1739515000000-create-security-user-roles.js +39 -0
- package/dist/api/migrations/1739520000000-create-password-reset-tokens.d.ts +9 -0
- package/dist/api/migrations/1739520000000-create-password-reset-tokens.js +42 -0
- package/dist/api/migrations/1739530000000-create-security-user.d.ts +9 -0
- package/dist/api/migrations/1739530000000-create-security-user.js +41 -0
- package/dist/api/migrations/index.d.ts +4 -2
- package/dist/api/migrations/index.js +10 -4
- package/dist/api/migrations/migrations.test.d.ts +1 -0
- package/dist/api/migrations/migrations.test.js +88 -0
- package/dist/api/notification-workflows.d.ts +31 -0
- package/dist/api/notification-workflows.js +22 -0
- package/dist/api/notification-workflows.test.d.ts +1 -0
- package/dist/api/notification-workflows.test.js +63 -0
- package/dist/api/validation.test.d.ts +1 -0
- package/dist/api/validation.test.js +20 -0
- package/dist/app/client.d.ts +17 -4
- package/dist/app/client.js +38 -11
- package/dist/app/client.test.d.ts +1 -0
- package/dist/app/client.test.js +130 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +10 -0
- package/dist/integration/database.integration.test.d.ts +1 -0
- package/dist/integration/database.integration.test.js +158 -0
- package/dist/nest/contracts.d.ts +21 -0
- package/dist/nest/contracts.js +2 -0
- package/dist/nest/dto/auth.dto.d.ts +25 -0
- package/dist/nest/dto/auth.dto.js +89 -0
- package/dist/nest/dto/workflows.dto.d.ts +16 -0
- package/dist/nest/dto/workflows.dto.js +58 -0
- package/dist/nest/entities/app-user.entity.d.ts +4 -0
- package/dist/nest/entities/app-user.entity.js +29 -0
- package/dist/nest/entities/password-reset-token.entity.d.ts +8 -0
- package/dist/nest/entities/password-reset-token.entity.js +49 -0
- package/dist/nest/entities/refresh-token.entity.d.ts +8 -0
- package/dist/nest/entities/refresh-token.entity.js +49 -0
- package/dist/nest/entities/security-role.entity.d.ts +6 -0
- package/dist/nest/entities/security-role.entity.js +39 -0
- package/dist/nest/entities/security-user-role.entity.d.ts +5 -0
- package/dist/nest/entities/security-user-role.entity.js +34 -0
- package/dist/nest/entities/security-user.entity.d.ts +9 -0
- package/dist/nest/entities/security-user.entity.js +54 -0
- package/dist/nest/index.d.ts +19 -0
- package/dist/nest/index.js +35 -0
- package/dist/nest/index.test.d.ts +1 -0
- package/dist/nest/index.test.js +14 -0
- package/dist/nest/security-admin.guard.d.ts +4 -0
- package/dist/nest/security-admin.guard.js +25 -0
- package/dist/nest/security-admin.guard.test.d.ts +1 -0
- package/dist/nest/security-admin.guard.test.js +24 -0
- package/dist/nest/security-auth.constants.d.ts +1 -0
- package/dist/nest/security-auth.constants.js +4 -0
- package/dist/nest/security-auth.controller.d.ts +51 -0
- package/dist/nest/security-auth.controller.js +177 -0
- package/dist/nest/security-auth.controller.test.d.ts +1 -0
- package/dist/nest/security-auth.controller.test.js +87 -0
- package/dist/nest/security-auth.module.d.ts +9 -0
- package/dist/nest/security-auth.module.js +70 -0
- package/dist/nest/security-auth.options.d.ts +8 -0
- package/dist/nest/security-auth.options.js +2 -0
- package/dist/nest/security-auth.service.d.ts +60 -0
- package/dist/nest/security-auth.service.js +299 -0
- package/dist/nest/security-auth.service.test.d.ts +1 -0
- package/dist/nest/security-auth.service.test.js +249 -0
- package/dist/nest/security-jwt.guard.d.ts +7 -0
- package/dist/nest/security-jwt.guard.js +46 -0
- package/dist/nest/security-jwt.guard.test.d.ts +1 -0
- package/dist/nest/security-jwt.guard.test.js +51 -0
- package/dist/nest/security-modules.test.d.ts +1 -0
- package/dist/nest/security-modules.test.js +61 -0
- package/dist/nest/security-workflows.controller.d.ts +72 -0
- package/dist/nest/security-workflows.controller.js +187 -0
- package/dist/nest/security-workflows.controller.test.d.ts +1 -0
- package/dist/nest/security-workflows.controller.test.js +87 -0
- package/dist/nest/security-workflows.module.d.ts +9 -0
- package/dist/nest/security-workflows.module.js +61 -0
- package/dist/nest/security-workflows.service.d.ts +69 -0
- package/dist/nest/security-workflows.service.js +203 -0
- package/dist/nest/security-workflows.service.test.d.ts +1 -0
- package/dist/nest/security-workflows.service.test.js +178 -0
- package/dist/nest/swagger.d.ts +2 -0
- package/dist/nest/swagger.js +16 -0
- package/dist/nest/swagger.test.d.ts +1 -0
- package/dist/nest/swagger.test.js +21 -0
- package/dist/nest/tokens.d.ts +1 -0
- package/dist/nest/tokens.js +4 -0
- package/package.json +45 -4
- package/src/api/contracts.ts +11 -2
- package/src/api/index.ts +1 -0
- package/src/api/migrations/1739500000000-create-security-identity.ts +11 -50
- package/src/api/migrations/1739510000000-create-security-roles.ts +2 -89
- package/src/api/migrations/1739515000000-create-security-user-roles.ts +49 -0
- package/src/api/migrations/1739520000000-create-password-reset-tokens.ts +57 -0
- package/src/api/migrations/1739530000000-create-security-user.ts +51 -0
- package/src/api/migrations/index.ts +9 -3
- package/src/api/migrations/migrations.test.ts +145 -0
- package/src/api/notification-workflows.test.ts +78 -0
- package/src/api/notification-workflows.ts +38 -0
- package/src/api/validation.test.ts +21 -0
- package/src/app/client.test.ts +157 -0
- package/src/app/client.ts +74 -18
- package/src/index.test.ts +9 -0
- package/src/integration/database.integration.test.ts +205 -0
- package/src/nest/contracts.ts +20 -0
- package/src/nest/dto/auth.dto.ts +48 -0
- package/src/nest/dto/workflows.dto.ts +29 -0
- package/src/nest/entities/app-user.entity.ts +10 -0
- package/src/nest/entities/password-reset-token.entity.ts +27 -0
- package/src/nest/entities/refresh-token.entity.ts +22 -0
- package/src/nest/entities/security-role.entity.ts +16 -0
- package/src/nest/entities/security-user-role.entity.ts +13 -0
- package/src/nest/entities/security-user.entity.ts +25 -0
- package/src/nest/index.test.ts +20 -0
- package/src/nest/index.ts +19 -0
- package/src/nest/security-admin.guard.test.ts +31 -0
- package/src/nest/security-admin.guard.ts +21 -0
- package/src/nest/security-auth.constants.ts +1 -0
- package/src/nest/security-auth.controller.test.ts +128 -0
- package/src/nest/security-auth.controller.ts +148 -0
- package/src/nest/security-auth.module.ts +65 -0
- package/src/nest/security-auth.options.ts +8 -0
- package/src/nest/security-auth.service.test.ts +368 -0
- package/src/nest/security-auth.service.ts +356 -0
- package/src/nest/security-jwt.guard.test.ts +65 -0
- package/src/nest/security-jwt.guard.ts +47 -0
- package/src/nest/security-modules.test.ts +79 -0
- package/src/nest/security-workflows.controller.test.ts +119 -0
- package/src/nest/security-workflows.controller.ts +149 -0
- package/src/nest/security-workflows.module.ts +56 -0
- package/src/nest/security-workflows.service.test.ts +238 -0
- package/src/nest/security-workflows.service.ts +220 -0
- package/src/nest/swagger.test.ts +27 -0
- package/src/nest/swagger.ts +18 -0
- package/src/nest/tokens.ts +1 -0
- package/dist/api/migrations/1739490000000-add-google-subject-to-user.d.ts +0 -5
- package/dist/api/migrations/1739490000000-add-google-subject-to-user.js +0 -14
- package/src/api/migrations/1739490000000-add-google-subject-to-user.ts +0 -12
|
@@ -2,20 +2,11 @@ export class CreateSecurityRoles1739510000000 {
|
|
|
2
2
|
name = "CreateSecurityRoles1739510000000";
|
|
3
3
|
|
|
4
4
|
async up(queryRunner: {
|
|
5
|
-
query: (sql: string
|
|
5
|
+
query: (sql: string) => Promise<unknown>;
|
|
6
6
|
}): Promise<void> {
|
|
7
|
-
const userTable = getSafeIdentifier(process.env.USER_TABLE, "app_user");
|
|
8
|
-
const userSchema = getSafeIdentifier(
|
|
9
|
-
process.env.USER_TABLE_SCHEMA,
|
|
10
|
-
"public",
|
|
11
|
-
);
|
|
12
|
-
const userTableRef = `"${userSchema}"."${userTable}"`;
|
|
13
|
-
|
|
14
|
-
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
|
|
15
|
-
|
|
16
7
|
await queryRunner.query(`
|
|
17
8
|
CREATE TABLE IF NOT EXISTS "security_role" (
|
|
18
|
-
"id" uuid PRIMARY KEY NOT NULL DEFAULT
|
|
9
|
+
"id" uuid PRIMARY KEY NOT NULL DEFAULT uuidv7(),
|
|
19
10
|
"role_key" varchar NOT NULL,
|
|
20
11
|
"description" text,
|
|
21
12
|
"is_system" boolean NOT NULL DEFAULT false,
|
|
@@ -28,95 +19,17 @@ export class CreateSecurityRoles1739510000000 {
|
|
|
28
19
|
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_security_role_key" ON "security_role" ("role_key")`,
|
|
29
20
|
);
|
|
30
21
|
|
|
31
|
-
await queryRunner.query(`
|
|
32
|
-
CREATE TABLE IF NOT EXISTS "security_user_role" (
|
|
33
|
-
"id" uuid PRIMARY KEY NOT NULL DEFAULT uuid_generate_v4(),
|
|
34
|
-
"user_id" varchar NOT NULL,
|
|
35
|
-
"role_id" uuid NOT NULL,
|
|
36
|
-
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
37
|
-
CONSTRAINT "FK_security_user_role_user_id" FOREIGN KEY ("user_id") REFERENCES ${userTableRef} ("id") ON DELETE CASCADE,
|
|
38
|
-
CONSTRAINT "FK_security_user_role_role_id" FOREIGN KEY ("role_id") REFERENCES "security_role" ("id") ON DELETE CASCADE
|
|
39
|
-
)
|
|
40
|
-
`);
|
|
41
|
-
|
|
42
|
-
await queryRunner.query(
|
|
43
|
-
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_security_user_role_user_role" ON "security_user_role" ("user_id", "role_id")`,
|
|
44
|
-
);
|
|
45
|
-
|
|
46
22
|
await queryRunner.query(`
|
|
47
23
|
INSERT INTO "security_role" ("role_key", "description", "is_system", "created_at", "updated_at")
|
|
48
24
|
VALUES ('ADMIN', 'Administrative access', true, now(), now())
|
|
49
25
|
ON CONFLICT ("role_key") DO NOTHING
|
|
50
26
|
`);
|
|
51
|
-
|
|
52
|
-
const hasRoleColumn = (await queryRunner.query(
|
|
53
|
-
`
|
|
54
|
-
SELECT 1
|
|
55
|
-
FROM information_schema.columns
|
|
56
|
-
WHERE table_schema = $1
|
|
57
|
-
AND table_name = $2
|
|
58
|
-
AND column_name = 'role'
|
|
59
|
-
LIMIT 1
|
|
60
|
-
`,
|
|
61
|
-
[userSchema, userTable],
|
|
62
|
-
)) as Array<{ "?column?": number }>;
|
|
63
|
-
|
|
64
|
-
if (hasRoleColumn.length > 0) {
|
|
65
|
-
await queryRunner.query(`
|
|
66
|
-
INSERT INTO "security_role" ("role_key", "description", "is_system", "created_at", "updated_at")
|
|
67
|
-
SELECT DISTINCT
|
|
68
|
-
CASE
|
|
69
|
-
WHEN UPPER(TRIM("role")) = 'ADMINISTRATOR' THEN 'ADMIN'
|
|
70
|
-
ELSE UPPER(TRIM("role"))
|
|
71
|
-
END AS "role_key",
|
|
72
|
-
NULL,
|
|
73
|
-
false,
|
|
74
|
-
now(),
|
|
75
|
-
now()
|
|
76
|
-
FROM ${userTableRef}
|
|
77
|
-
WHERE "role" IS NOT NULL
|
|
78
|
-
AND LENGTH(TRIM("role")) > 0
|
|
79
|
-
ON CONFLICT ("role_key") DO NOTHING
|
|
80
|
-
`);
|
|
81
|
-
|
|
82
|
-
await queryRunner.query(`
|
|
83
|
-
INSERT INTO "security_user_role" ("user_id", "role_id", "created_at")
|
|
84
|
-
SELECT
|
|
85
|
-
u."id" AS "user_id",
|
|
86
|
-
r."id" AS "role_id",
|
|
87
|
-
now()
|
|
88
|
-
FROM ${userTableRef} u
|
|
89
|
-
INNER JOIN "security_role" r ON r."role_key" = CASE
|
|
90
|
-
WHEN UPPER(TRIM(u."role")) = 'ADMINISTRATOR' THEN 'ADMIN'
|
|
91
|
-
ELSE UPPER(TRIM(u."role"))
|
|
92
|
-
END
|
|
93
|
-
WHERE u."role" IS NOT NULL
|
|
94
|
-
AND LENGTH(TRIM(u."role")) > 0
|
|
95
|
-
ON CONFLICT ("user_id", "role_id") DO NOTHING
|
|
96
|
-
`);
|
|
97
|
-
|
|
98
|
-
await queryRunner.query(
|
|
99
|
-
`ALTER TABLE ${userTableRef} DROP COLUMN IF EXISTS "role"`,
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
27
|
}
|
|
103
28
|
|
|
104
29
|
async down(queryRunner: {
|
|
105
30
|
query: (sql: string) => Promise<unknown>;
|
|
106
31
|
}): Promise<void> {
|
|
107
|
-
await queryRunner.query(
|
|
108
|
-
`DROP INDEX IF EXISTS "IDX_security_user_role_user_role"`,
|
|
109
|
-
);
|
|
110
|
-
await queryRunner.query(`DROP TABLE IF EXISTS "security_user_role"`);
|
|
111
32
|
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_security_role_key"`);
|
|
112
33
|
await queryRunner.query(`DROP TABLE IF EXISTS "security_role"`);
|
|
113
34
|
}
|
|
114
35
|
}
|
|
115
|
-
|
|
116
|
-
const getSafeIdentifier = (value: string | undefined, fallback: string) => {
|
|
117
|
-
const resolved = value?.trim() || fallback;
|
|
118
|
-
if (!resolved || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(resolved)) {
|
|
119
|
-
throw new Error(`Invalid SQL identifier: ${resolved}`);
|
|
120
|
-
}
|
|
121
|
-
return resolved;
|
|
122
|
-
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export class CreateSecurityUserRoles1739515000000 {
|
|
2
|
+
name = "CreateSecurityUserRoles1739515000000";
|
|
3
|
+
|
|
4
|
+
async up(queryRunner: {
|
|
5
|
+
query: (sql: string) => Promise<unknown>;
|
|
6
|
+
}): Promise<void> {
|
|
7
|
+
const userTableRef = getUserTableReference();
|
|
8
|
+
|
|
9
|
+
await queryRunner.query(`
|
|
10
|
+
CREATE TABLE IF NOT EXISTS "security_user_role" (
|
|
11
|
+
"id" uuid PRIMARY KEY NOT NULL DEFAULT uuidv7(),
|
|
12
|
+
"user_id" varchar NOT NULL,
|
|
13
|
+
"role_id" uuid NOT NULL,
|
|
14
|
+
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
15
|
+
CONSTRAINT "FK_security_user_role_user_id" FOREIGN KEY ("user_id") REFERENCES ${userTableRef} ("id") ON DELETE CASCADE,
|
|
16
|
+
CONSTRAINT "FK_security_user_role_role_id" FOREIGN KEY ("role_id") REFERENCES "security_role" ("id") ON DELETE CASCADE
|
|
17
|
+
)
|
|
18
|
+
`);
|
|
19
|
+
|
|
20
|
+
await queryRunner.query(
|
|
21
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_security_user_role_user_role" ON "security_user_role" ("user_id", "role_id")`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async down(queryRunner: {
|
|
26
|
+
query: (sql: string) => Promise<unknown>;
|
|
27
|
+
}): Promise<void> {
|
|
28
|
+
await queryRunner.query(
|
|
29
|
+
`DROP INDEX IF EXISTS "IDX_security_user_role_user_role"`,
|
|
30
|
+
);
|
|
31
|
+
await queryRunner.query(`DROP TABLE IF EXISTS "security_user_role"`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const getUserTableReference = () => {
|
|
36
|
+
const table = getSafeIdentifier(process.env.USER_TABLE, "app_user");
|
|
37
|
+
const schema = process.env.USER_TABLE_SCHEMA
|
|
38
|
+
? getSafeIdentifier(process.env.USER_TABLE_SCHEMA, "public")
|
|
39
|
+
: "public";
|
|
40
|
+
return `"${schema}"."${table}"`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const getSafeIdentifier = (value: string | undefined, fallback: string) => {
|
|
44
|
+
const resolved = value?.trim() || fallback;
|
|
45
|
+
if (!resolved || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(resolved)) {
|
|
46
|
+
throw new Error(`Invalid SQL identifier: ${resolved}`);
|
|
47
|
+
}
|
|
48
|
+
return resolved;
|
|
49
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export class CreatePasswordResetTokens1739520000000 {
|
|
2
|
+
name = "CreatePasswordResetTokens1739520000000";
|
|
3
|
+
|
|
4
|
+
async up(queryRunner: {
|
|
5
|
+
query: (sql: string) => Promise<unknown>;
|
|
6
|
+
}): Promise<void> {
|
|
7
|
+
const userTableRef = getUserTableReference();
|
|
8
|
+
|
|
9
|
+
await queryRunner.query(`
|
|
10
|
+
CREATE TABLE IF NOT EXISTS "security_password_reset_token" (
|
|
11
|
+
"id" uuid PRIMARY KEY NOT NULL DEFAULT uuidv7(),
|
|
12
|
+
"user_id" varchar NOT NULL,
|
|
13
|
+
"token" varchar NOT NULL,
|
|
14
|
+
"expires_at" timestamptz NOT NULL,
|
|
15
|
+
"used_at" timestamptz,
|
|
16
|
+
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
17
|
+
CONSTRAINT "FK_security_password_reset_token_user_id" FOREIGN KEY ("user_id") REFERENCES ${userTableRef} ("id") ON DELETE CASCADE
|
|
18
|
+
)
|
|
19
|
+
`);
|
|
20
|
+
await queryRunner.query(
|
|
21
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_security_password_reset_token_token" ON "security_password_reset_token" ("token")`,
|
|
22
|
+
);
|
|
23
|
+
await queryRunner.query(
|
|
24
|
+
`CREATE INDEX IF NOT EXISTS "IDX_security_password_reset_token_user_id" ON "security_password_reset_token" ("user_id")`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async down(queryRunner: {
|
|
29
|
+
query: (sql: string) => Promise<unknown>;
|
|
30
|
+
}): Promise<void> {
|
|
31
|
+
await queryRunner.query(
|
|
32
|
+
`DROP INDEX IF EXISTS "IDX_security_password_reset_token_user_id"`,
|
|
33
|
+
);
|
|
34
|
+
await queryRunner.query(
|
|
35
|
+
`DROP INDEX IF EXISTS "IDX_security_password_reset_token_token"`,
|
|
36
|
+
);
|
|
37
|
+
await queryRunner.query(
|
|
38
|
+
`DROP TABLE IF EXISTS "security_password_reset_token"`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const getUserTableReference = () => {
|
|
44
|
+
const table = getSafeIdentifier(process.env.USER_TABLE, "app_user");
|
|
45
|
+
const schema = process.env.USER_TABLE_SCHEMA
|
|
46
|
+
? getSafeIdentifier(process.env.USER_TABLE_SCHEMA, "public")
|
|
47
|
+
: "public";
|
|
48
|
+
return `"${schema}"."${table}"`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const getSafeIdentifier = (value: string | undefined, fallback: string) => {
|
|
52
|
+
const resolved = value?.trim() || fallback;
|
|
53
|
+
if (!resolved || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(resolved)) {
|
|
54
|
+
throw new Error(`Invalid SQL identifier: ${resolved}`);
|
|
55
|
+
}
|
|
56
|
+
return resolved;
|
|
57
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export class CreateSecurityUser1739530000000 {
|
|
2
|
+
name = "CreateSecurityUser1739530000000";
|
|
3
|
+
|
|
4
|
+
async up(queryRunner: {
|
|
5
|
+
query: (sql: string) => Promise<unknown>;
|
|
6
|
+
}): Promise<void> {
|
|
7
|
+
const userTableRef = getUserTableReference();
|
|
8
|
+
|
|
9
|
+
await queryRunner.query(`
|
|
10
|
+
CREATE TABLE IF NOT EXISTS "security_user" (
|
|
11
|
+
"user_id" varchar PRIMARY KEY NOT NULL,
|
|
12
|
+
"password_hash" varchar NOT NULL,
|
|
13
|
+
"email_verified_at" timestamptz,
|
|
14
|
+
"email_verification_token" varchar,
|
|
15
|
+
"admin_approved_at" timestamptz,
|
|
16
|
+
"is_active" boolean NOT NULL DEFAULT true,
|
|
17
|
+
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
18
|
+
CONSTRAINT "FK_security_user_user_id" FOREIGN KEY ("user_id") REFERENCES ${userTableRef} ("id") ON DELETE CASCADE
|
|
19
|
+
)
|
|
20
|
+
`);
|
|
21
|
+
|
|
22
|
+
await queryRunner.query(
|
|
23
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_security_user_email_verification_token" ON "security_user" ("email_verification_token") WHERE "email_verification_token" IS NOT NULL`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async down(queryRunner: {
|
|
28
|
+
query: (sql: string) => Promise<unknown>;
|
|
29
|
+
}): Promise<void> {
|
|
30
|
+
await queryRunner.query(
|
|
31
|
+
`DROP INDEX IF EXISTS "IDX_security_user_email_verification_token"`,
|
|
32
|
+
);
|
|
33
|
+
await queryRunner.query(`DROP TABLE IF EXISTS "security_user"`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const getSafeIdentifier = (value: string | undefined, fallback: string) => {
|
|
38
|
+
const resolved = value?.trim() || fallback;
|
|
39
|
+
if (!resolved || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(resolved)) {
|
|
40
|
+
throw new Error(`Invalid SQL identifier: ${resolved}`);
|
|
41
|
+
}
|
|
42
|
+
return resolved;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const getUserTableReference = () => {
|
|
46
|
+
const table = getSafeIdentifier(process.env.USER_TABLE, "app_user");
|
|
47
|
+
const schema = process.env.USER_TABLE_SCHEMA
|
|
48
|
+
? getSafeIdentifier(process.env.USER_TABLE_SCHEMA, "public")
|
|
49
|
+
: "public";
|
|
50
|
+
return `"${schema}"."${table}"`;
|
|
51
|
+
};
|
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
import { AddRefreshTokens1700000000001 } from "./1700000000001-add-refresh-tokens";
|
|
2
|
-
import { AddGoogleSubjectToUser1739490000000 } from "./1739490000000-add-google-subject-to-user";
|
|
3
2
|
import { CreateSecurityIdentity1739500000000 } from "./1739500000000-create-security-identity";
|
|
4
3
|
import { CreateSecurityRoles1739510000000 } from "./1739510000000-create-security-roles";
|
|
4
|
+
import { CreateSecurityUserRoles1739515000000 } from "./1739515000000-create-security-user-roles";
|
|
5
|
+
import { CreatePasswordResetTokens1739520000000 } from "./1739520000000-create-password-reset-tokens";
|
|
6
|
+
import { CreateSecurityUser1739530000000 } from "./1739530000000-create-security-user";
|
|
5
7
|
|
|
6
8
|
export const securityMigrations = [
|
|
7
9
|
AddRefreshTokens1700000000001,
|
|
8
|
-
AddGoogleSubjectToUser1739490000000,
|
|
9
10
|
CreateSecurityIdentity1739500000000,
|
|
10
11
|
CreateSecurityRoles1739510000000,
|
|
12
|
+
CreateSecurityUserRoles1739515000000,
|
|
13
|
+
CreatePasswordResetTokens1739520000000,
|
|
14
|
+
CreateSecurityUser1739530000000,
|
|
11
15
|
];
|
|
12
16
|
|
|
13
17
|
export {
|
|
14
18
|
AddRefreshTokens1700000000001,
|
|
15
|
-
AddGoogleSubjectToUser1739490000000,
|
|
16
19
|
CreateSecurityIdentity1739500000000,
|
|
17
20
|
CreateSecurityRoles1739510000000,
|
|
21
|
+
CreateSecurityUserRoles1739515000000,
|
|
22
|
+
CreatePasswordResetTokens1739520000000,
|
|
23
|
+
CreateSecurityUser1739530000000,
|
|
18
24
|
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { AddRefreshTokens1700000000001 } from "./1700000000001-add-refresh-tokens";
|
|
3
|
+
import { CreateSecurityIdentity1739500000000 } from "./1739500000000-create-security-identity";
|
|
4
|
+
import { CreateSecurityRoles1739510000000 } from "./1739510000000-create-security-roles";
|
|
5
|
+
import { CreateSecurityUserRoles1739515000000 } from "./1739515000000-create-security-user-roles";
|
|
6
|
+
import { CreatePasswordResetTokens1739520000000 } from "./1739520000000-create-password-reset-tokens";
|
|
7
|
+
import { CreateSecurityUser1739530000000 } from "./1739530000000-create-security-user";
|
|
8
|
+
import { securityMigrations } from "./index";
|
|
9
|
+
|
|
10
|
+
const originalEnv = { ...process.env };
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
process.env = { ...originalEnv };
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("security migrations", () => {
|
|
18
|
+
it("exports migration list", () => {
|
|
19
|
+
expect(securityMigrations.length).toBe(6);
|
|
20
|
+
expect(securityMigrations).toEqual([
|
|
21
|
+
AddRefreshTokens1700000000001,
|
|
22
|
+
CreateSecurityIdentity1739500000000,
|
|
23
|
+
CreateSecurityRoles1739510000000,
|
|
24
|
+
CreateSecurityUserRoles1739515000000,
|
|
25
|
+
CreatePasswordResetTokens1739520000000,
|
|
26
|
+
CreateSecurityUser1739530000000,
|
|
27
|
+
]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("runs refresh token migration up/down", async () => {
|
|
31
|
+
const query = vi.fn().mockResolvedValue(undefined);
|
|
32
|
+
const migration = new AddRefreshTokens1700000000001();
|
|
33
|
+
|
|
34
|
+
await migration.up({ query });
|
|
35
|
+
await migration.down({ query });
|
|
36
|
+
|
|
37
|
+
expect(query).toHaveBeenCalledWith(
|
|
38
|
+
expect.stringContaining('CREATE TABLE "refresh_token"'),
|
|
39
|
+
);
|
|
40
|
+
expect(query).toHaveBeenCalledWith(
|
|
41
|
+
expect.stringContaining('DROP TABLE "refresh_token"'),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("runs security identity migration up/down", async () => {
|
|
46
|
+
const query = vi.fn().mockResolvedValue(undefined);
|
|
47
|
+
const migration = new CreateSecurityIdentity1739500000000();
|
|
48
|
+
|
|
49
|
+
await migration.up({ query });
|
|
50
|
+
await migration.down({ query });
|
|
51
|
+
|
|
52
|
+
expect(query).toHaveBeenCalledWith(
|
|
53
|
+
expect.stringContaining('CREATE TABLE IF NOT EXISTS "security_identity"'),
|
|
54
|
+
);
|
|
55
|
+
expect(query).toHaveBeenCalledWith(
|
|
56
|
+
expect.stringContaining('DROP TABLE IF EXISTS "security_identity"'),
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("runs security role migration up/down", async () => {
|
|
61
|
+
const query = vi.fn().mockResolvedValue(undefined);
|
|
62
|
+
const migration = new CreateSecurityRoles1739510000000();
|
|
63
|
+
|
|
64
|
+
await migration.up({ query });
|
|
65
|
+
await migration.down({ query });
|
|
66
|
+
|
|
67
|
+
expect(query).toHaveBeenCalledWith(
|
|
68
|
+
expect.stringContaining('CREATE TABLE IF NOT EXISTS "security_role"'),
|
|
69
|
+
);
|
|
70
|
+
expect(query).toHaveBeenCalledWith(
|
|
71
|
+
expect.stringContaining('DROP TABLE IF EXISTS "security_role"'),
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("runs security user role migration up/down", async () => {
|
|
76
|
+
const query = vi.fn().mockResolvedValue(undefined);
|
|
77
|
+
const migration = new CreateSecurityUserRoles1739515000000();
|
|
78
|
+
|
|
79
|
+
await migration.up({ query });
|
|
80
|
+
await migration.down({ query });
|
|
81
|
+
|
|
82
|
+
expect(query).toHaveBeenCalledWith(
|
|
83
|
+
expect.stringContaining(
|
|
84
|
+
'CREATE TABLE IF NOT EXISTS "security_user_role"',
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
expect(query).toHaveBeenCalledWith(
|
|
88
|
+
expect.stringContaining('DROP TABLE IF EXISTS "security_user_role"'),
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("runs password reset token migration up/down", async () => {
|
|
93
|
+
const query = vi.fn().mockResolvedValue(undefined);
|
|
94
|
+
const migration = new CreatePasswordResetTokens1739520000000();
|
|
95
|
+
|
|
96
|
+
await migration.up({ query });
|
|
97
|
+
await migration.down({ query });
|
|
98
|
+
|
|
99
|
+
expect(query).toHaveBeenCalledWith(
|
|
100
|
+
expect.stringContaining(
|
|
101
|
+
'CREATE TABLE IF NOT EXISTS "security_password_reset_token"',
|
|
102
|
+
),
|
|
103
|
+
);
|
|
104
|
+
expect(query).toHaveBeenCalledWith(
|
|
105
|
+
expect.stringContaining(
|
|
106
|
+
'DROP TABLE IF EXISTS "security_password_reset_token"',
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("runs security user migration up/down", async () => {
|
|
112
|
+
const query = vi.fn().mockResolvedValue(undefined);
|
|
113
|
+
const migration = new CreateSecurityUser1739530000000();
|
|
114
|
+
|
|
115
|
+
await migration.up({ query });
|
|
116
|
+
await migration.down({ query });
|
|
117
|
+
|
|
118
|
+
expect(query).toHaveBeenCalledWith(
|
|
119
|
+
expect.stringContaining('CREATE TABLE IF NOT EXISTS "security_user"'),
|
|
120
|
+
);
|
|
121
|
+
expect(query).toHaveBeenCalledWith(
|
|
122
|
+
expect.stringContaining('DROP TABLE IF EXISTS "security_user"'),
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("uses user schema/table env safely", async () => {
|
|
127
|
+
process.env.USER_TABLE = "users";
|
|
128
|
+
process.env.USER_TABLE_SCHEMA = "security";
|
|
129
|
+
const query = vi.fn().mockResolvedValue(undefined);
|
|
130
|
+
|
|
131
|
+
await new CreateSecurityUser1739530000000().up({ query });
|
|
132
|
+
expect(query).toHaveBeenCalledWith(
|
|
133
|
+
expect.stringContaining('REFERENCES "security"."users" ("id")'),
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("throws for invalid identifiers", async () => {
|
|
138
|
+
process.env.USER_TABLE = "bad-name!";
|
|
139
|
+
const query = vi.fn().mockResolvedValue(undefined);
|
|
140
|
+
|
|
141
|
+
await expect(
|
|
142
|
+
new CreateSecurityUserRoles1739515000000().up({ query }),
|
|
143
|
+
).rejects.toThrow("Invalid SQL identifier");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
notifyAdminsOnEmailVerified,
|
|
4
|
+
notifyUserOnAdminApproval,
|
|
5
|
+
} from "./notification-workflows";
|
|
6
|
+
|
|
7
|
+
describe("notification-workflows", () => {
|
|
8
|
+
it("notifies admins when admin recipients exist", async () => {
|
|
9
|
+
const listAdminEmails = vi
|
|
10
|
+
.fn<() => Promise<string[]>>()
|
|
11
|
+
.mockResolvedValue(["admin@example.com"]);
|
|
12
|
+
const notifyAdmins = vi.fn().mockResolvedValue(undefined);
|
|
13
|
+
|
|
14
|
+
const result = await notifyAdminsOnEmailVerified({
|
|
15
|
+
user: {
|
|
16
|
+
id: "user-1",
|
|
17
|
+
email: "user@example.com",
|
|
18
|
+
},
|
|
19
|
+
listAdminEmails,
|
|
20
|
+
notifyAdmins,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(result).toEqual({
|
|
24
|
+
notified: true,
|
|
25
|
+
adminEmails: ["admin@example.com"],
|
|
26
|
+
});
|
|
27
|
+
expect(notifyAdmins).toHaveBeenCalledWith(
|
|
28
|
+
expect.objectContaining({
|
|
29
|
+
adminEmails: ["admin@example.com"],
|
|
30
|
+
user: expect.objectContaining({ id: "user-1" }),
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("skips admin notification when there are no admin recipients", async () => {
|
|
36
|
+
const notifyAdmins = vi.fn().mockResolvedValue(undefined);
|
|
37
|
+
|
|
38
|
+
const result = await notifyAdminsOnEmailVerified({
|
|
39
|
+
user: {
|
|
40
|
+
id: "user-1",
|
|
41
|
+
email: "user@example.com",
|
|
42
|
+
},
|
|
43
|
+
listAdminEmails: vi.fn().mockResolvedValue([]),
|
|
44
|
+
notifyAdmins,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(result).toEqual({ notified: false, adminEmails: [] });
|
|
48
|
+
expect(notifyAdmins).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("notifies user when account is approved", async () => {
|
|
52
|
+
const notifyUser = vi.fn().mockResolvedValue(undefined);
|
|
53
|
+
|
|
54
|
+
const result = await notifyUserOnAdminApproval({
|
|
55
|
+
approved: true,
|
|
56
|
+
user: { email: "user@example.com" },
|
|
57
|
+
notifyUser,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(result).toEqual({ notified: true });
|
|
61
|
+
expect(notifyUser).toHaveBeenCalledWith({
|
|
62
|
+
email: "user@example.com",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("skips user notification when approval is false", async () => {
|
|
67
|
+
const notifyUser = vi.fn().mockResolvedValue(undefined);
|
|
68
|
+
|
|
69
|
+
const result = await notifyUserOnAdminApproval({
|
|
70
|
+
approved: false,
|
|
71
|
+
user: { email: "user@example.com" },
|
|
72
|
+
notifyUser,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(result).toEqual({ notified: false });
|
|
76
|
+
expect(notifyUser).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type VerificationNotificationUser = {
|
|
2
|
+
id: string;
|
|
3
|
+
email: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export const notifyAdminsOnEmailVerified = async (params: {
|
|
7
|
+
user: VerificationNotificationUser;
|
|
8
|
+
listAdminEmails: () => Promise<string[]>;
|
|
9
|
+
notifyAdmins: (payload: {
|
|
10
|
+
adminEmails: string[];
|
|
11
|
+
user: VerificationNotificationUser;
|
|
12
|
+
}) => Promise<void>;
|
|
13
|
+
}) => {
|
|
14
|
+
const adminEmails = await params.listAdminEmails();
|
|
15
|
+
if (adminEmails.length === 0) {
|
|
16
|
+
return { notified: false as const, adminEmails };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
await params.notifyAdmins({ adminEmails, user: params.user });
|
|
20
|
+
return { notified: true as const, adminEmails };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const notifyUserOnAdminApproval = async (params: {
|
|
24
|
+
approved: boolean;
|
|
25
|
+
user: {
|
|
26
|
+
email: string;
|
|
27
|
+
};
|
|
28
|
+
notifyUser: (payload: { email: string }) => Promise<void>;
|
|
29
|
+
}) => {
|
|
30
|
+
if (!params.approved) {
|
|
31
|
+
return { notified: false as const };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await params.notifyUser({
|
|
35
|
+
email: params.user.email,
|
|
36
|
+
});
|
|
37
|
+
return { notified: true as const };
|
|
38
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isStrongPassword, isValidEmail, sanitizeEmail } from "./validation";
|
|
3
|
+
|
|
4
|
+
describe("validation", () => {
|
|
5
|
+
it("sanitizes email", () => {
|
|
6
|
+
expect(sanitizeEmail(" USER@Example.COM ")).toBe("user@example.com");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("validates email format", () => {
|
|
10
|
+
expect(isValidEmail("bad-email")).toBe(false);
|
|
11
|
+
expect(isValidEmail("u@e\\.c")).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("checks password strength", () => {
|
|
15
|
+
expect(isStrongPassword("Abc123")).toBe(false);
|
|
16
|
+
expect(isStrongPassword("lowercase123")).toBe(false);
|
|
17
|
+
expect(isStrongPassword("UPPERCASE123")).toBe(false);
|
|
18
|
+
expect(isStrongPassword("NoDigitsHere")).toBe(false);
|
|
19
|
+
expect(isStrongPassword("Abc\\d")).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
});
|