@scryan7371/sdr-security 0.1.1
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 +72 -0
- package/dist/api/access-policy.d.ts +15 -0
- package/dist/api/access-policy.js +41 -0
- package/dist/api/access-policy.test.d.ts +1 -0
- package/dist/api/access-policy.test.js +67 -0
- package/dist/api/contracts.d.ts +46 -0
- package/dist/api/contracts.js +4 -0
- package/dist/api/index.d.ts +5 -0
- package/dist/api/index.js +21 -0
- package/dist/api/migrations/1700000000001-add-refresh-tokens.d.ts +9 -0
- package/dist/api/migrations/1700000000001-add-refresh-tokens.js +40 -0
- package/dist/api/migrations/1739490000000-add-google-subject-to-user.d.ts +5 -0
- package/dist/api/migrations/1739490000000-add-google-subject-to-user.js +14 -0
- package/dist/api/migrations/1739500000000-create-security-identity.d.ts +9 -0
- package/dist/api/migrations/1739500000000-create-security-identity.js +68 -0
- package/dist/api/migrations/1739510000000-create-security-roles.d.ts +9 -0
- package/dist/api/migrations/1739510000000-create-security-roles.js +95 -0
- package/dist/api/migrations/index.d.ts +6 -0
- package/dist/api/migrations/index.js +17 -0
- package/dist/api/roles.d.ts +3 -0
- package/dist/api/roles.js +29 -0
- package/dist/api/roles.test.d.ts +1 -0
- package/dist/api/roles.test.js +19 -0
- package/dist/api/validation.d.ts +3 -0
- package/dist/api/validation.js +9 -0
- package/dist/app/client.d.ts +52 -0
- package/dist/app/client.js +78 -0
- package/dist/app/index.d.ts +1 -0
- package/dist/app/index.js +17 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +38 -0
- package/package.json +34 -0
- package/src/api/access-policy.test.ts +103 -0
- package/src/api/access-policy.ts +68 -0
- package/src/api/contracts.ts +47 -0
- package/src/api/index.ts +5 -0
- package/src/api/migrations/1700000000001-add-refresh-tokens.ts +47 -0
- package/src/api/migrations/1739490000000-add-google-subject-to-user.ts +12 -0
- package/src/api/migrations/1739500000000-create-security-identity.ts +95 -0
- package/src/api/migrations/1739510000000-create-security-roles.ts +122 -0
- package/src/api/migrations/index.ts +18 -0
- package/src/api/roles.test.ts +20 -0
- package/src/api/roles.ts +25 -0
- package/src/api/validation.ts +7 -0
- package/src/app/client.ts +131 -0
- package/src/app/index.ts +1 -0
- package/src/index.ts +2 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export class CreateSecurityRoles1739510000000 {
|
|
2
|
+
name = "CreateSecurityRoles1739510000000";
|
|
3
|
+
|
|
4
|
+
async up(queryRunner: {
|
|
5
|
+
query: (sql: string, params?: unknown[]) => Promise<unknown>;
|
|
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
|
+
await queryRunner.query(`
|
|
17
|
+
CREATE TABLE IF NOT EXISTS "security_role" (
|
|
18
|
+
"id" uuid PRIMARY KEY NOT NULL DEFAULT uuid_generate_v4(),
|
|
19
|
+
"role_key" varchar NOT NULL,
|
|
20
|
+
"description" text,
|
|
21
|
+
"is_system" boolean NOT NULL DEFAULT false,
|
|
22
|
+
"created_at" timestamptz NOT NULL DEFAULT now(),
|
|
23
|
+
"updated_at" timestamptz NOT NULL DEFAULT now()
|
|
24
|
+
)
|
|
25
|
+
`);
|
|
26
|
+
|
|
27
|
+
await queryRunner.query(
|
|
28
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_security_role_key" ON "security_role" ("role_key")`,
|
|
29
|
+
);
|
|
30
|
+
|
|
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
|
+
await queryRunner.query(`
|
|
47
|
+
INSERT INTO "security_role" ("role_key", "description", "is_system", "created_at", "updated_at")
|
|
48
|
+
VALUES ('ADMIN', 'Administrative access', true, now(), now())
|
|
49
|
+
ON CONFLICT ("role_key") DO NOTHING
|
|
50
|
+
`);
|
|
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
|
+
}
|
|
103
|
+
|
|
104
|
+
async down(queryRunner: {
|
|
105
|
+
query: (sql: string) => Promise<unknown>;
|
|
106
|
+
}): 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
|
+
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_security_role_key"`);
|
|
112
|
+
await queryRunner.query(`DROP TABLE IF EXISTS "security_role"`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
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,18 @@
|
|
|
1
|
+
import { AddRefreshTokens1700000000001 } from "./1700000000001-add-refresh-tokens";
|
|
2
|
+
import { AddGoogleSubjectToUser1739490000000 } from "./1739490000000-add-google-subject-to-user";
|
|
3
|
+
import { CreateSecurityIdentity1739500000000 } from "./1739500000000-create-security-identity";
|
|
4
|
+
import { CreateSecurityRoles1739510000000 } from "./1739510000000-create-security-roles";
|
|
5
|
+
|
|
6
|
+
export const securityMigrations = [
|
|
7
|
+
AddRefreshTokens1700000000001,
|
|
8
|
+
AddGoogleSubjectToUser1739490000000,
|
|
9
|
+
CreateSecurityIdentity1739500000000,
|
|
10
|
+
CreateSecurityRoles1739510000000,
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
AddRefreshTokens1700000000001,
|
|
15
|
+
AddGoogleSubjectToUser1739490000000,
|
|
16
|
+
CreateSecurityIdentity1739500000000,
|
|
17
|
+
CreateSecurityRoles1739510000000,
|
|
18
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { hasRole, isAdmin, normalizeRoleName } from "./roles";
|
|
3
|
+
|
|
4
|
+
describe("roles", () => {
|
|
5
|
+
it("normalizes role names", () => {
|
|
6
|
+
expect(normalizeRoleName("admin")).toBe("ADMIN");
|
|
7
|
+
expect(normalizeRoleName("case manager")).toBe("CASE_MANAGER");
|
|
8
|
+
expect(normalizeRoleName("ADMINISTRATOR")).toBe("ADMIN");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("checks role membership", () => {
|
|
12
|
+
expect(hasRole(["ADMIN", "MEMBER"], "admin")).toBe(true);
|
|
13
|
+
expect(hasRole(["MEMBER"], "ADMIN")).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("checks admin role", () => {
|
|
17
|
+
expect(isAdmin(["ADMIN"])).toBe(true);
|
|
18
|
+
expect(isAdmin(["MEMBER"])).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
package/src/api/roles.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ADMIN_ROLE } from "./contracts";
|
|
2
|
+
|
|
3
|
+
export const normalizeRoleName = (value: string): string => {
|
|
4
|
+
const normalized = value.trim().toUpperCase().replace(/\s+/g, "_");
|
|
5
|
+
if (!normalized || !/^[A-Z][A-Z0-9_]*$/.test(normalized)) {
|
|
6
|
+
throw new Error("Invalid role name");
|
|
7
|
+
}
|
|
8
|
+
if (normalized === "ADMINISTRATOR") {
|
|
9
|
+
return ADMIN_ROLE;
|
|
10
|
+
}
|
|
11
|
+
return normalized;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const hasRole = (roles: string[], role: string) => {
|
|
15
|
+
const normalizedRole = normalizeRoleName(role);
|
|
16
|
+
return roles.some((assignedRole) => {
|
|
17
|
+
try {
|
|
18
|
+
return normalizeRoleName(assignedRole) === normalizedRole;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const isAdmin = (roles: string[]) => hasRole(roles, ADMIN_ROLE);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const sanitizeEmail = (email: string) => email.trim().toLowerCase();
|
|
2
|
+
|
|
3
|
+
export const isValidEmail = (value: string) =>
|
|
4
|
+
/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(value);
|
|
5
|
+
|
|
6
|
+
export const isStrongPassword = (value: string) =>
|
|
7
|
+
/[A-Z]/.test(value) && /[a-z]/.test(value) && /\\d/.test(value);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AuthResponse,
|
|
3
|
+
DebugCodeResponse,
|
|
4
|
+
DebugTokenResponse,
|
|
5
|
+
RoleCatalogResponse,
|
|
6
|
+
RegisterResponse,
|
|
7
|
+
UserRolesResponse,
|
|
8
|
+
} from "../api/contracts";
|
|
9
|
+
|
|
10
|
+
type FetchLike = typeof fetch;
|
|
11
|
+
|
|
12
|
+
export type SecurityClientOptions = {
|
|
13
|
+
baseUrl: string;
|
|
14
|
+
getAccessToken: () => string | null;
|
|
15
|
+
fetchImpl?: FetchLike;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const createSecurityClient = (options: SecurityClientOptions) => {
|
|
19
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
20
|
+
|
|
21
|
+
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
|
22
|
+
const token = options.getAccessToken();
|
|
23
|
+
const headers: Record<string, string> = {
|
|
24
|
+
"Content-Type": "application/json",
|
|
25
|
+
...(init?.headers ? (init.headers as Record<string, string>) : {}),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
if (token) {
|
|
29
|
+
headers.Authorization = `Bearer ${token}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const response = await fetchImpl(`${options.baseUrl}${path}`, {
|
|
33
|
+
...init,
|
|
34
|
+
headers,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const text = await response.text();
|
|
38
|
+
const body = text ? JSON.parse(text) : {};
|
|
39
|
+
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
const message =
|
|
42
|
+
typeof body?.message === "string"
|
|
43
|
+
? body.message
|
|
44
|
+
: `Request failed: ${response.status}`;
|
|
45
|
+
throw new Error(message);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return body as T;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
register: (payload: {
|
|
53
|
+
email: string;
|
|
54
|
+
password: string;
|
|
55
|
+
firstName?: string;
|
|
56
|
+
lastName?: string;
|
|
57
|
+
}) =>
|
|
58
|
+
request<RegisterResponse>("/auth/register", {
|
|
59
|
+
method: "POST",
|
|
60
|
+
body: JSON.stringify(payload),
|
|
61
|
+
}),
|
|
62
|
+
|
|
63
|
+
login: (payload: { email: string; password: string }) =>
|
|
64
|
+
request<AuthResponse>("/auth/login", {
|
|
65
|
+
method: "POST",
|
|
66
|
+
body: JSON.stringify(payload),
|
|
67
|
+
}),
|
|
68
|
+
|
|
69
|
+
loginWithGoogle: (payload: { idToken: string }) =>
|
|
70
|
+
request<AuthResponse>("/auth/login/google", {
|
|
71
|
+
method: "POST",
|
|
72
|
+
body: JSON.stringify(payload),
|
|
73
|
+
}),
|
|
74
|
+
|
|
75
|
+
refresh: (payload: { refreshToken: string }) =>
|
|
76
|
+
request<AuthResponse>("/auth/refresh", {
|
|
77
|
+
method: "POST",
|
|
78
|
+
body: JSON.stringify(payload),
|
|
79
|
+
}),
|
|
80
|
+
|
|
81
|
+
revoke: (payload: { refreshToken: string }) =>
|
|
82
|
+
request<{ success: true }>("/auth/revoke", {
|
|
83
|
+
method: "POST",
|
|
84
|
+
body: JSON.stringify(payload),
|
|
85
|
+
}),
|
|
86
|
+
|
|
87
|
+
logout: (payload: { refreshToken?: string }) =>
|
|
88
|
+
request<{ success: true }>("/auth/logout", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
body: JSON.stringify(payload),
|
|
91
|
+
}),
|
|
92
|
+
|
|
93
|
+
requestEmailVerification: () =>
|
|
94
|
+
request<DebugTokenResponse>("/auth/request-email-verification", {
|
|
95
|
+
method: "POST",
|
|
96
|
+
}),
|
|
97
|
+
|
|
98
|
+
verifyEmail: (token: string) =>
|
|
99
|
+
request<{ success: true }>(`/auth/verify-email?token=${token}`),
|
|
100
|
+
|
|
101
|
+
requestPhoneVerification: () =>
|
|
102
|
+
request<DebugCodeResponse>("/auth/request-phone-verification", {
|
|
103
|
+
method: "POST",
|
|
104
|
+
}),
|
|
105
|
+
|
|
106
|
+
verifyPhone: (code: string) =>
|
|
107
|
+
request<{ success: true }>("/auth/verify-phone", {
|
|
108
|
+
method: "POST",
|
|
109
|
+
body: JSON.stringify({ code }),
|
|
110
|
+
}),
|
|
111
|
+
|
|
112
|
+
getMyRoles: () => request<UserRolesResponse>("/auth/me/roles"),
|
|
113
|
+
|
|
114
|
+
listRoles: () => request<RoleCatalogResponse>("/admin/roles"),
|
|
115
|
+
|
|
116
|
+
createRole: (payload: { role: string; description?: string | null }) =>
|
|
117
|
+
request<RoleCatalogResponse>("/admin/roles", {
|
|
118
|
+
method: "POST",
|
|
119
|
+
body: JSON.stringify(payload),
|
|
120
|
+
}),
|
|
121
|
+
|
|
122
|
+
getUserRoles: (userId: string) =>
|
|
123
|
+
request<UserRolesResponse>(`/admin/users/${userId}/roles`),
|
|
124
|
+
|
|
125
|
+
setUserRoles: (userId: string, roles: string[]) =>
|
|
126
|
+
request<UserRolesResponse>(`/admin/users/${userId}/roles`, {
|
|
127
|
+
method: "PUT",
|
|
128
|
+
body: JSON.stringify({ roles }),
|
|
129
|
+
}),
|
|
130
|
+
};
|
|
131
|
+
};
|
package/src/app/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./client";
|
package/src/index.ts
ADDED