@devopsplaybook.io/common-utils 1.3.0-beta.12.165a8d0 → 1.4.0-beta.13.4edad2b

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.
@@ -0,0 +1,205 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UsersRoutes = void 0;
4
+ const Auth_1 = require("./Auth");
5
+ const User_1 = require("./User");
6
+ const UserPassword_1 = require("./UserPassword");
7
+ const UsersData_1 = require("./UsersData");
8
+ /**
9
+ * Retrieves the OTel span attached to the request by the
10
+ * `@devopsplaybook.io/otel-utils-fastify` hooks.
11
+ */
12
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
+ function requestSpan(req) {
14
+ return req === null || req === void 0 ? void 0 : req.tracerSpanApi;
15
+ }
16
+ /**
17
+ * Standard user management routes shared across applications:
18
+ * initialization status, login (session), user CRUD and password changes.
19
+ *
20
+ * Register on a fastify instance:
21
+ * ```ts
22
+ * fastify.register(new UsersRoutes().getRoutes, { prefix: "/api/users" });
23
+ * ```
24
+ */
25
+ class UsersRoutes {
26
+ //
27
+ async getRoutes(fastify) {
28
+ //
29
+ fastify.get("/status/initialization", async (req, res) => {
30
+ if ((await (0, UsersData_1.UsersDataList)(requestSpan(req))).length === 0) {
31
+ res.status(201).send({ initialized: false });
32
+ }
33
+ else {
34
+ res.status(201).send({ initialized: true });
35
+ }
36
+ });
37
+ fastify.post("/session", async (req, res) => {
38
+ let user;
39
+ // From token
40
+ const userSession = await (0, Auth_1.AuthGetUserSession)(req);
41
+ if (userSession.isAuthenticated) {
42
+ // isAuthenticated implies userId is set
43
+ user = await (0, UsersData_1.UsersDataGet)(requestSpan(req), userSession.userId);
44
+ if (!user) {
45
+ return res.status(403).send({ error: "Authentication Failed" });
46
+ }
47
+ return res.status(201).send({
48
+ success: true,
49
+ token: await (0, Auth_1.AuthGenerateJWT)(user),
50
+ user: user.toTransportJson(),
51
+ });
52
+ }
53
+ // From User/Pass
54
+ if (!req.body.name) {
55
+ return res.status(400).send({ error: "Missing: Name" });
56
+ }
57
+ if (!req.body.password) {
58
+ return res.status(400).send({ error: "Missing: Password" });
59
+ }
60
+ user = await (0, UsersData_1.UsersDataGetByName)(requestSpan(req), req.body.name);
61
+ if (!user) {
62
+ return res.status(403).send({ error: "Authentication Failed" });
63
+ }
64
+ else if (await (0, UserPassword_1.UserPasswordCheckPassword)(requestSpan(req), user, req.body.password)) {
65
+ return res.status(201).send({
66
+ success: true,
67
+ token: await (0, Auth_1.AuthGenerateJWT)(user),
68
+ user: user.toTransportJson(),
69
+ });
70
+ }
71
+ else {
72
+ return res.status(403).send({ error: "Authentication Failed" });
73
+ }
74
+ });
75
+ // ==================== LIST USERS (Admin only) ====================
76
+ fastify.get("/", async (req, res) => {
77
+ try {
78
+ await (0, Auth_1.AuthMustBeAdmin)(req, res);
79
+ }
80
+ catch {
81
+ return;
82
+ }
83
+ const users = await (0, UsersData_1.UsersDataList)(requestSpan(req));
84
+ return res.status(200).send(users.map((u) => u.toTransportJson()));
85
+ });
86
+ fastify.post("/", async (req, res) => {
87
+ const context = requestSpan(req);
88
+ let isInitialized = true;
89
+ if ((await (0, UsersData_1.UsersDataList)(context)).length === 0) {
90
+ isInitialized = false;
91
+ }
92
+ // If initialized, only admin can create users
93
+ if (isInitialized) {
94
+ try {
95
+ await (0, Auth_1.AuthMustBeAdmin)(req, res);
96
+ }
97
+ catch {
98
+ return;
99
+ }
100
+ }
101
+ if (!req.body.name) {
102
+ return res.status(400).send({ error: "Missing: Name" });
103
+ }
104
+ if (!req.body.password) {
105
+ return res.status(400).send({ error: "Missing: Password" });
106
+ }
107
+ if (await (0, UsersData_1.UsersDataGetByName)(context, req.body.name)) {
108
+ return res.status(400).send({ error: "Username Already Exists" });
109
+ }
110
+ const newUser = new User_1.User();
111
+ newUser.name = req.body.name;
112
+ // First user is always admin
113
+ if (isInitialized) {
114
+ newUser.role = req.body.role === "admin" ? "admin" : "user";
115
+ if (req.body.scopes && Array.isArray(req.body.scopes)) {
116
+ newUser.scopes = req.body.scopes.filter((s) => User_1.User.ALL_SCOPES.includes(s));
117
+ }
118
+ }
119
+ else {
120
+ newUser.role = "admin";
121
+ newUser.scopes = [...User_1.User.ALL_SCOPES];
122
+ }
123
+ await (0, UserPassword_1.UserPasswordSetPassword)(context, newUser, req.body.password);
124
+ await (0, UsersData_1.UsersDataAdd)(context, newUser);
125
+ res.status(201).send({ user: newUser.toTransportJson() });
126
+ });
127
+ fastify.put("/password", async (req, res) => {
128
+ const context = requestSpan(req);
129
+ const userSession = await (0, Auth_1.AuthGetUserSession)(req);
130
+ if (!userSession.isAuthenticated) {
131
+ return res.status(403).send({ error: "Access Denied" });
132
+ }
133
+ // isAuthenticated implies userId is set
134
+ const user = await (0, UsersData_1.UsersDataGet)(context, userSession.userId);
135
+ if (!user) {
136
+ return res.status(403).send({ error: "Access Denied" });
137
+ }
138
+ if (!req.body.password) {
139
+ return res.status(400).send({ error: "Missing: Password" });
140
+ }
141
+ if (!(await (0, UserPassword_1.UserPasswordCheckPassword)(context, user, req.body.passwordOld))) {
142
+ return res.status(403).send({ error: "Old Password Wrong" });
143
+ }
144
+ await (0, UserPassword_1.UserPasswordSetPassword)(context, user, req.body.password);
145
+ await (0, UsersData_1.UsersDataUpdatePassword)(context, user);
146
+ res.status(201).send({});
147
+ });
148
+ fastify.put("/:id", async (req, res) => {
149
+ const context = requestSpan(req);
150
+ try {
151
+ await (0, Auth_1.AuthMustBeAdmin)(req, res);
152
+ }
153
+ catch {
154
+ return;
155
+ }
156
+ const user = await (0, UsersData_1.UsersDataGet)(context, req.params.id);
157
+ if (!user) {
158
+ return res.status(404).send({ error: "User Not Found" });
159
+ }
160
+ if (req.body.role) {
161
+ user.role = req.body.role === "admin" ? "admin" : "user";
162
+ }
163
+ if (req.body.scopes && Array.isArray(req.body.scopes)) {
164
+ user.scopes = req.body.scopes.filter((s) => User_1.User.ALL_SCOPES.includes(s));
165
+ }
166
+ await (0, UsersData_1.UsersDataUpdateUser)(context, user);
167
+ // If password change requested
168
+ if (req.body.password) {
169
+ await (0, UserPassword_1.UserPasswordSetPassword)(context, user, req.body.password);
170
+ await (0, UsersData_1.UsersDataUpdatePassword)(context, user);
171
+ }
172
+ res.status(201).send({ user: user.toTransportJson() });
173
+ });
174
+ fastify.delete("/:id", async (req, res) => {
175
+ const context = requestSpan(req);
176
+ try {
177
+ await (0, Auth_1.AuthMustBeAdmin)(req, res);
178
+ }
179
+ catch {
180
+ return;
181
+ }
182
+ const userSession = await (0, Auth_1.AuthGetUserSession)(req);
183
+ // Cannot delete yourself
184
+ if (userSession.userId === req.params.id) {
185
+ return res.status(400).send({ error: "Cannot Delete Yourself" });
186
+ }
187
+ const user = await (0, UsersData_1.UsersDataGet)(context, req.params.id);
188
+ if (!user) {
189
+ return res.status(404).send({ error: "User Not Found" });
190
+ }
191
+ // Check that at least 1 admin remains
192
+ if (user.role === "admin") {
193
+ const admins = (await (0, UsersData_1.UsersDataList)(context)).filter((u) => u.role === "admin");
194
+ if (admins.length <= 1) {
195
+ return res
196
+ .status(400)
197
+ .send({ error: "At least 1 admin must be defined" });
198
+ }
199
+ }
200
+ await (0, UsersData_1.UsersDataDelete)(context, req.params.id);
201
+ res.status(201).send({});
202
+ });
203
+ }
204
+ }
205
+ exports.UsersRoutes = UsersRoutes;
package/index.ts CHANGED
@@ -7,3 +7,9 @@ export * from "./src/PostgresDbUtils";
7
7
  export * from "./src/Notifications";
8
8
  export * from "./src/SystemCommand";
9
9
  export * from "./src/Timeout";
10
+ export * from "./src/User";
11
+ export * from "./src/UserSession";
12
+ export * from "./src/Auth";
13
+ export * from "./src/UserPassword";
14
+ export * from "./src/UsersData";
15
+ export * from "./src/UsersRoutes";
package/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "@devopsplaybook.io/common-utils",
3
- "version": "1.3.0-beta.12.165a8d0",
4
- "description": "Shared utility modules for devopsplaybook.io projects (DB, Config, OTel context, notifications, system helpers)",
3
+ "version": "1.4.0-beta.13.4edad2b",
4
+ "description": "Shared utility modules for devopsplaybook.io projects (DB, Config, OTel context, auth/users, notifications, system helpers)",
5
5
  "keywords": [
6
6
  "Open Telemetry",
7
7
  "OTel",
8
8
  "SQLite",
9
9
  "Postgres",
10
10
  "Config",
11
+ "Auth",
12
+ "JWT",
13
+ "Users",
11
14
  "Notifications",
12
15
  "Utilities"
13
16
  ],
@@ -26,16 +29,21 @@
26
29
  "@opentelemetry/api": "^1.9.1",
27
30
  "@opentelemetry/sdk-trace-base": "^2.10.0",
28
31
  "axios": "^1.19.0",
32
+ "bcrypt": "^6.0.0",
29
33
  "better-sqlite3": "^13.0.3",
34
+ "fastify": "^5.12.1",
30
35
  "fs-extra": "^11.4.0",
36
+ "jsonwebtoken": "^9.0.3",
31
37
  "pg": "^8.23.0",
32
38
  "uuid": "^14.0.2"
33
39
  },
34
40
  "devDependencies": {
35
41
  "@eslint/js": "^10.0.1",
42
+ "@types/bcrypt": "^6.0.0",
36
43
  "@types/better-sqlite3": "^9.6.0",
37
44
  "@types/fs-extra": "^11.0.4",
38
45
  "@types/jest": "^30.0.0",
46
+ "@types/jsonwebtoken": "^9.0.10",
39
47
  "@types/node": "^26.2.0",
40
48
  "@types/pg": "^8.23.1",
41
49
  "@types/uuid": "^11.0.0",
package/src/Auth.ts ADDED
@@ -0,0 +1,165 @@
1
+ import { StandardTracer } from "@devopsplaybook.io/otel-utils";
2
+ import { Span } from "@opentelemetry/sdk-trace-base";
3
+ import * as jwt from "jsonwebtoken";
4
+ import { v4 as uuidv4 } from "uuid";
5
+ import { DbUtilsQuerySQL } from "./DbUtils";
6
+ import { User, UserScope } from "./User";
7
+ import { UserSession } from "./UserSession";
8
+
9
+ /**
10
+ * Configuration subset required by the auth module.
11
+ */
12
+ export interface AuthConfig {
13
+ JWT_KEY: string;
14
+ JWT_VALIDITY_DURATION: number;
15
+ DATABASE_TYPE: "sqlite" | "postgres";
16
+ }
17
+
18
+ let tracer: StandardTracer;
19
+ let config: AuthConfig;
20
+
21
+ /**
22
+ * Injects the OTel tracer instance used by the auth module.
23
+ * Must be called once at startup, before {@link AuthInit}.
24
+ */
25
+ export function AuthSetOTel(tracerIn: StandardTracer): void {
26
+ tracer = tracerIn;
27
+ }
28
+
29
+ /**
30
+ * Initialise the auth module.
31
+ *
32
+ * Registers the full scope set of the host application and loads the JWT
33
+ * signing key from the `metadata` table. When no key is stored yet, a fresh
34
+ * one is generated and persisted.
35
+ *
36
+ * @param context Parent OTel span.
37
+ * @param configIn Server configuration (JWT_KEY is updated in place).
38
+ * @param allScopes All scopes supported by the host application.
39
+ */
40
+ export async function AuthInit(
41
+ context: Span,
42
+ configIn: AuthConfig,
43
+ allScopes: UserScope[] = [],
44
+ ): Promise<void> {
45
+ config = configIn;
46
+ User.ALL_SCOPES = [...allScopes];
47
+ const span = tracer.startSpan("AuthInit", context);
48
+ const authKeyRaw = await DbUtilsQuerySQL(span, SQL_QUERIES.GET_AUTH_TOKEN);
49
+ if (authKeyRaw.length == 0) {
50
+ configIn.JWT_KEY = uuidv4();
51
+ await DbUtilsQuerySQL(span, SQL_QUERIES.INSERT_AUTH_TOKEN, [
52
+ configIn.JWT_KEY,
53
+ new Date().toISOString(),
54
+ ]);
55
+ } else {
56
+ configIn.JWT_KEY = authKeyRaw[0].value;
57
+ }
58
+ span.end();
59
+ }
60
+
61
+ export async function AuthGenerateJWT(user: User): Promise<string> {
62
+ return jwt.sign(
63
+ {
64
+ exp: Math.floor(Date.now() / 1000) + config.JWT_VALIDITY_DURATION,
65
+ userId: user.id,
66
+ userName: user.name,
67
+ role: user.role,
68
+ scopes: user.role === "admin" ? User.ALL_SCOPES : user.scopes,
69
+ },
70
+ config.JWT_KEY,
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Decode JWT from request, caching result on req._jwtPayload to avoid
76
+ * redundant verification when multiple auth functions are called per request.
77
+ */
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
+ function jwtDecodeCached(req: any): any | null {
80
+ if (req._jwtPayload) {
81
+ return req._jwtPayload;
82
+ }
83
+ if (!req.headers.authorization) {
84
+ return null;
85
+ }
86
+ try {
87
+ const info = jwt.verify(
88
+ req.headers.authorization.split(" ")[1],
89
+ config.JWT_KEY,
90
+ );
91
+ req._jwtPayload = info;
92
+ return info;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ export async function AuthMustBeAuthenticated(
99
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
+ req: any,
101
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
102
+ res: any,
103
+ ): Promise<void> {
104
+ if (!jwtDecodeCached(req)) {
105
+ res.status(403).send({ error: "Access Denied" });
106
+ throw new Error("Access Denied");
107
+ }
108
+ }
109
+
110
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
111
+ export async function AuthMustBeAdmin(req: any, res: any): Promise<void> {
112
+ const info = jwtDecodeCached(req);
113
+ if (info?.role === "admin") {
114
+ return;
115
+ }
116
+ res.status(403).send({ error: "Access Denied" });
117
+ throw new Error("Access Denied");
118
+ }
119
+
120
+ export async function AuthHasScope(
121
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
122
+ req: any,
123
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
124
+ res: any,
125
+ scope: UserScope,
126
+ ): Promise<void> {
127
+ const info = jwtDecodeCached(req);
128
+ if (!info) {
129
+ res.status(403).send({ error: "Access Denied" });
130
+ throw new Error("Access Denied");
131
+ }
132
+ if (info.role === "admin") {
133
+ return;
134
+ }
135
+ const scopes: UserScope[] = info.scopes || [];
136
+ if (scopes.includes(scope)) {
137
+ return;
138
+ }
139
+ res.status(403).send({ error: "Access Denied" });
140
+ throw new Error("Access Denied");
141
+ }
142
+
143
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
144
+ export async function AuthGetUserSession(req: any): Promise<UserSession> {
145
+ const userSession: UserSession = { isAuthenticated: false };
146
+ const info = jwtDecodeCached(req);
147
+ if (info) {
148
+ userSession.userId = info.userId;
149
+ userSession.userName = info.userName;
150
+ userSession.role = info.role;
151
+ userSession.scopes = info.scopes;
152
+ userSession.isAuthenticated = true;
153
+ }
154
+ return userSession;
155
+ }
156
+
157
+ // SQL
158
+ // Written SQLite-first with quoted identifiers (valid for both backends);
159
+ // the DbUtils facade converts `?` placeholders for Postgres.
160
+
161
+ const SQL_QUERIES = {
162
+ GET_AUTH_TOKEN: "SELECT value FROM metadata WHERE \"type\" = 'auth_token' LIMIT 1",
163
+ INSERT_AUTH_TOKEN:
164
+ 'INSERT INTO metadata ("type", "value", "dateCreated") VALUES (\'auth_token\', ?, ?)',
165
+ };
package/src/User.ts ADDED
@@ -0,0 +1,78 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+
3
+ /**
4
+ * User role. `admin` bypasses scope checks; `user` is restricted
5
+ * to its granted scopes.
6
+ */
7
+ export type UserRole = "admin" | "user";
8
+
9
+ /**
10
+ * Scope identifier restricting what a non-admin user can access.
11
+ * Each application defines its own scope set (e.g. `"traces"`, `"metrics"`)
12
+ * and registers it through `AuthInit`.
13
+ */
14
+ export type UserScope = string;
15
+
16
+ export class User {
17
+ //
18
+ public static DEFAULT_SCOPES: UserScope[] = [];
19
+ /** Full scope set of the host application, registered via `AuthInit`. */
20
+ public static ALL_SCOPES: UserScope[] = [];
21
+
22
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
+ public static fromJson(json: any): User | null {
24
+ if (!json) {
25
+ return null;
26
+ }
27
+ const user = new User();
28
+ if (json.id) {
29
+ user.id = json.id;
30
+ }
31
+ user.id = json.id;
32
+ user.name = json.name;
33
+ user.passwordEncrypted = json.passwordEncrypted;
34
+ user.role = json.role || "user";
35
+ if (json.scopes) {
36
+ try {
37
+ user.scopes =
38
+ typeof json.scopes === "string"
39
+ ? JSON.parse(json.scopes)
40
+ : json.scopes;
41
+ } catch {
42
+ user.scopes = [...User.DEFAULT_SCOPES];
43
+ }
44
+ }
45
+ return user;
46
+ }
47
+
48
+ public id: string;
49
+ public name!: string;
50
+ public passwordEncrypted!: string;
51
+ public role: UserRole = "user";
52
+ public scopes: UserScope[] = [...User.DEFAULT_SCOPES];
53
+
54
+ constructor() {
55
+ this.id = uuidv4();
56
+ }
57
+
58
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
+ public toJson(): any {
60
+ return {
61
+ id: this.id,
62
+ name: this.name,
63
+ passwordEncrypted: this.passwordEncrypted,
64
+ role: this.role,
65
+ scopes: this.scopes,
66
+ };
67
+ }
68
+
69
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
+ public toTransportJson(): any {
71
+ return {
72
+ id: this.id,
73
+ name: this.name,
74
+ role: this.role,
75
+ scopes: this.scopes,
76
+ };
77
+ }
78
+ }
@@ -0,0 +1,28 @@
1
+ jest.mock("uuid", () => ({
2
+ v4: () => "mock-uuid-1234",
3
+ }));
4
+
5
+ import { User } from "./User";
6
+ import {
7
+ UserPasswordCheckPassword,
8
+ UserPasswordSetPassword,
9
+ } from "./UserPassword";
10
+
11
+ test("Password should be successfully verified if it's the same", async () => {
12
+ const password = "testPassword1234";
13
+ const user = new User();
14
+ await UserPasswordSetPassword(null as never, user, password);
15
+ expect(
16
+ await UserPasswordCheckPassword(null as never, user, password),
17
+ ).toBeTruthy();
18
+ });
19
+
20
+ test("Password should be faile to be verified if it's not the same", async () => {
21
+ const password = "testPassword1234";
22
+ const passwordWrong = "testPassword12345";
23
+ const user = new User();
24
+ await UserPasswordSetPassword(null as never, user, password);
25
+ expect(
26
+ await UserPasswordCheckPassword(null as never, user, passwordWrong),
27
+ ).toBeFalsy();
28
+ });
@@ -0,0 +1,20 @@
1
+ import { Span } from "@opentelemetry/sdk-trace-base";
2
+ import * as bcrypt from "bcrypt";
3
+ import { User } from "./User";
4
+
5
+ export async function UserPasswordSetPassword(
6
+ context: Span | undefined,
7
+ user: User,
8
+ password: string,
9
+ ): Promise<void> {
10
+ const salt = await bcrypt.genSalt(10);
11
+ user.passwordEncrypted = await bcrypt.hash(password, salt);
12
+ }
13
+
14
+ export async function UserPasswordCheckPassword(
15
+ context: Span | undefined,
16
+ user: User,
17
+ password: string,
18
+ ): Promise<boolean> {
19
+ return await bcrypt.compare(password, user.passwordEncrypted);
20
+ }
@@ -0,0 +1,9 @@
1
+ import { UserRole, UserScope } from "./User";
2
+
3
+ export interface UserSession {
4
+ isAuthenticated: boolean;
5
+ userId?: string;
6
+ userName?: string;
7
+ role?: UserRole;
8
+ scopes?: UserScope[];
9
+ }