From 15b57de0ed9d44d5deb6c43ce60fe470f72af127 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Sat, 9 Dec 2023 19:18:01 +0530 Subject: [PATCH] feat(infisical-pg): test first milestone one flow --- backend-pg/.eslintignore | 2 + backend-pg/.eslintrc.js | 7 +- backend-pg/{.prettierrc => .prettierrc.json} | 2 +- backend-pg/package-lock.json | 215 +++++++--- backend-pg/package.json | 14 +- backend-pg/scripts/create-backend-file.ts | 25 +- backend-pg/scripts/generate-schema-types.ts | 7 +- backend-pg/src/@types/fastify.d.ts | 23 +- backend-pg/src/@types/knex.d.ts | 40 +- .../20231128092347_user-encryption-key.ts | 2 +- .../migrations/20231204092737_organization.ts | 9 + .../20231204092747_org-membership.ts | 25 +- .../20231205151331_incident-contact.ts | 25 ++ .../migrations/20231207055643_user-action.ts | 20 + .../20231207055701_server-config.ts | 23 ++ .../db/migrations/20231207105059_api-key.ts | 26 ++ backend-pg/src/db/schemas/api-keys.ts | 23 ++ .../src/db/schemas/auth-token-sessions.ts | 6 +- backend-pg/src/db/schemas/auth-tokens.ts | 7 +- .../src/db/schemas/backup-private-key.ts | 4 +- .../src/db/schemas/incident-contacts.ts | 20 + backend-pg/src/db/schemas/index.ts | 6 +- backend-pg/src/db/schemas/models.ts | 7 +- backend-pg/src/db/schemas/org-memberships.ts | 24 ++ backend-pg/src/db/schemas/org-roles.ts | 23 ++ .../db/schemas/organization-memberships.ts | 23 -- backend-pg/src/db/schemas/organizations.ts | 4 +- backend-pg/src/db/schemas/server-config.ts | 20 + backend-pg/src/db/schemas/user-actions.ts | 20 + backend-pg/src/db/schemas/users.ts | 6 +- backend-pg/src/ee/LICENSE | 36 ++ backend-pg/src/ee/routes/v1/index.ts | 6 + backend-pg/src/ee/routes/v1/org-role.ts | 150 +++++++ .../ee/services/permission/org-permission.ts | 124 ++++++ .../ee/services/permission/permission-dal.ts | 23 ++ .../services/permission/permission-service.ts | 50 +++ .../services/permission/permission-types.ts | 0 backend-pg/src/lib/config/env.ts | 56 +-- backend-pg/src/lib/errors/index.ts | 2 +- backend-pg/src/lib/knex/index.ts | 100 ++++- .../server/plugins/auth/inject-identity.ts | 18 +- .../src/server/plugins/auth/superAdmin.ts | 11 + .../src/server/plugins/auth/verify-auth.ts | 2 +- backend-pg/src/server/routes/index.ts | 109 +++++- backend-pg/src/server/routes/v1/admin.ts | 101 +++++ backend-pg/src/server/routes/v1/auth.ts | 88 +++++ backend-pg/src/server/routes/v1/index.ts | 12 + backend-pg/src/server/routes/v1/invite-org.ts | 70 ++++ .../server/routes/v1/organization-router.ts | 183 +++++++++ .../server/routes/v1/user-action-router.ts | 54 +++ .../src/server/routes/v1/user-router.ts | 24 ++ backend-pg/src/server/routes/v2/index.ts | 2 + backend-pg/src/server/routes/v2/mfa-router.ts | 2 +- .../server/routes/v2/organization-router.ts | 140 +++++++ .../src/server/routes/v2/user-router.ts | 225 +++++++++++ backend-pg/src/server/routes/v3/index.ts | 2 + .../src/server/routes/v3/signup-router.ts | 50 +++ .../src/server/routes/v3/user-router.ts | 24 ++ .../src/services/api-key/api-key-dal.ts | 7 + .../src/services/api-key/api-key-service.ts | 55 +++ .../src/services/api-key/api-key-types.ts | 0 backend-pg/src/services/auth/auth-dal.ts | 103 +---- .../src/services/auth/auth-login-service.ts | 72 ++-- .../services/auth/auth-password-service.ts | 45 ++- .../src/services/auth/auth-signup-service.ts | 147 ++++++- .../src/services/auth/auth-signup-type.ts | 17 + backend-pg/src/services/auth/auth-type.ts | 7 + .../src/services/org/incident-contacts-dal.ts | 57 +++ backend-pg/src/services/org/org-dal.ts | 184 +++++++++ backend-pg/src/services/org/org-role-dal.ts | 67 ++++ .../src/services/org/org-role-service.ts | 120 ++++++ backend-pg/src/services/org/org-service.ts | 368 ++++++++++++++++++ backend-pg/src/services/org/org-types.ts | 24 ++ .../src/services/server-cfg/server-cfg-dal.ts | 7 + .../services/server-cfg/server-cfg-service.ts | 106 +++++ .../services/server-cfg/server-cfg-types.ts | 16 + backend-pg/src/services/token/token-dal.ts | 85 ++-- .../src/services/token/token-service.ts | 48 ++- backend-pg/src/services/token/token-types.ts | 8 + backend-pg/src/services/user/user-dal.ts | 108 +++++ backend-pg/src/services/user/user-service.ts | 84 ++++ backend-pg/src/services/user/user-types.ts | 0 backend-pg/tsconfig.json | 2 +- backend/src/routes/v1/membershipOrg.ts | 2 + docker-compose.pg.yml | 1 + frontend/src/hooks/api/auth/queries.tsx | 2 +- .../hooks/api/incidentContacts/queries.tsx | 8 +- .../src/hooks/api/incidentContacts/types.ts | 2 +- frontend/src/hooks/api/roles/queries.tsx | 10 +- frontend/src/pages/_app.tsx | 9 +- .../components/InitialStep/InitialStep.tsx | 3 +- .../OrgIncidentContactsTable.tsx | 8 +- 92 files changed, 3668 insertions(+), 436 deletions(-) create mode 100644 backend-pg/.eslintignore rename backend-pg/{.prettierrc => .prettierrc.json} (98%) create mode 100644 backend-pg/src/db/migrations/20231205151331_incident-contact.ts create mode 100644 backend-pg/src/db/migrations/20231207055643_user-action.ts create mode 100644 backend-pg/src/db/migrations/20231207055701_server-config.ts create mode 100644 backend-pg/src/db/migrations/20231207105059_api-key.ts create mode 100644 backend-pg/src/db/schemas/api-keys.ts create mode 100644 backend-pg/src/db/schemas/incident-contacts.ts create mode 100644 backend-pg/src/db/schemas/org-memberships.ts create mode 100644 backend-pg/src/db/schemas/org-roles.ts delete mode 100644 backend-pg/src/db/schemas/organization-memberships.ts create mode 100644 backend-pg/src/db/schemas/server-config.ts create mode 100644 backend-pg/src/db/schemas/user-actions.ts create mode 100644 backend-pg/src/ee/LICENSE create mode 100644 backend-pg/src/ee/routes/v1/index.ts create mode 100644 backend-pg/src/ee/routes/v1/org-role.ts create mode 100644 backend-pg/src/ee/services/permission/org-permission.ts create mode 100644 backend-pg/src/ee/services/permission/permission-dal.ts create mode 100644 backend-pg/src/ee/services/permission/permission-service.ts create mode 100644 backend-pg/src/ee/services/permission/permission-types.ts create mode 100644 backend-pg/src/server/plugins/auth/superAdmin.ts create mode 100644 backend-pg/src/server/routes/v1/admin.ts create mode 100644 backend-pg/src/server/routes/v1/auth.ts create mode 100644 backend-pg/src/server/routes/v1/invite-org.ts create mode 100644 backend-pg/src/server/routes/v1/organization-router.ts create mode 100644 backend-pg/src/server/routes/v1/user-action-router.ts create mode 100644 backend-pg/src/server/routes/v1/user-router.ts create mode 100644 backend-pg/src/server/routes/v2/organization-router.ts create mode 100644 backend-pg/src/server/routes/v2/user-router.ts create mode 100644 backend-pg/src/server/routes/v3/user-router.ts create mode 100644 backend-pg/src/services/api-key/api-key-dal.ts create mode 100644 backend-pg/src/services/api-key/api-key-service.ts create mode 100644 backend-pg/src/services/api-key/api-key-types.ts create mode 100644 backend-pg/src/services/org/incident-contacts-dal.ts create mode 100644 backend-pg/src/services/org/org-dal.ts create mode 100644 backend-pg/src/services/org/org-role-dal.ts create mode 100644 backend-pg/src/services/org/org-role-service.ts create mode 100644 backend-pg/src/services/org/org-service.ts create mode 100644 backend-pg/src/services/org/org-types.ts create mode 100644 backend-pg/src/services/server-cfg/server-cfg-dal.ts create mode 100644 backend-pg/src/services/server-cfg/server-cfg-service.ts create mode 100644 backend-pg/src/services/server-cfg/server-cfg-types.ts create mode 100644 backend-pg/src/services/user/user-dal.ts create mode 100644 backend-pg/src/services/user/user-service.ts create mode 100644 backend-pg/src/services/user/user-types.ts diff --git a/backend-pg/.eslintignore b/backend-pg/.eslintignore new file mode 100644 index 000000000..9899d04e4 --- /dev/null +++ b/backend-pg/.eslintignore @@ -0,0 +1,2 @@ +.eslintrc.js +./scripts diff --git a/backend-pg/.eslintrc.js b/backend-pg/.eslintrc.js index 6d6217634..4fc556adb 100644 --- a/backend-pg/.eslintrc.js +++ b/backend-pg/.eslintrc.js @@ -13,12 +13,13 @@ module.exports = { tsconfigRootDir: __dirname }, rules: { - "import/prefer-default-export": "off", - "simple-import-sort/exports": "error", + "consistent-return": "off", // my style + 'import/order': 'off', // for simple-import-order + "import/prefer-default-export": "off", // why "import/first": "error", "import/newline-after-import": "error", "import/no-duplicates": "error", - "consistent-return": "off", + "simple-import-sort/exports": "error", "simple-import-sort/imports": [ "warn", { diff --git a/backend-pg/.prettierrc b/backend-pg/.prettierrc.json similarity index 98% rename from backend-pg/.prettierrc rename to backend-pg/.prettierrc.json index 0b8ef54d2..f9058accf 100644 --- a/backend-pg/.prettierrc +++ b/backend-pg/.prettierrc.json @@ -4,4 +4,4 @@ "trailingComma": "none", "tabWidth": 2, "semi": true -} +} \ No newline at end of file diff --git a/backend-pg/package-lock.json b/backend-pg/package-lock.json index 35a5d2383..97eaaa114 100644 --- a/backend-pg/package-lock.json +++ b/backend-pg/package-lock.json @@ -9,12 +9,14 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@casl/ability": "^6.5.0", "@fastify/cookie": "^9.2.0", "@fastify/cors": "^8.4.1", "@fastify/helmet": "^11.1.1", "@fastify/rate-limit": "^9.0.0", "@fastify/swagger": "^8.12.0", "@fastify/swagger-ui": "^1.10.1", + "@ucast/mongo2js": "^1.3.4", "bcrypt": "^5.1.1", "dotenv": "^16.3.1", "eslint-config-airbnb-typescript": "^17.1.0", @@ -27,6 +29,7 @@ "nodemailer": "^6.9.7", "ora": "^7.0.1", "pg": "^8.11.3", + "picomatch": "^3.0.1", "pino": "^8.16.2", "zod": "^3.22.4", "zod-to-json-schema": "^3.22.0" @@ -37,12 +40,13 @@ "@types/jsrp": "^0.2.6", "@types/node": "^20.9.5", "@types/nodemailer": "^6.4.14", + "@types/picomatch": "^2.3.3", "@types/prompt-sync": "^4.2.3", - "@typescript-eslint/eslint-plugin": "^6.12.0", - "@typescript-eslint/parser": "^6.12.0", - "eslint": "^8.54.0", + "@typescript-eslint/eslint-plugin": "^6.13.2", + "@typescript-eslint/parser": "^6.13.2", + "eslint": "^8.55.0", "eslint-config-airbnb-base": "^15.0.0", - "eslint-config-prettier": "^9.0.0", + "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-typescript": "^3.6.1", "eslint-plugin-import": "^2.29.0", "eslint-plugin-prettier": "^5.0.1", @@ -63,6 +67,17 @@ "node": ">=0.10.0" } }, + "node_modules/@casl/ability": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.5.0.tgz", + "integrity": "sha512-3guc94ugr5ylZQIpJTLz0CDfwNi0mxKVECj1vJUPAvs+Lwunh/dcuUjwzc4MHM9D8JOYX0XUZMEPedpB3vIbOw==", + "dependencies": { + "@ucast/mongo2js": "^1.3.0" + }, + "funding": { + "url": "https://github.com/stalniy/casl/blob/master/BACKERS.md" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -450,9 +465,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.3.tgz", - "integrity": "sha512-yZzuIG+jnVu6hNSzFEN07e8BxF3uAzYtQb6uDkaYZLo6oYZDCq454c5kB8zxnzfCYyP4MIuyBn10L0DqwujTmA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -513,9 +528,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/@eslint/js": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.54.0.tgz", - "integrity": "sha512-ut5V+D+fOoWPgGGNj83GGjnntO39xDy6DWxO0wb7Jp3DcMX0TfIqdzHF85VTQkerdyGmuuMD9AKAo5KiNlf/AQ==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.55.0.tgz", + "integrity": "sha512-qQfo2mxH5yVom1kacMtZZJFVdW+E70mqHMJvVg6WTLo+VBuQJ4TojZlfWBjK0ve5BdEeNAVxOsl/nvNMpJOaJA==", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -1092,6 +1107,12 @@ "@types/node": "*" } }, + "node_modules/@types/picomatch": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-2.3.3.tgz", + "integrity": "sha512-Yll76ZHikRFCyz/pffKGjrCwe/le2CDwOP5F210KQo27kpRE46U2rDnzikNlVn6/ezH3Mhn46bJMTfeVTtcYMg==", + "dev": true + }, "node_modules/@types/prompt-sync": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/@types/prompt-sync/-/prompt-sync-4.2.3.tgz", @@ -1104,15 +1125,15 @@ "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.12.0.tgz", - "integrity": "sha512-XOpZ3IyJUIV1b15M7HVOpgQxPPF7lGXgsfcEIu3yDxFPaf/xZKt7s9QO/pbk7vpWQyVulpJbu4E5LwpZiQo4kA==", + "version": "6.13.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.13.2.tgz", + "integrity": "sha512-3+9OGAWHhk4O1LlcwLBONbdXsAhLjyCFogJY/cWy2lxdVJ2JrcTF2pTGMaLl2AE7U1l31n8Py4a8bx5DLf/0dQ==", "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.12.0", - "@typescript-eslint/type-utils": "6.12.0", - "@typescript-eslint/utils": "6.12.0", - "@typescript-eslint/visitor-keys": "6.12.0", + "@typescript-eslint/scope-manager": "6.13.2", + "@typescript-eslint/type-utils": "6.13.2", + "@typescript-eslint/utils": "6.13.2", + "@typescript-eslint/visitor-keys": "6.13.2", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -1159,14 +1180,14 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/@typescript-eslint/parser": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.12.0.tgz", - "integrity": "sha512-s8/jNFPKPNRmXEnNXfuo1gemBdVmpQsK1pcu+QIvuNJuhFzGrpD7WjOcvDc/+uEdfzSYpNu7U/+MmbScjoQ6vg==", + "version": "6.13.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.13.2.tgz", + "integrity": "sha512-MUkcC+7Wt/QOGeVlM8aGGJZy1XV5YKjTpq9jK6r6/iLsGXhBVaGP5N0UYvFsu9BFlSpwY9kMretzdBH01rkRXg==", "dependencies": { - "@typescript-eslint/scope-manager": "6.12.0", - "@typescript-eslint/types": "6.12.0", - "@typescript-eslint/typescript-estree": "6.12.0", - "@typescript-eslint/visitor-keys": "6.12.0", + "@typescript-eslint/scope-manager": "6.13.2", + "@typescript-eslint/types": "6.13.2", + "@typescript-eslint/typescript-estree": "6.13.2", + "@typescript-eslint/visitor-keys": "6.13.2", "debug": "^4.3.4" }, "engines": { @@ -1207,12 +1228,12 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/@typescript-eslint/scope-manager": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.12.0.tgz", - "integrity": "sha512-5gUvjg+XdSj8pcetdL9eXJzQNTl3RD7LgUiYTl8Aabdi8hFkaGSYnaS6BLc0BGNaDH+tVzVwmKtWvu0jLgWVbw==", + "version": "6.13.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.13.2.tgz", + "integrity": "sha512-CXQA0xo7z6x13FeDYCgBkjWzNqzBn8RXaE3QVQVIUm74fWJLkJkaHmHdKStrxQllGh6Q4eUGyNpMe0b1hMkXFA==", "dependencies": { - "@typescript-eslint/types": "6.12.0", - "@typescript-eslint/visitor-keys": "6.12.0" + "@typescript-eslint/types": "6.13.2", + "@typescript-eslint/visitor-keys": "6.13.2" }, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -1223,12 +1244,12 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.12.0.tgz", - "integrity": "sha512-WWmRXxhm1X8Wlquj+MhsAG4dU/Blvf1xDgGaYCzfvStP2NwPQh6KBvCDbiOEvaE0filhranjIlK/2fSTVwtBng==", + "version": "6.13.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.13.2.tgz", + "integrity": "sha512-Qr6ssS1GFongzH2qfnWKkAQmMUyZSyOr0W54nZNU1MDfo+U4Mv3XveeLZzadc/yq8iYhQZHYT+eoXJqnACM1tw==", "dependencies": { - "@typescript-eslint/typescript-estree": "6.12.0", - "@typescript-eslint/utils": "6.12.0", + "@typescript-eslint/typescript-estree": "6.13.2", + "@typescript-eslint/utils": "6.13.2", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, @@ -1270,9 +1291,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/@typescript-eslint/types": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.12.0.tgz", - "integrity": "sha512-MA16p/+WxM5JG/F3RTpRIcuOghWO30//VEOvzubM8zuOOBYXsP+IfjoCXXiIfy2Ta8FRh9+IO9QLlaFQUU+10Q==", + "version": "6.13.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.13.2.tgz", + "integrity": "sha512-7sxbQ+EMRubQc3wTfTsycgYpSujyVbI1xw+3UMRUcrhSy+pN09y/lWzeKDbvhoqcRbHdc+APLs/PWYi/cisLPg==", "engines": { "node": "^16.0.0 || >=18.0.0" }, @@ -1282,12 +1303,12 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.12.0.tgz", - "integrity": "sha512-vw9E2P9+3UUWzhgjyyVczLWxZ3GuQNT7QpnIY3o5OMeLO/c8oHljGc8ZpryBMIyympiAAaKgw9e5Hl9dCWFOYw==", + "version": "6.13.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.13.2.tgz", + "integrity": "sha512-SuD8YLQv6WHnOEtKv8D6HZUzOub855cfPnPMKvdM/Bh1plv1f7Q/0iFUDLKKlxHcEstQnaUU4QZskgQq74t+3w==", "dependencies": { - "@typescript-eslint/types": "6.12.0", - "@typescript-eslint/visitor-keys": "6.12.0", + "@typescript-eslint/types": "6.13.2", + "@typescript-eslint/visitor-keys": "6.13.2", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1329,16 +1350,16 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/@typescript-eslint/utils": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.12.0.tgz", - "integrity": "sha512-LywPm8h3tGEbgfyjYnu3dauZ0U7R60m+miXgKcZS8c7QALO9uWJdvNoP+duKTk2XMWc7/Q3d/QiCuLN9X6SWyQ==", + "version": "6.13.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.13.2.tgz", + "integrity": "sha512-b9Ptq4eAZUym4idijCRzl61oPCwwREcfDI8xGk751Vhzig5fFZR9CyzDz4Sp/nxSLBYxUPyh4QdIDqWykFhNmQ==", "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.12.0", - "@typescript-eslint/types": "6.12.0", - "@typescript-eslint/typescript-estree": "6.12.0", + "@typescript-eslint/scope-manager": "6.13.2", + "@typescript-eslint/types": "6.13.2", + "@typescript-eslint/typescript-estree": "6.13.2", "semver": "^7.5.4" }, "engines": { @@ -1353,11 +1374,11 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.12.0.tgz", - "integrity": "sha512-rg3BizTZHF1k3ipn8gfrzDXXSFKyOEB5zxYXInQ6z0hUvmQlhaZQzK+YmHmNViMA9HzW5Q9+bPPt90bU6GQwyw==", + "version": "6.13.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.13.2.tgz", + "integrity": "sha512-OGznFs0eAQXJsp+xSd6k/O1UbFi/K/L7WjqeRoFE7vadjAF9y0uppXhYNQNEqygjou782maGClOoZwPqF0Drlw==", "dependencies": { - "@typescript-eslint/types": "6.12.0", + "@typescript-eslint/types": "6.13.2", "eslint-visitor-keys": "^3.4.1" }, "engines": { @@ -1368,6 +1389,37 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ucast/core": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/@ucast/core/-/core-1.10.2.tgz", + "integrity": "sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==" + }, + "node_modules/@ucast/js": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@ucast/js/-/js-3.0.3.tgz", + "integrity": "sha512-jBBqt57T5WagkAjqfCIIE5UYVdaXYgGkOFYv2+kjq2AVpZ2RIbwCo/TujJpDlwTVluUI+WpnRpoGU2tSGlEvFQ==", + "dependencies": { + "@ucast/core": "^1.0.0" + } + }, + "node_modules/@ucast/mongo": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@ucast/mongo/-/mongo-2.4.3.tgz", + "integrity": "sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==", + "dependencies": { + "@ucast/core": "^1.4.1" + } + }, + "node_modules/@ucast/mongo2js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@ucast/mongo2js/-/mongo2js-1.3.4.tgz", + "integrity": "sha512-ahazOr1HtelA5AC1KZ9x0UwPMqqimvfmtSm/PRRSeKKeE5G2SCqTgwiNzO7i9jS8zA3dzXpKVPpXMkcYLnyItA==", + "dependencies": { + "@ucast/core": "^1.6.1", + "@ucast/js": "^3.0.0", + "@ucast/mongo": "^2.4.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", @@ -1523,6 +1575,18 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", @@ -2453,14 +2517,14 @@ } }, "node_modules/eslint": { - "version": "8.54.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.54.0.tgz", - "integrity": "sha512-NY0DfAkM8BIZDVl6PgSa1ttZbx3xHgJzSNJKYcQglem6CppHyMhRIQkBVSSMaSRnLhig3jsDbEzOjwCVt4AmmA==", + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.55.0.tgz", + "integrity": "sha512-iyUUAM0PCKj5QpwGfmCAG9XXbZCWsqP/eWAWrG/W0umvjuLRBECwSFdt+rCntju0xEH7teIABPwXpahftIaTdA==", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.3", - "@eslint/js": "8.54.0", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.55.0", "@humanwhocodes/config-array": "^0.11.13", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", @@ -2547,9 +2611,9 @@ } }, "node_modules/eslint-config-prettier": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.0.0.tgz", - "integrity": "sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, "bin": { "eslint-config-prettier": "bin/cli.js" @@ -4576,6 +4640,17 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", @@ -5192,11 +5267,11 @@ "dev": true }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", + "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", "engines": { - "node": ">=8.6" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -5471,6 +5546,18 @@ "node": ">=8.10.0" } }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", diff --git a/backend-pg/package.json b/backend-pg/package.json index 93e94eac3..3a9b3c273 100644 --- a/backend-pg/package.json +++ b/backend-pg/package.json @@ -8,7 +8,7 @@ "dev": "tsx watch --clear-screen=false ./src/server/app.ts | pino-pretty --colorize --colorizeObjects --singleLine", "dev:docker": "nodemon", "type:check": "tsc --noEmit", - "lint:fix": "eslint --fix 'src/**/*.ts'", + "lint:fix": "eslint --fix --ext js,ts ./src", "lint": "eslint 'src/**/*.ts'", "generate:component": "tsx ./scripts/create-backend-file.ts", "generate:schema": "tsx ./scripts/generate-schema-types.ts", @@ -28,12 +28,13 @@ "@types/jsrp": "^0.2.6", "@types/node": "^20.9.5", "@types/nodemailer": "^6.4.14", + "@types/picomatch": "^2.3.3", "@types/prompt-sync": "^4.2.3", - "@typescript-eslint/eslint-plugin": "^6.12.0", - "@typescript-eslint/parser": "^6.12.0", - "eslint": "^8.54.0", + "@typescript-eslint/eslint-plugin": "^6.13.2", + "@typescript-eslint/parser": "^6.13.2", + "eslint": "^8.55.0", "eslint-config-airbnb-base": "^15.0.0", - "eslint-config-prettier": "^9.0.0", + "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-typescript": "^3.6.1", "eslint-plugin-import": "^2.29.0", "eslint-plugin-prettier": "^5.0.1", @@ -46,12 +47,14 @@ "typescript": "^5.3.2" }, "dependencies": { + "@casl/ability": "^6.5.0", "@fastify/cookie": "^9.2.0", "@fastify/cors": "^8.4.1", "@fastify/helmet": "^11.1.1", "@fastify/rate-limit": "^9.0.0", "@fastify/swagger": "^8.12.0", "@fastify/swagger-ui": "^1.10.1", + "@ucast/mongo2js": "^1.3.4", "bcrypt": "^5.1.1", "dotenv": "^16.3.1", "eslint-config-airbnb-typescript": "^17.1.0", @@ -64,6 +67,7 @@ "nodemailer": "^6.9.7", "ora": "^7.0.1", "pg": "^8.11.3", + "picomatch": "^3.0.1", "pino": "^8.16.2", "zod": "^3.22.4", "zod-to-json-schema": "^3.22.0" diff --git a/backend-pg/scripts/create-backend-file.ts b/backend-pg/scripts/create-backend-file.ts index 3b1cb96c8..27a52fae8 100644 --- a/backend-pg/scripts/create-backend-file.ts +++ b/backend-pg/scripts/create-backend-file.ts @@ -16,11 +16,18 @@ const componentType = parseInt(prompt("Select a component: "), 10); if (componentType === 1) { const componentName = prompt("Enter service name: "); const dir = path.join(__dirname, `../src/services/${componentName}`); - const capitalizedComponentName = componentName.at(0)?.toUpperCase() + componentName.slice(1); - const dalTypeName = `T${capitalizedComponentName}DalFactory`; - const dalName = `${componentName}DalFactory`; - const serviceTypeName = `T${capitalizedComponentName}ServiceFactory`; - const serviceName = `${componentName}ServiceFactory`; + const pascalCase = componentName + .split("-") + .map((el) => `${el[0].toUpperCase()}${el.slice(1)}`) + .join(""); + const camelCase = componentName + .split("-") + .map((el, index) => (index === 0 ? el : `${el[0].toUpperCase()}${el.slice(1)}`)) + .join(""); + const dalTypeName = `T${pascalCase}DalFactory`; + const dalName = `${camelCase}DalFactory`; + const serviceTypeName = `T${pascalCase}ServiceFactory`; + const serviceName = `${camelCase}ServiceFactory`; mkdirSync(dir); @@ -29,9 +36,9 @@ if (componentType === 1) { `import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; -export type ${dalTypeName} = {}; +export type ${dalTypeName} = ReturnType; -export const ${dalName} = (db: TDbClient): ${dalTypeName} => { +export const ${dalName} = (db: TDbClient) => { return { }; }; @@ -43,12 +50,12 @@ export const ${dalName} = (db: TDbClient): ${dalTypeName} => { `import { ${dalTypeName} } from "./${componentName}-dal"; type ${serviceTypeName}Dep = { - ${componentName}Dal: ${dalTypeName}; + ${camelCase}Dal: ${dalTypeName}; }; export type ${serviceTypeName} = ReturnType; -export const ${serviceName} = ({ ${componentName}Dal }: ${serviceTypeName}Dep) => { +export const ${serviceName} = ({ ${camelCase}Dal }: ${serviceTypeName}Dep) => { return {}; }; ` diff --git a/backend-pg/scripts/generate-schema-types.ts b/backend-pg/scripts/generate-schema-types.ts index 5c279f7f3..64bcbec6e 100644 --- a/backend-pg/scripts/generate-schema-types.ts +++ b/backend-pg/scripts/generate-schema-types.ts @@ -3,6 +3,7 @@ import path from "path"; import knex from "knex"; import { appendFileSync, readFileSync, writeFileSync } from "fs"; import promptSync from "prompt-sync"; +import { TableName } from "@app/db/schemas"; const prompt = promptSync(); @@ -27,11 +28,11 @@ const getZodPrimitiveType = (type: string) => { case "boolean": return "z.boolean()"; case "jsonb": - return "z.string()"; + return "z.unknown()"; case "json": - return "z.string()"; + return "z.unknown()"; case "timestamp with time zone": - return "z.string().datetime()"; + return "z.date()"; case "integer": return "z.number()"; case "text": diff --git a/backend-pg/src/@types/fastify.d.ts b/backend-pg/src/@types/fastify.d.ts index 01833e08f..5788c8023 100644 --- a/backend-pg/src/@types/fastify.d.ts +++ b/backend-pg/src/@types/fastify.d.ts @@ -1,10 +1,16 @@ -import { TUser } from "@app/db/schemas"; -import { TAuthDalFactory } from "@app/services/auth/auth-dal"; +import { TUsers } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { TApiKeyServiceFactory } from "@app/services/api-key/api-key-service"; import { TAuthLoginFactory } from "@app/services/auth/auth-login-service"; import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service"; import { TAuthSignupFactory } from "@app/services/auth/auth-signup-service"; import { AuthMode } from "@app/services/auth/auth-signup-type"; +import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service"; +import { TOrgServiceFactory } from "@app/services/org/org-service"; +import { TServerCfgServiceFactory } from "@app/services/server-cfg/server-cfg-service"; import { TAuthTokenServiceFactory } from "@app/services/token/token-service"; +import { TUserDalFactory } from "@app/services/user/user-dal"; +import { TUserServiceFactory } from "@app/services/user/user-service"; import "fastify"; @@ -14,13 +20,14 @@ declare module "fastify" { // used for mfa session authentication mfa: { userId: string; - user: TUser; + user: TUsers; }; // identity injection. depending on which kinda of token the information is filled in auth auth: { authMode: AuthMode.JWT | AuthMode.API_KEY_V2 | AuthMode.API_KEY; userId: string; - user: TUser; + tokenVersionId: string; // the session id of token used + user: TUsers; }; } @@ -30,12 +37,18 @@ declare module "fastify" { password: TAuthPasswordFactory; signup: TAuthSignupFactory; authToken: TAuthTokenServiceFactory; + permission: TPermissionServiceFactory; + org: TOrgServiceFactory; + orgRole: TOrgRoleServiceFactory; + serverCfg: TServerCfgServiceFactory; + user: TUserServiceFactory; + apiKey: TApiKeyServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer store: { - user: Pick; + user: Pick; }; } } diff --git a/backend-pg/src/@types/knex.d.ts b/backend-pg/src/@types/knex.d.ts index 09e8935f5..7f9ef07f8 100644 --- a/backend-pg/src/@types/knex.d.ts +++ b/backend-pg/src/@types/knex.d.ts @@ -10,10 +10,24 @@ import { TBackupPrivateKey, TBackupPrivateKeyInsert, TBackupPrivateKeyUpdate, - TOrganizationMemberships, + TIncidentContacts, + TIncidentContactsInsert, + TIncidentContactsUpdate, TOrganizations, TOrganizationsInsert, TOrganizationsUpdate, + TOrgMemberships, + TOrgMembershipsInsert, + TOrgMembershipsUpdate, + TOrgRoles, + TOrgRolesInsert, + TOrgRolesUpdate, + TServerConfig, + TServerConfigInsert, + TServerConfigUpdate, + TUserActions, + TUserActionsInsert, + TUserActionsUpdate, TUserEncryptionKeys, TUserEncryptionKeysInsert, TUserEncryptionKeysUpdate, @@ -21,6 +35,7 @@ import { TUsersInsert, TUsersUpdate } from "@app/db/schemas"; +import { TApiKeys, TApiKeysInsert, TApiKeysUpdate } from "@app/db/schemas/api-keys"; declare module "knex/types/tables" { interface Tables extends { [key in TableName]: Knex.CompositeTableType } { @@ -51,9 +66,26 @@ declare module "knex/types/tables" { TOrganizationsUpdate >; [TableName.OrgMembership]: Knex.CompositeTableType< - TOrganizationMemberships, - TOrganizationsInsert, - TOrganizationsUpdate + TOrgMemberships, + TOrgMembershipsInsert, + TOrgMembershipsUpdate >; + [TableName.OrgRoles]: Knex.CompositeTableType; + [TableName.IncidentContact]: Knex.CompositeTableType< + TIncidentContacts, + TIncidentContactsInsert, + TIncidentContactsUpdate + >; + [TableName.UserAction]: Knex.CompositeTableType< + TUserActions, + TUserActionsInsert, + TUserActionsUpdate + >; + [TableName.ServerConfig]: Knex.CompositeTableType< + TServerConfig, + TServerConfigInsert, + TServerConfigUpdate + >; + [TableName.ApiKey]: Knex.CompositeTableType; } } diff --git a/backend-pg/src/db/migrations/20231128092347_user-encryption-key.ts b/backend-pg/src/db/migrations/20231128092347_user-encryption-key.ts index 153aeb077..5cf251865 100644 --- a/backend-pg/src/db/migrations/20231128092347_user-encryption-key.ts +++ b/backend-pg/src/db/migrations/20231128092347_user-encryption-key.ts @@ -9,7 +9,7 @@ export async function up(knex: Knex): Promise { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.text("clientPublicKey"); t.text("serverPrivateKey"); - t.integer("encryptionVersion").defaultTo(1); + t.integer("encryptionVersion").defaultTo(2); t.text("protectedKey").notNullable(); t.text("protectedKeyIV").notNullable(); t.text("protectedKeyTag").notNullable(); diff --git a/backend-pg/src/db/migrations/20231204092737_organization.ts b/backend-pg/src/db/migrations/20231204092737_organization.ts index 45badacb9..cc03babb2 100644 --- a/backend-pg/src/db/migrations/20231204092737_organization.ts +++ b/backend-pg/src/db/migrations/20231204092737_organization.ts @@ -13,12 +13,21 @@ export async function up(knex: Knex): Promise { // does not need update trigger we will do it manually t.timestamps(true, true, true); }); + await knex.schema.alterTable(TableName.AuthTokens, (t) => { + t.uuid("orgId"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + }); } // this is a one time function await createOnUpdateTrigger(knex, TableName.Organization); } export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.AuthTokens, "orgId")) { + await knex.schema.alterTable(TableName.AuthTokens, (t) => { + t.dropColumn("orgId"); + }); + } await knex.schema.dropTableIfExists(TableName.Organization); await dropOnUpdateTrigger(knex, TableName.Organization); } diff --git a/backend-pg/src/db/migrations/20231204092747_org-membership.ts b/backend-pg/src/db/migrations/20231204092747_org-membership.ts index 20e6e2fef..895ec088b 100644 --- a/backend-pg/src/db/migrations/20231204092747_org-membership.ts +++ b/backend-pg/src/db/migrations/20231204092747_org-membership.ts @@ -5,8 +5,23 @@ import { OrgMembershipStatus } from "../schemas/models"; import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; export async function up(knex: Knex): Promise { - const isTablePresent = await knex.schema.hasTable(TableName.OrgMembership); - if (!isTablePresent) { + const isOrgRolePresent = await knex.schema.hasTable(TableName.OrgRoles); + if (!isOrgRolePresent) { + await knex.schema.createTable(TableName.OrgRoles, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.string("description"); + t.string("slug").notNullable(); + t.json("permissions").notNullable(); + // does not need update trigger we will do it manually + t.timestamps(true, true, true); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + }); + } + + const isOrgTablePresent = await knex.schema.hasTable(TableName.OrgMembership); + if (!isOrgTablePresent) { await knex.schema.createTable(TableName.OrgMembership, (t) => { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.string("role").notNullable(); @@ -14,10 +29,13 @@ export async function up(knex: Knex): Promise { t.string("inviteEmail"); // does not need update trigger we will do it manually t.timestamps(true, true, true); - t.uuid("userId").notNullable(); + t.uuid("userId"); t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); t.uuid("orgId").notNullable(); t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + // until role is changed/removed the role should not deleted + t.uuid("roleId"); + t.foreign("roleId").references("id").inTable(TableName.OrgRoles); }); } // this is a one time function @@ -26,5 +44,6 @@ export async function up(knex: Knex): Promise { export async function down(knex: Knex): Promise { await knex.schema.dropTableIfExists(TableName.OrgMembership); + await knex.schema.dropTableIfExists(TableName.OrgRoles); await dropOnUpdateTrigger(knex, TableName.OrgMembership); } diff --git a/backend-pg/src/db/migrations/20231205151331_incident-contact.ts b/backend-pg/src/db/migrations/20231205151331_incident-contact.ts new file mode 100644 index 000000000..a0c1a3c27 --- /dev/null +++ b/backend-pg/src/db/migrations/20231205151331_incident-contact.ts @@ -0,0 +1,25 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + const isTablePresent = await knex.schema.hasTable(TableName.IncidentContact); + if (!isTablePresent) { + await knex.schema.createTable(TableName.IncidentContact, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("email").notNullable(); + // does not need update trigger we will do it manually + t.timestamps(true, true, true); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + }); + } + // this is a one time function + await createOnUpdateTrigger(knex, TableName.IncidentContact); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IncidentContact); + await dropOnUpdateTrigger(knex, TableName.IncidentContact); +} diff --git a/backend-pg/src/db/migrations/20231207055643_user-action.ts b/backend-pg/src/db/migrations/20231207055643_user-action.ts new file mode 100644 index 000000000..c3b54839c --- /dev/null +++ b/backend-pg/src/db/migrations/20231207055643_user-action.ts @@ -0,0 +1,20 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const isTablePresent = await knex.schema.hasTable(TableName.UserAction); + if (!isTablePresent) { + await knex.schema.createTable(TableName.UserAction, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("action").notNullable(); + t.timestamps(true, true, true); + t.uuid("userId").notNullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + }); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.UserAction); +} diff --git a/backend-pg/src/db/migrations/20231207055701_server-config.ts b/backend-pg/src/db/migrations/20231207055701_server-config.ts new file mode 100644 index 000000000..853981e1c --- /dev/null +++ b/backend-pg/src/db/migrations/20231207055701_server-config.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + const isTablePresent = await knex.schema.hasTable(TableName.ServerConfig); + if (!isTablePresent) { + await knex.schema.createTable(TableName.ServerConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.boolean("initialized").defaultTo(false); + t.boolean("allowSignUp").defaultTo(true); + t.timestamps(true, true, true); + }); + } + // this is a one time function + await createOnUpdateTrigger(knex, TableName.ServerConfig); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.ServerConfig); + await dropOnUpdateTrigger(knex, TableName.ServerConfig); +} diff --git a/backend-pg/src/db/migrations/20231207105059_api-key.ts b/backend-pg/src/db/migrations/20231207105059_api-key.ts new file mode 100644 index 000000000..4758973da --- /dev/null +++ b/backend-pg/src/db/migrations/20231207105059_api-key.ts @@ -0,0 +1,26 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + const isTablePresent = await knex.schema.hasTable(TableName.ApiKey); + if (!isTablePresent) { + await knex.schema.createTable(TableName.ApiKey, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.datetime("lastUsed"); + t.datetime("expiresAt"); + t.string("secretHash").notNullable(); + t.timestamps(true, true, true); + t.uuid("userId").notNullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + }); + } + await createOnUpdateTrigger(knex, TableName.ApiKey); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.ApiKey); + await dropOnUpdateTrigger(knex, TableName.ApiKey); +} diff --git a/backend-pg/src/db/schemas/api-keys.ts b/backend-pg/src/db/schemas/api-keys.ts new file mode 100644 index 000000000..32e5ef6c8 --- /dev/null +++ b/backend-pg/src/db/schemas/api-keys.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ApiKeysSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + lastUsed: z.date().nullable().optional(), + expiresAt: z.date().nullable().optional(), + secretHash: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + userId: z.string().uuid(), +}); + +export type TApiKeys = z.infer; +export type TApiKeysInsert = Omit; +export type TApiKeysUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/auth-token-sessions.ts b/backend-pg/src/db/schemas/auth-token-sessions.ts index dd4fd9416..9dbf8e09f 100644 --- a/backend-pg/src/db/schemas/auth-token-sessions.ts +++ b/backend-pg/src/db/schemas/auth-token-sessions.ts @@ -13,9 +13,9 @@ export const AuthTokenSessionsSchema = z.object({ userAgent: z.string().nullable().optional(), refreshVersion: z.number().default(1), accessVersion: z.number().default(1), - lastUsed: z.string().datetime(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), + lastUsed: z.date(), + createdAt: z.date(), + updatedAt: z.date(), userId: z.string().uuid(), }); diff --git a/backend-pg/src/db/schemas/auth-tokens.ts b/backend-pg/src/db/schemas/auth-tokens.ts index a47146170..4a612b11d 100644 --- a/backend-pg/src/db/schemas/auth-tokens.ts +++ b/backend-pg/src/db/schemas/auth-tokens.ts @@ -13,10 +13,11 @@ export const AuthTokensSchema = z.object({ phoneNumber: z.string().nullable().optional(), tokenHash: z.string(), triesLeft: z.number().nullable().optional(), - expiresAt: z.string().datetime(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), + expiresAt: z.date(), + createdAt: z.date(), + updatedAt: z.date(), userId: z.string().uuid().nullable().optional(), + orgId: z.string().uuid().nullable().optional(), }); export type TAuthTokens = z.infer; diff --git a/backend-pg/src/db/schemas/backup-private-key.ts b/backend-pg/src/db/schemas/backup-private-key.ts index bb2b8a048..9b6e787b1 100644 --- a/backend-pg/src/db/schemas/backup-private-key.ts +++ b/backend-pg/src/db/schemas/backup-private-key.ts @@ -16,8 +16,8 @@ export const BackupPrivateKeySchema = z.object({ keyEncoding: z.string(), salt: z.string(), verifier: z.string(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), + createdAt: z.date(), + updatedAt: z.date(), userId: z.string().uuid(), }); diff --git a/backend-pg/src/db/schemas/incident-contacts.ts b/backend-pg/src/db/schemas/incident-contacts.ts new file mode 100644 index 000000000..c1492e0fa --- /dev/null +++ b/backend-pg/src/db/schemas/incident-contacts.ts @@ -0,0 +1,20 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const IncidentContactsSchema = z.object({ + id: z.string().uuid(), + email: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + orgId: z.string().uuid(), +}); + +export type TIncidentContacts = z.infer; +export type TIncidentContactsInsert = Omit; +export type TIncidentContactsUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/index.ts b/backend-pg/src/db/schemas/index.ts index b63b35e14..27d04c172 100644 --- a/backend-pg/src/db/schemas/index.ts +++ b/backend-pg/src/db/schemas/index.ts @@ -1,8 +1,12 @@ export * from "./auth-token-sessions"; export * from "./auth-tokens"; export * from "./backup-private-key"; +export * from "./incident-contacts"; export * from "./models"; -export * from "./organization-memberships"; +export * from "./org-memberships"; +export * from "./org-roles"; export * from "./organizations"; +export * from "./server-config"; +export * from "./user-actions"; export * from "./user-encryption-keys"; export * from "./users"; diff --git a/backend-pg/src/db/schemas/models.ts b/backend-pg/src/db/schemas/models.ts index 9be605cd2..e12eabdc1 100644 --- a/backend-pg/src/db/schemas/models.ts +++ b/backend-pg/src/db/schemas/models.ts @@ -7,7 +7,12 @@ export enum TableName { AuthTokenSession = "auth_token_sessions", BackupPrivateKey = "backup_private_key", Organization = "organizations", - OrgMembership = "organization_memberships" + OrgMembership = "org_memberships", + OrgRoles = "org_roles", + IncidentContact = "incident_contacts", + UserAction = "user_actions", + ServerConfig = "server_config", + ApiKey = "api_keys" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; diff --git a/backend-pg/src/db/schemas/org-memberships.ts b/backend-pg/src/db/schemas/org-memberships.ts new file mode 100644 index 000000000..932c84d00 --- /dev/null +++ b/backend-pg/src/db/schemas/org-memberships.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const OrgMembershipsSchema = z.object({ + id: z.string().uuid(), + role: z.string(), + status: z.string().default('invited'), + inviteEmail: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date(), + userId: z.string().uuid().nullable().optional(), + orgId: z.string().uuid(), + roleId: z.string().uuid().nullable().optional(), +}); + +export type TOrgMemberships = z.infer; +export type TOrgMembershipsInsert = Omit; +export type TOrgMembershipsUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/org-roles.ts b/backend-pg/src/db/schemas/org-roles.ts new file mode 100644 index 000000000..9718cdb26 --- /dev/null +++ b/backend-pg/src/db/schemas/org-roles.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const OrgRolesSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable().optional(), + slug: z.string(), + permissions: z.unknown(), + createdAt: z.date(), + updatedAt: z.date(), + orgId: z.string().uuid(), +}); + +export type TOrgRoles = z.infer; +export type TOrgRolesInsert = Omit; +export type TOrgRolesUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/organization-memberships.ts b/backend-pg/src/db/schemas/organization-memberships.ts deleted file mode 100644 index 97cf01dbd..000000000 --- a/backend-pg/src/db/schemas/organization-memberships.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Code generated by automation script, DO NOT EDIT. -// Automated by pulling database and generating zod schema -// To update. Just run npm run generate:schema -// Written by akhilmhdh. - -import { z } from "zod"; - -import { TImmutableDBKeys } from "./models"; - -export const OrganizationMembershipsSchema = z.object({ - id: z.string().uuid(), - role: z.string(), - status: z.string().default('invited'), - inviteEmail: z.string().nullable().optional(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), - userId: z.string().uuid(), - orgId: z.string().uuid(), -}); - -export type TOrganizationMemberships = z.infer; -export type TOrganizationMembershipsInsert = Omit; -export type TOrganizationMembershipsUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/organizations.ts b/backend-pg/src/db/schemas/organizations.ts index a79b7c57b..e9ff4555f 100644 --- a/backend-pg/src/db/schemas/organizations.ts +++ b/backend-pg/src/db/schemas/organizations.ts @@ -11,8 +11,8 @@ export const OrganizationsSchema = z.object({ id: z.string().uuid(), name: z.string(), customerId: z.string().nullable().optional(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), + createdAt: z.date(), + updatedAt: z.date(), }); export type TOrganizations = z.infer; diff --git a/backend-pg/src/db/schemas/server-config.ts b/backend-pg/src/db/schemas/server-config.ts new file mode 100644 index 000000000..0b777e1bc --- /dev/null +++ b/backend-pg/src/db/schemas/server-config.ts @@ -0,0 +1,20 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ServerConfigSchema = z.object({ + id: z.string().uuid(), + initialized: z.boolean().default(false).nullable().optional(), + allowSignUp: z.boolean().default(true).nullable().optional(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +export type TServerConfig = z.infer; +export type TServerConfigInsert = Omit; +export type TServerConfigUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/user-actions.ts b/backend-pg/src/db/schemas/user-actions.ts new file mode 100644 index 000000000..a3a07d699 --- /dev/null +++ b/backend-pg/src/db/schemas/user-actions.ts @@ -0,0 +1,20 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const UserActionsSchema = z.object({ + id: z.string().uuid(), + action: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + userId: z.string().uuid(), +}); + +export type TUserActions = z.infer; +export type TUserActionsInsert = Omit; +export type TUserActionsUpdate = Partial>; diff --git a/backend-pg/src/db/schemas/users.ts b/backend-pg/src/db/schemas/users.ts index 7e12344e1..b9689883d 100644 --- a/backend-pg/src/db/schemas/users.ts +++ b/backend-pg/src/db/schemas/users.ts @@ -17,9 +17,9 @@ export const UsersSchema = z.object({ isAccepted: z.boolean().default(false).nullable().optional(), isMfaEnabled: z.boolean().default(false).nullable().optional(), mfaMethods: z.string().array().nullable().optional(), - devices: z.string().nullable().optional(), - createdAt: z.string().datetime(), - updatedAt: z.string().datetime(), + devices: z.unknown().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date(), }); export type TUsers = z.infer; diff --git a/backend-pg/src/ee/LICENSE b/backend-pg/src/ee/LICENSE new file mode 100644 index 000000000..a1c37bb93 --- /dev/null +++ b/backend-pg/src/ee/LICENSE @@ -0,0 +1,36 @@ +The Infisical Enterprise license (the “Enterprise License”) +Copyright (c) 2022 Infisical Inc + +With regard to the Infisical Software: + +This software and associated documentation files (the "Software") may only be +used in production, if you (and any entity that you represent) have agreed to, +and are in compliance with, the Infisical Subscription Terms of Service, available +at https://infisical.com/terms (the “Enterprise Terms”), or other +agreement governing the use of the Software, as agreed by you and Infisical, +and otherwise have a valid Infisical Enterprise License for the +correct number of user seats. Subject to the foregoing sentence, you are free to +modify this Software and publish patches to the Software. You agree that Infisical +and/or its licensors (as applicable) retain all right, title and interest in and +to all such modifications and/or patches, and all such modifications and/or +patches may only be used, copied, modified, displayed, distributed, or otherwise +exploited with a valid Infiscial Enterprise subscription for the correct +number of user seats. Notwithstanding the foregoing, you may copy and modify +the Software for development and testing purposes, without requiring a +subscription. You agree that Infisical and/or its licensors (as applicable) retain +all right, title and interest in and to all such modifications. You are not +granted any other rights beyond what is expressly stated herein. Subject to the +foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, +and/or sell the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +For all third party components incorporated into the Infisical Software, those +components are licensed under the original license provided by the owner of the +applicable component. diff --git a/backend-pg/src/ee/routes/v1/index.ts b/backend-pg/src/ee/routes/v1/index.ts new file mode 100644 index 000000000..a3b4c1554 --- /dev/null +++ b/backend-pg/src/ee/routes/v1/index.ts @@ -0,0 +1,6 @@ +import { registerOrgRoleRouter } from "./org-role"; + +export const registerV1EERoutes = async (server: FastifyZodProvider) => { + // org role starts with organization + await server.register(registerOrgRoleRouter, { prefix: "/organization" }); +}; diff --git a/backend-pg/src/ee/routes/v1/org-role.ts b/backend-pg/src/ee/routes/v1/org-role.ts new file mode 100644 index 000000000..27fe6d21f --- /dev/null +++ b/backend-pg/src/ee/routes/v1/org-role.ts @@ -0,0 +1,150 @@ +import { z } from "zod"; + +import { OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/:organizationId/roles", + schema: { + params: z.object({ + organizationId: z.string().trim() + }), + body: z.object({ + slug: z.string().trim(), + name: z.string().trim(), + description: z.string().trim().optional(), + workspaceId: z.string().trim().optional(), + orgId: z.string().trim(), + permissions: z.any().array() + }), + response: { + 200: z.object({ + role: OrgRolesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const role = await server.services.orgRole.createRole( + req.auth.userId, + req.params.organizationId, + { ...req.body, permissions: JSON.stringify(req.body.permissions) } + ); + return { role }; + } + }); + + server.route({ + method: "PATCH", + url: "/:organizationId/roles/:roleId", + schema: { + params: z.object({ + organizationId: z.string().trim(), + roleId: z.string().trim() + }), + body: z.object({ + slug: z.string().trim().optional(), + name: z.string().trim().optional(), + description: z.string().trim().optional(), + workspaceId: z.string().trim().optional(), + orgId: z.string().trim(), + permissions: z.any().array() + }), + response: { + 200: z.object({ + role: OrgRolesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const role = await server.services.orgRole.updateRole( + req.auth.userId, + req.params.organizationId, + req.params.roleId, + { + ...req.body, + permissions: req.body.permissions ? JSON.stringify(req.body.permissions) : undefined + } + ); + return { role }; + } + }); + + server.route({ + method: "DELETE", + url: "/:organizationId/roles/:roleId", + schema: { + params: z.object({ + organizationId: z.string().trim(), + roleId: z.string().trim() + }), + response: { + 200: z.object({ + role: OrgRolesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const role = await server.services.orgRole.deleteRole( + req.auth.userId, + req.params.organizationId, + req.params.roleId + ); + return { role }; + } + }); + + server.route({ + method: "GET", + url: "/:organizationId/roles", + schema: { + params: z.object({ + organizationId: z.string().trim() + }), + response: { + 200: z.object({ + roles: OrgRolesSchema.omit({ permissions: true }) + .merge(z.object({ permissions: z.unknown() })) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const roles = await server.services.orgRole.listRoles( + req.auth.userId, + req.params.organizationId + ); + return { roles }; + } + }); + + server.route({ + method: "GET", + url: "/:organizationId/permissions", + schema: { + params: z.object({ + organizationId: z.string().trim() + }), + response: { + 200: z.object({ + membership: OrgMembershipsSchema, + permissions: z.any().array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { permissions, membership } = await server.services.orgRole.getUserPermission( + req.auth.userId, + req.params.organizationId + ); + return { permissions, membership }; + } + }); +}; diff --git a/backend-pg/src/ee/services/permission/org-permission.ts b/backend-pg/src/ee/services/permission/org-permission.ts new file mode 100644 index 000000000..cfe83afe7 --- /dev/null +++ b/backend-pg/src/ee/services/permission/org-permission.ts @@ -0,0 +1,124 @@ +import picomatch from "picomatch"; + +import { + AbilityBuilder, + buildMongoQueryMatcher, + createMongoAbility, + MongoAbility +} from "@casl/ability"; +import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js"; + +const $glob: FieldInstruction = { + type: "field", + validate(instruction, value) { + if (typeof value !== "string") { + throw new Error(`"${instruction.name}" expects value to be a string`); + } + } +}; + +const glob: JsInterpreter> = (node, object, context) => { + const secretPath = context.get(object, node.field); + const permissionSecretGlobPath = node.value; + return picomatch.isMatch(secretPath, permissionSecretGlobPath, { strictSlashes: false }); +}; + +export const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob }); + +export enum OrgPermissionActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete" +} + +export enum OrgPermissionSubjects { + Workspace = "workspace", + Role = "role", + Member = "member", + Settings = "settings", + IncidentAccount = "incident-contact", + Sso = "sso", + Billing = "billing", + SecretScanning = "secret-scanning" +} + +export type OrgPermissionSet = + | [OrgPermissionActions.Read, OrgPermissionSubjects.Workspace] + | [OrgPermissionActions.Create, OrgPermissionSubjects.Workspace] + | [OrgPermissionActions, OrgPermissionSubjects.Role] + | [OrgPermissionActions, OrgPermissionSubjects.Member] + | [OrgPermissionActions, OrgPermissionSubjects.Settings] + | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] + | [OrgPermissionActions, OrgPermissionSubjects.Sso] + | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] + | [OrgPermissionActions, OrgPermissionSubjects.Billing]; + +const buildAdminPermission = () => { + const { can, build } = new AbilityBuilder>(createMongoAbility); + // ws permissions + can(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); + // role permission + can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Role); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Role); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Role); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Member); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Member); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Member); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Create, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.SecretScanning); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); + can(OrgPermissionActions.Create, OrgPermissionSubjects.IncidentAccount); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.IncidentAccount); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.IncidentAccount); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Sso); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Sso); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.Billing); + + return build({ conditionsMatcher }); +}; + +export const orgAdminPermissions = buildAdminPermission(); + +const buildMemberPermission = () => { + const { can, build } = new AbilityBuilder>(createMongoAbility); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Member); + can(OrgPermissionActions.Create, OrgPermissionSubjects.Member); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Role); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Sso); + can(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount); + + can(OrgPermissionActions.Read, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Create, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.SecretScanning); + + return build({ conditionsMatcher }); +}; + +export const orgMemberPermissions = buildMemberPermission(); diff --git a/backend-pg/src/ee/services/permission/permission-dal.ts b/backend-pg/src/ee/services/permission/permission-dal.ts new file mode 100644 index 000000000..4b4731bc3 --- /dev/null +++ b/backend-pg/src/ee/services/permission/permission-dal.ts @@ -0,0 +1,23 @@ +import { TDbClient } from "@app/db"; +import { TableName, TOrgMemberships } from "@app/db/schemas"; + +export type TPermissionDalFactory = ReturnType; + +export const permissionDalFactory = (db: TDbClient) => { + const getOrgPermission = async ( + userId: string, + orgId: string + ): Promise<(TOrgMemberships & { permissions: string }) | undefined> => { + const membership = await db(TableName.OrgMembership) + .leftJoin(TableName.OrgRoles, `${TableName.OrgMembership}.roleId`, `${TableName.OrgRoles}.id`) + .select(`${TableName.OrgMembership}.*`, `${TableName.OrgRoles}.permissions`) + .where({ userId, [`${TableName.OrgMembership}.orgId`]: orgId }) + .first(); + + return membership; + }; + + return { + getOrgPermission + }; +}; diff --git a/backend-pg/src/ee/services/permission/permission-service.ts b/backend-pg/src/ee/services/permission/permission-service.ts new file mode 100644 index 000000000..f88b91152 --- /dev/null +++ b/backend-pg/src/ee/services/permission/permission-service.ts @@ -0,0 +1,50 @@ +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; + +import { + conditionsMatcher, + orgAdminPermissions, + orgMemberPermissions, + OrgPermissionSet +} from "./org-permission"; +import { TPermissionDalFactory } from "./permission-dal"; + +import { createMongoAbility, MongoAbility, RawRuleOf } from "@casl/ability"; +import { unpackRules } from "@casl/ability/extra"; + +type TPermissionServiceFactoryDep = { + permissionDal: TPermissionDalFactory; +}; + +export type TPermissionServiceFactory = ReturnType; + +export const permissionServiceFactory = ({ permissionDal }: TPermissionServiceFactoryDep) => { + /* + * Get user permission in an organization + * */ + const getUserOrgPermission = async (userId: string, orgId: string) => { + const membership = await permissionDal.getOrgPermission(userId, orgId); + if (!membership) throw new UnauthorizedError({ name: "User not in org" }); + if (membership.role === "custom" && !membership.permissions) { + throw new BadRequestError({ name: "Custom permission not found" }); + } + + if (membership.role === "admin") return { permission: orgAdminPermissions, membership }; + if (membership.role === "member") return { permission: orgMemberPermissions, membership }; + if (membership.role === "custom") { + const permission = createMongoAbility( + // akhilmhdh: putting any due to ts incompatiable matching with string and the other + unpackRules>>(membership.permissions as any), + { + conditionsMatcher + } + ); + return { permission, membership }; + } + + throw new BadRequestError({ name: "Role missing", message: "User role not found" }); + }; + + return { + getUserOrgPermission + }; +}; diff --git a/backend-pg/src/ee/services/permission/permission-types.ts b/backend-pg/src/ee/services/permission/permission-types.ts new file mode 100644 index 000000000..e69de29bb diff --git a/backend-pg/src/lib/config/env.ts b/backend-pg/src/lib/config/env.ts index ed03e6850..12951232c 100644 --- a/backend-pg/src/lib/config/env.ts +++ b/backend-pg/src/lib/config/env.ts @@ -8,33 +8,35 @@ const zodStrBool = z .optional() .transform((val) => val === "true"); -const envSchema = z.object({ - PORT: z.coerce.number().default(4000), - HOST: zpStr(z.string().default("localhost")), - DB_CONNECTION_URI: zpStr(z.string().describe("Postgres database conntection string")), - NODE_ENV: z.enum(["development", "test", "production"]).default("development"), - SALT_ROUNDS: z.coerce.number().default(10), - // TODO(akhilmhdh): will be changed to one - ENCRYPTION_KEY: zpStr(z.string().optional()), - ROOT_ENCRYPTION_KEY: zpStr(z.string().optional()), - HTTPS_ENABLED: zodStrBool, - // smtp options - SMTP_HOST: zpStr(z.string().optional()), - SMTP_SECURE: zodStrBool, - SMTP_PORT: z.coerce.number().default(587), - SMTP_USERNAME: zpStr(z.string().optional()), - SMTP_PASSWORD: zpStr(z.string().optional()), - SMTP_FROM_ADDRESS: zpStr(z.string().optional()), - SMTP_FROM_NAME: zpStr(z.string().optional().default("Infisical")), - COOKIE_SECRET_SIGN_KEY: z.string().default("g5giLbOMpaJhqEogXApkiw2ZFW5Q0jvA"), - SITE_URL: zpStr(z.string().optional()), - // jwt options - JWT_AUTH_SECRET: zpStr(z.string()), - JWT_AUTH_LIFETIME: zpStr(z.string().default("10d")), - JWT_SIGNUP_LIFETIME: zpStr(z.string().default("15m")), - JWT_REFRESH_LIFETIME: zpStr(z.string().default("90d")), - JWT_MFA_LIFETIME: zpStr(z.string().default("5m")) -}); +const envSchema = z + .object({ + PORT: z.coerce.number().default(4000), + HOST: zpStr(z.string().default("localhost")), + DB_CONNECTION_URI: zpStr(z.string().describe("Postgres database conntection string")), + NODE_ENV: z.enum(["development", "test", "production"]).default("development"), + SALT_ROUNDS: z.coerce.number().default(10), + // TODO(akhilmhdh): will be changed to one + ENCRYPTION_KEY: zpStr(z.string().optional()), + ROOT_ENCRYPTION_KEY: zpStr(z.string().optional()), + HTTPS_ENABLED: zodStrBool, + // smtp options + SMTP_HOST: zpStr(z.string().optional()), + SMTP_SECURE: zodStrBool, + SMTP_PORT: z.coerce.number().default(587), + SMTP_USERNAME: zpStr(z.string().optional()), + SMTP_PASSWORD: zpStr(z.string().optional()), + SMTP_FROM_ADDRESS: zpStr(z.string().optional()), + SMTP_FROM_NAME: zpStr(z.string().optional().default("Infisical")), + COOKIE_SECRET_SIGN_KEY: z.string().default("g5giLbOMpaJhqEogXApkiw2ZFW5Q0jvA"), + SITE_URL: zpStr(z.string().optional()), + // jwt options + JWT_AUTH_SECRET: zpStr(z.string()), + JWT_AUTH_LIFETIME: zpStr(z.string().default("10d")), + JWT_SIGNUP_LIFETIME: zpStr(z.string().default("15m")), + JWT_REFRESH_LIFETIME: zpStr(z.string().default("90d")), + JWT_MFA_LIFETIME: zpStr(z.string().default("5m")) + }) + .transform((data) => ({ ...data, isSmtpConfigured: Boolean(data.SMTP_HOST) })); let envCfg: Readonly>; diff --git a/backend-pg/src/lib/errors/index.ts b/backend-pg/src/lib/errors/index.ts index 2786194e9..b3a1e689d 100644 --- a/backend-pg/src/lib/errors/index.ts +++ b/backend-pg/src/lib/errors/index.ts @@ -28,7 +28,7 @@ export class BadRequestError extends Error { error: unknown; - constructor({ name, error, message }: { message?: string; name: string; error: unknown }) { + constructor({ name, error, message }: { message?: string; name: string; error?: unknown }) { super(message ?? "The request is invalid"); this.name = name; this.error = error; diff --git a/backend-pg/src/lib/knex/index.ts b/backend-pg/src/lib/knex/index.ts index d480f7301..7b75d8fb8 100644 --- a/backend-pg/src/lib/knex/index.ts +++ b/backend-pg/src/lib/knex/index.ts @@ -1,10 +1,108 @@ import { Knex } from "knex"; +import { Tables } from "knex/types/tables"; + +import { TableName } from "@app/db/schemas"; + +import { DatabaseError } from "../errors"; export const withTransaction = (db: Knex, dal: K) => ({ - transaction: async (cb: (tx: Knex) => T) => + transaction: async (cb: (tx: Knex) => Promise) => db.transaction(async (trx) => { const res = await cb(trx); return res; }), ...dal }); + +// What is ormify +// It is to inject typical operations like find, findOne, update, delete, create +// This will avoid writing most common ones each time +export const ormify = ( + db: Knex, + tableName: Tname, + dal?: DbOps +) => ({ + transaction: async (cb: (tx: Knex) => Promise) => + db.transaction(async (trx) => { + const res = await cb(trx); + return res; + }), + findById: (id: string, tx?: Knex) => { + try { + return (tx || db)(tableName) + .where({ id } as any) + .first("*"); + } catch (error) { + throw new DatabaseError({ error, name: "Find by id" }); + } + }, + findOne: async (filter: Partial, tx?: Knex) => { + try { + const res = await (tx || db)(tableName).where(filter).first("*"); + return res; + } catch (error) { + throw new DatabaseError({ error, name: "Find one" }); + } + }, + find: (filter: Partial, tx?: Knex) => { + try { + return (tx || db)(tableName).where(filter); + } catch (error) { + throw new DatabaseError({ error, name: "Find one" }); + } + }, + create: async (data: Tables[Tname]["insert"], tx?: Knex) => { + try { + const [user] = await (tx || db)(tableName).insert(data).returning("*"); + return user; + } catch (error) { + throw new DatabaseError({ error, name: "Create" }); + } + }, + updateById: async (id: string, data: Tables[Tname]["update"], tx?: Knex) => { + try { + const [user] = await (tx || db)(tableName) + .where({ id } as any) + .update(data as any) + .returning("*"); + return user; + } catch (error) { + throw new DatabaseError({ error, name: "Update by id" }); + } + }, + update: async ( + filter: Partial, + data: Tables[Tname]["update"], + tx?: Knex + ) => { + try { + const user = await (tx || db)(tableName) + .where(filter) + .update(data as any) + .returning("*"); + return user; + } catch (error) { + throw new DatabaseError({ error, name: "Update" }); + } + }, + deleteById: async (id: string, tx?: Knex) => { + try { + const [user] = await (tx || db)(tableName) + .where({ id } as any) + .delete() + .returning("*"); + return user; + } catch (error) { + throw new DatabaseError({ error, name: "Delete by id" }); + } + }, + delete: async (filter: Partial, tx?: Knex) => { + try { + const user = await (tx || db)(tableName).where(filter).delete().returning("*"); + return user; + } catch (error) { + throw new DatabaseError({ error, name: "Delete" }); + } + }, + ...(dal || {}) +}); diff --git a/backend-pg/src/server/plugins/auth/inject-identity.ts b/backend-pg/src/server/plugins/auth/inject-identity.ts index 6fcff596e..33a6a54d3 100644 --- a/backend-pg/src/server/plugins/auth/inject-identity.ts +++ b/backend-pg/src/server/plugins/auth/inject-identity.ts @@ -1,4 +1,5 @@ import { FastifyRequest } from "fastify"; +import fp from "fastify-plugin"; import jwt, { JwtPayload } from "jsonwebtoken"; import { getConfig } from "@app/lib/config/env"; @@ -27,7 +28,7 @@ const extractAuth = async (req: FastifyRequest, jwtSecret: string) => { case AuthMode.SERVICE_ACCESS_TOKEN: return { authMode: AuthMode.SERVICE_ACCESS_TOKEN, token: decodedToken } as const; default: - throw new UnauthorizedError({ name: "Invalid token type" }); + return { authMode: null, token: null } as const; } }; @@ -41,13 +42,13 @@ const getJwtIdentity = async (server: FastifyZodProvider, token: AuthModeJwtToke if (token.accessVersion !== session.accessVersion) throw new UnauthorizedError({ name: "Stale session" }); - const user = await server.store.user.getUserById(session.userId); + const user = await server.store.user.findById(session.userId); if (!user || !user.isAccepted) throw new UnauthorizedError({ name: "Token user not found" }); - return user; + return { user, tokenVersionId: token.tokenVersionId }; }; -export const injectIdentity = (server: FastifyZodProvider) => { +export const injectIdentity = fp(async (server: FastifyZodProvider) => { server.decorateRequest("auth", null); server.addHook("onRequest", async (req) => { const appCfg = getConfig(); @@ -56,8 +57,11 @@ export const injectIdentity = (server: FastifyZodProvider) => { // TODO(akhilmhdh-pg): fill in rest of auth mode logic switch (authMode) { case AuthMode.JWT: { - const user = await getJwtIdentity(server, token as AuthModeJwtTokenPayload); - req.auth = { authMode: AuthMode.JWT, user, userId: user.id }; + const { user, tokenVersionId } = await getJwtIdentity( + server, + token as AuthModeJwtTokenPayload + ); + req.auth = { authMode: AuthMode.JWT, user, userId: user.id, tokenVersionId }; break; } case AuthMode.SERVICE_TOKEN: @@ -72,4 +76,4 @@ export const injectIdentity = (server: FastifyZodProvider) => { throw new UnauthorizedError({ name: "Unknown token strategy" }); } }); -}; +}); diff --git a/backend-pg/src/server/plugins/auth/superAdmin.ts b/backend-pg/src/server/plugins/auth/superAdmin.ts new file mode 100644 index 000000000..86c18d242 --- /dev/null +++ b/backend-pg/src/server/plugins/auth/superAdmin.ts @@ -0,0 +1,11 @@ +import { FastifyRequest } from "fastify"; + +import { UnauthorizedError } from "@app/lib/errors"; + +export const verifySuperAdmin = async (req: T) => { + if (!req.auth.user.superAdmin) + throw new UnauthorizedError({ + name: "Unauthorized access", + message: "Requires superadmin access" + }); +}; diff --git a/backend-pg/src/server/plugins/auth/verify-auth.ts b/backend-pg/src/server/plugins/auth/verify-auth.ts index 90432e380..cfd856dbe 100644 --- a/backend-pg/src/server/plugins/auth/verify-auth.ts +++ b/backend-pg/src/server/plugins/auth/verify-auth.ts @@ -5,7 +5,7 @@ import { AuthMode } from "@app/services/auth/auth-type"; export const verifyAuth = (authStrats: AuthMode[]) => - (req: T) => { + async (req: T) => { if (!Array.isArray(authStrats)) throw new Error("Auth strategy must be array"); if (!req.auth) throw new UnauthorizedError({ name: "Unauthorized access", message: "Token missing" }); diff --git a/backend-pg/src/server/routes/index.ts b/backend-pg/src/server/routes/index.ts index 8d81b238a..ad662872b 100644 --- a/backend-pg/src/server/routes/index.ts +++ b/backend-pg/src/server/routes/index.ts @@ -1,47 +1,142 @@ import { Knex } from "knex"; +import { z } from "zod"; +import { registerV1EERoutes } from "@app/ee/routes/v1"; +import { permissionDalFactory } from "@app/ee/services/permission/permission-dal"; +import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { getConfig } from "@app/lib/config/env"; +import { apiKeyDalFactory } from "@app/services/api-key/api-key-dal"; +import { apiKeyServiceFactory } from "@app/services/api-key/api-key-service"; import { authDalFactory } from "@app/services/auth/auth-dal"; import { authLoginServiceFactory } from "@app/services/auth/auth-login-service"; import { authPaswordServiceFactory } from "@app/services/auth/auth-password-service"; import { authSignupServiceFactory } from "@app/services/auth/auth-signup-service"; +import { incidentContactDalFactory } from "@app/services/org/incident-contacts-dal"; +import { orgDalFactory } from "@app/services/org/org-dal"; +import { orgRoleDalFactory } from "@app/services/org/org-role-dal"; +import { orgRoleServiceFactory } from "@app/services/org/org-role-service"; +import { orgServiceFactory } from "@app/services/org/org-service"; +import { serverCfgDalFactory } from "@app/services/server-cfg/server-cfg-dal"; +import { serverCfgServiceFactory } from "@app/services/server-cfg/server-cfg-service"; import { TSmtpService } from "@app/services/smtp/smtp-service"; import { tokenDalFactory } from "@app/services/token/token-dal"; import { tokenServiceFactory } from "@app/services/token/token-service"; +import { userDalFactory } from "@app/services/user/user-dal"; +import { userServiceFactory } from "@app/services/user/user-service"; +import { injectIdentity } from "../plugins/auth/inject-identity"; import { registerV1Routes } from "./v1"; import { registerV2Routes } from "./v2"; import { registerV3Routes } from "./v3"; -import { injectIdentity } from "../plugins/auth/inject-identity"; export const registerRoutes = async ( server: FastifyZodProvider, - { db, smtp }: { db: Knex; smtp: TSmtpService } + { db, smtp: smtpService }: { db: Knex; smtp: TSmtpService } ) => { // db layers + const userDal = userDalFactory(db); const authDal = authDalFactory(db); const authTokenDal = tokenDalFactory(db); + const orgDal = orgDalFactory(db); + const incidentContactDal = incidentContactDalFactory(db); + const orgRoleDal = orgRoleDalFactory(db); + const serverCfgDal = serverCfgDalFactory(db); + const apiKeyDal = apiKeyDalFactory(db); + + // ee db layer ops + const permissionDal = permissionDalFactory(db); + + // ee services + const permissionService = permissionServiceFactory({ permissionDal }); // service layers const tokenService = tokenServiceFactory({ tokenDal: authTokenDal }); - const loginService = authLoginServiceFactory({ authDal, smtpService: smtp, tokenService }); - const passwordService = authPaswordServiceFactory({ tokenService, smtpService: smtp, authDal }); - const signupService = authSignupServiceFactory({ tokenService, smtpService: smtp, authDal }); + const userService = userServiceFactory({ userDal }); + const loginService = authLoginServiceFactory({ userDal, smtpService, tokenService }); + const passwordService = authPaswordServiceFactory({ + tokenService, + smtpService, + authDal, + userDal + }); + const orgService = orgServiceFactory({ + orgRoleDal, + permissionService, + orgDal, + incidentContactDal, + tokenService, + smtpService, + userDal + }); + const signupService = authSignupServiceFactory({ + tokenService, + smtpService, + authDal, + userDal, + orgDal, + orgService + }); + const orgRoleService = orgRoleServiceFactory({ permissionService, orgRoleDal }); + const serverCfgService = serverCfgServiceFactory({ + userDal, + authService: loginService, + serverCfgDal + }); + const apiKeyService = apiKeyServiceFactory({ apiKeyDal }); + await serverCfgService.initServerCfg(); // inject all services server.decorate("services", { login: loginService, password: passwordService, - signup: signupService + signup: signupService, + user: userService, + permission: permissionService, + org: orgService, + orgRole: orgRoleService, + serverCfg: serverCfgService, + apiKey: apiKeyService, + authToken: tokenService } as FastifyZodProvider["services"]); server.decorate("store", { - user: authDal + user: userDal } as FastifyZodProvider["store"]); await server.register(injectIdentity); + server.route({ + url: "/status", + method: "GET", + schema: { + response: { + 200: z.object({ + date: z.date(), + message: z.literal("Ok"), + emailConfigured: z.boolean().optional(), + inviteOnlySignup: z.boolean().optional(), + redisConfigured: z.boolean().optional(), + secretScanningConfigured: z.boolean().optional() + }) + } + }, + handler: () => { + const appCfg = getConfig(); + return { + date: new Date(), + message: "Ok" as const, + emailConfigured: appCfg.isSmtpConfigured, + inviteOnlySignup: false, + redisConfigured: false, + secretScanningConfigured: false + }; + } + }); + // register routes for v1 await server.register(registerV1Routes, { prefix: "/v1" }); await server.register(registerV2Routes, { prefix: "/v2" }); await server.register(registerV3Routes, { prefix: "/v3" }); + + await server.register(registerV1EERoutes, { prefix: "/ee/v1" }); }; diff --git a/backend-pg/src/server/routes/v1/admin.ts b/backend-pg/src/server/routes/v1/admin.ts new file mode 100644 index 000000000..3bd05cc09 --- /dev/null +++ b/backend-pg/src/server/routes/v1/admin.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; + +import { ServerConfigSchema, UsersSchema } from "@app/db/schemas"; +import { getConfig } from "@app/lib/config/env"; +import { UnauthorizedError } from "@app/lib/errors"; +import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerAdminRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/config", + method: "GET", + schema: { + response: { + 200: z.object({ + config: ServerConfigSchema + }) + } + }, + handler: () => { + const config = server.services.serverCfg.getServerCfg(); + return { config }; + } + }); + + server.route({ + url: "/config", + method: "PATCH", + schema: { + body: z.object({ + allowSignUp: z.boolean().optional() + }), + response: { + 200: z.object({ + config: ServerConfigSchema + }) + } + }, + preHandler: (req) => { + verifyAuth([AuthMode.JWT, AuthMode.API_KEY])(req); + verifySuperAdmin(req); + }, + handler: async (req) => { + const config = await server.services.serverCfg.updateServerCfg(req.body); + return { config }; + } + }); + + server.route({ + url: "/signup", + method: "POST", + schema: { + body: z.object({ + email: z.string().email().trim(), + firstName: z.string().trim(), + lastName: z.string().trim().optional(), + protectedKey: z.string().trim(), + protectedKeyIV: z.string().trim(), + protectedKeyTag: z.string().trim(), + publicKey: z.string().trim(), + encryptedPrivateKey: z.string().trim(), + encryptedPrivateKeyIV: z.string().trim(), + encryptedPrivateKeyTag: z.string().trim(), + salt: z.string().trim(), + verifier: z.string().trim() + }), + response: { + 200: z.object({ + message: z.string(), + user: UsersSchema, + token: z.string() + }) + } + }, + handler: async (req, res) => { + const appCfg = getConfig(); + const serverCfg = server.services.serverCfg.getServerCfg(); + if (serverCfg.initialized) + throw new UnauthorizedError({ name: "Admin sign up", message: "Admin has been created" }); + const { user, token } = await server.services.serverCfg.adminSignUp({ + ...req.body, + ip: req.realIp, + userAgent: req.headers["user-agent"] || "" + }); + + res.setCookie("jid", token.refresh, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED + }); + + return { + message: "Successfully set up admin account", + user: user.user, + token: token.access + }; + } + }); +}; diff --git a/backend-pg/src/server/routes/v1/auth.ts b/backend-pg/src/server/routes/v1/auth.ts new file mode 100644 index 000000000..e40e015c6 --- /dev/null +++ b/backend-pg/src/server/routes/v1/auth.ts @@ -0,0 +1,88 @@ +import jwt from "jsonwebtoken"; +import { z } from "zod"; + +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { + AuthMode, + AuthModeRefreshJwtTokenPayload, + AuthTokenType +} from "@app/services/auth/auth-type"; + +export const registerAuthRoutes = async (server: FastifyZodProvider) => { + server.route({ + url: "/logout", + method: "POST", + schema: { + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req, res) => { + const appCfg = getConfig(); + await server.services.login.logout(req.auth.userId, req.auth.tokenVersionId); + res.cookie("jid", "", { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED + }); + return { message: "Successfully logged out" }; + } + }); + + server.route({ + url: "/token", + method: "POST", + schema: { + response: { + 200: z.object({ + token: z.string() + }) + } + }, + handler: async (req) => { + const refreshToken = req.cookies.jid; + const appCfg = getConfig(); + if (!refreshToken) + throw new BadRequestError({ + name: "Auth token route", + message: "Failed to find refresh token" + }); + + const decodedToken = jwt.verify( + refreshToken, + appCfg.JWT_AUTH_SECRET + ) as AuthModeRefreshJwtTokenPayload; + if (decodedToken.authTokenType !== AuthTokenType.REFRESH_TOKEN) + throw new UnauthorizedError({ message: "Invalid token", name: "Auth token route" }); + + const tokenVersion = await server.services.authToken.getUserTokenSessionById( + decodedToken.tokenVersionId, + decodedToken.userId + ); + if (!tokenVersion) + throw new UnauthorizedError({ message: "Invalid token", name: "Auth token route" }); + + if (decodedToken.refreshVersion !== tokenVersion.refreshVersion) + throw new UnauthorizedError({ message: "Invalid token", name: "Auth token route" }); + + const token = jwt.sign( + { + authTokenType: AuthTokenType.ACCESS_TOKEN, + userId: decodedToken.userId, + tokenVersionId: tokenVersion.id, + accessVersion: tokenVersion.accessVersion + }, + appCfg.JWT_AUTH_SECRET, + { expiresIn: appCfg.JWT_AUTH_LIFETIME } + ); + + return { token }; + } + }); +}; diff --git a/backend-pg/src/server/routes/v1/index.ts b/backend-pg/src/server/routes/v1/index.ts index 086fee790..7461f26ac 100644 --- a/backend-pg/src/server/routes/v1/index.ts +++ b/backend-pg/src/server/routes/v1/index.ts @@ -1,5 +1,17 @@ +import { registerAdminRouter } from "./admin"; +import { registerAuthRoutes } from "./auth"; +import { registerInviteOrgRouter } from "./invite-org"; +import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; +import { registerUserActionRouter } from "./user-action-router"; +import { registerUserRouter } from "./user-router"; export const registerV1Routes = async (server: FastifyZodProvider) => { + await server.register(registerAuthRoutes, { prefix: "/auth" }); await server.register(registerPasswordRouter, { prefix: "/password" }); + await server.register(registerOrgRouter, { prefix: "/organization" }); + await server.register(registerAdminRouter, { prefix: "/admin" }); + await server.register(registerUserRouter, { prefix: "/user" }); + await server.register(registerInviteOrgRouter, { prefix: "/invite-org" }); + await server.register(registerUserActionRouter, { prefix: "/user-action" }); }; diff --git a/backend-pg/src/server/routes/v1/invite-org.ts b/backend-pg/src/server/routes/v1/invite-org.ts new file mode 100644 index 000000000..66d866ba3 --- /dev/null +++ b/backend-pg/src/server/routes/v1/invite-org.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; + +import { UsersSchema } from "@app/db/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerInviteOrgRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/signup", + method: "POST", + schema: { + body: z.object({ + inviteeEmail: z.string().trim().email(), + organizationId: z.string().trim() + }), + response: { + 200: z.object({ + message: z.string(), + completeInviteLink: z.string().optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const completeInviteLink = await server.services.org.inviteUserToOrganization({ + orgId: req.body.organizationId, + userId: req.auth.userId, + inviteeEmail: req.body.inviteeEmail + }); + + return { + completeInviteLink, + message: `Send an invite link to ${req.body.inviteeEmail}` + }; + } + }); + + server.route({ + url: "/verify", + method: "POST", + schema: { + body: z.object({ + email: z.string().trim().email(), + organizationId: z.string().trim(), + code: z.string().trim() + }), + response: { + 200: z.object({ + message: z.string(), + token: z.string().optional(), + user: UsersSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { user, token } = await server.services.org.verifyUserToOrg({ + orgId: req.body.organizationId, + code: req.body.code, + email: req.body.email + }); + + return { + message: "Successfully verified email", + user, + token + }; + } + }); +}; diff --git a/backend-pg/src/server/routes/v1/organization-router.ts b/backend-pg/src/server/routes/v1/organization-router.ts new file mode 100644 index 000000000..a75ad17a7 --- /dev/null +++ b/backend-pg/src/server/routes/v1/organization-router.ts @@ -0,0 +1,183 @@ +import { z } from "zod"; + +import { + IncidentContactsSchema, + OrganizationsSchema, + OrgMembershipsSchema, + UserEncryptionKeysSchema, + UsersSchema +} from "@app/db/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerOrgRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/", + schema: { + response: { + 200: z.object({ + organizations: OrganizationsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const organizations = await server.services.org.findAllOrganizationOfUser(req.auth.userId); + return { organizations }; + } + }); + + server.route({ + method: "GET", + url: "/:organizationId", + schema: { + params: z.object({ + organizationId: z.string().trim() + }), + response: { + 200: z.object({ + organization: OrganizationsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const organization = await server.services.org.findOrganizationById( + req.auth.userId, + req.params.organizationId + ); + return { organization }; + } + }); + + server.route({ + method: "GET", + url: "/:organizationId/users", + schema: { + params: z.object({ + organizationId: z.string().trim() + }), + response: { + 200: z.object({ + users: OrgMembershipsSchema.merge( + z.object({ + user: UsersSchema.pick({ + email: true, + firstName: true, + lastName: true, + id: true + }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })) + }) + ) + .omit({ createdAt: true, updatedAt: true }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const users = await server.services.org.findAllOrgMembers( + req.auth.userId, + req.params.organizationId + ); + return { users }; + } + }); + + // TODO(akhilmhdh-pg): missing my-workspace list + + server.route({ + method: "PATCH", + url: "/:organizationId/name", + schema: { + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ name: z.string().trim() }), + response: { + 200: z.object({ + message: z.string(), + organization: OrganizationsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const organization = await server.services.org.updateOrgName( + req.auth.userId, + req.params.organizationId, + req.body.name + ); + return { + message: "Successfully changed organization name", + organization + }; + } + }); + + server.route({ + method: "GET", + url: "/:organizationId/incidentContactOrg", + schema: { + params: z.object({ organizationId: z.string().trim() }), + response: { + 200: z.object({ + incidentContactsOrg: IncidentContactsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const incidentContactsOrg = await req.server.services.org.findIncidentContacts( + req.auth.userId, + req.params.organizationId + ); + return { incidentContactsOrg }; + } + }); + + server.route({ + method: "POST", + url: "/:organizationId/incidentContactOrg", + schema: { + params: z.object({ organizationId: z.string().trim() }), + body: z.object({ email: z.string().email().trim() }), + response: { + 200: z.object({ + incidentContactsOrg: IncidentContactsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const incidentContactsOrg = await req.server.services.org.createIncidentContact( + req.auth.userId, + req.params.organizationId, + req.body.email + ); + return { incidentContactsOrg }; + } + }); + + server.route({ + method: "DELETE", + url: "/:organizationId/incidentContactOrg/:incidentContactId", + schema: { + // TODO(akhilmhdh-pg): change accept id instead of email + params: z.object({ organizationId: z.string().trim(), incidentContactId: z.string().trim() }), + response: { + 200: z.object({ + incidentContactsOrg: IncidentContactsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const incidentContactsOrg = await req.server.services.org.deleteIncidentContact( + req.auth.userId, + req.params.organizationId, + req.params.incidentContactId + ); + return { incidentContactsOrg }; + } + }); +}; diff --git a/backend-pg/src/server/routes/v1/user-action-router.ts b/backend-pg/src/server/routes/v1/user-action-router.ts new file mode 100644 index 000000000..204107493 --- /dev/null +++ b/backend-pg/src/server/routes/v1/user-action-router.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +import { UserActionsSchema } from "@app/db/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerUserActionRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/", + method: "POST", + schema: { + body: z.object({ + action: z.string().trim() + }), + response: { + 200: z.object({ + message: z.string(), + userAction: UserActionsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const userAction = await server.services.user.createUserAction( + req.auth.userId, + req.body.action + ); + return { userAction, message: "Successfully recorded user action" }; + } + }); + + server.route({ + url: "/", + method: "GET", + schema: { + querystring: z.object({ + action: z.string().trim() + }), + response: { + 200: z.object({ + userAction: UserActionsSchema.optional().nullable() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const userAction = await server.services.user.getUserAction( + req.auth.userId, + req.query.action + ); + return { userAction }; + } + }); +}; diff --git a/backend-pg/src/server/routes/v1/user-router.ts b/backend-pg/src/server/routes/v1/user-router.ts new file mode 100644 index 000000000..0b2074823 --- /dev/null +++ b/backend-pg/src/server/routes/v1/user-router.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +import { UsersSchema } from "@app/db/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerUserRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/", + schema: { + response: { + 200: z.object({ + user: UsersSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const user = await server.services.user.getMe(req.auth.userId); + return { user }; + } + }); +}; diff --git a/backend-pg/src/server/routes/v2/index.ts b/backend-pg/src/server/routes/v2/index.ts index 66bd9d695..f1abeb95f 100644 --- a/backend-pg/src/server/routes/v2/index.ts +++ b/backend-pg/src/server/routes/v2/index.ts @@ -1,5 +1,7 @@ import { registerMfaRouter } from "./mfa-router"; +import { registerUserRouter } from "./user-router"; export const registerV2Routes = async (server: FastifyZodProvider) => { await server.register(registerMfaRouter, { prefix: "/auth" }); + await server.register(registerUserRouter, { prefix: "/users" }); }; diff --git a/backend-pg/src/server/routes/v2/mfa-router.ts b/backend-pg/src/server/routes/v2/mfa-router.ts index 71f841ad7..6372e870b 100644 --- a/backend-pg/src/server/routes/v2/mfa-router.ts +++ b/backend-pg/src/server/routes/v2/mfa-router.ts @@ -25,7 +25,7 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { if (decodedToken.authTokenType !== AuthTokenType.MFA_TOKEN) throw new Error("Unauthorized access"); - const user = await server.store.user.getUserById(decodedToken.userId); + const user = await server.store.user.findById(decodedToken.userId); if (!user) throw new Error("User not found"); req.mfa = { userId: user.id, user }; }); diff --git a/backend-pg/src/server/routes/v2/organization-router.ts b/backend-pg/src/server/routes/v2/organization-router.ts new file mode 100644 index 000000000..7343216df --- /dev/null +++ b/backend-pg/src/server/routes/v2/organization-router.ts @@ -0,0 +1,140 @@ +import { z } from "zod"; + +import { + OrganizationsSchema, + OrgMembershipsSchema, + UserEncryptionKeysSchema, + UsersSchema +} from "@app/db/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerOrgRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:organizationId/memberships", + schema: { + params: z.object({ + organizationId: z.string().trim() + }), + response: { + 200: z.object({ + users: OrgMembershipsSchema.merge( + z.object({ + user: UsersSchema.pick({ + email: true, + firstName: true, + lastName: true, + id: true + }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })) + }) + ) + .omit({ createdAt: true, updatedAt: true }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const users = await server.services.org.findAllOrgMembers( + req.auth.userId, + req.params.organizationId + ); + return { users }; + } + }); + + server.route({ + method: "PATCH", + url: "/:organizationId/memberships/:membershipId", + schema: { + params: z.object({ organizationId: z.string().trim(), membershipId: z.string().trim() }), + body: z.object({ + role: z.string().trim() + }), + response: { + 200: z.object({ + membership: OrgMembershipsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const membership = await server.services.org.updateOrgMembership({ + userId: req.auth.userId, + role: req.body.role, + orgId: req.params.organizationId, + membershipId: req.params.membershipId + }); + return { membership }; + } + }); + + server.route({ + method: "DELETE", + url: "/:organizationId/memberships/:membershipId", + schema: { + params: z.object({ organizationId: z.string().trim(), membershipId: z.string().trim() }), + response: { + 200: z.object({ + membership: OrgMembershipsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const membership = await server.services.org.deleteOrgMembership({ + userId: req.auth.userId, + orgId: req.params.organizationId, + membershipId: req.params.membershipId + }); + return { membership }; + } + }); + + server.route({ + method: "POST", + url: "/", + schema: { + body: z.object({ + name: z.string().trim() + }), + response: { + 200: z.object({ + organization: OrganizationsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const organization = await server.services.org.createOrganization( + req.auth.userId, + req.body.name + ); + return { organization }; + } + }); + + server.route({ + method: "DELETE", + url: "/:organizationId", + schema: { + params: z.object({ + organizationId: z.string().trim() + }), + response: { + 200: z.object({ + organization: OrganizationsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const organization = await server.services.org.deleteOrganizationById( + req.auth.userId, + req.params.organizationId + ); + return { organization }; + } + }); +}; diff --git a/backend-pg/src/server/routes/v2/user-router.ts b/backend-pg/src/server/routes/v2/user-router.ts new file mode 100644 index 000000000..3df7a3ba0 --- /dev/null +++ b/backend-pg/src/server/routes/v2/user-router.ts @@ -0,0 +1,225 @@ +import { z } from "zod"; + +import { AuthTokenSessionsSchema, OrganizationsSchema, UsersSchema } from "@app/db/schemas"; +import { ApiKeysSchema } from "@app/db/schemas/api-keys"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMethod, AuthMode } from "@app/services/auth/auth-type"; + +export const registerUserRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/me/mfa", + method: "PATCH", + schema: { + body: z.object({ + isMfaEnabled: z.boolean() + }), + response: { + 200: z.object({ + user: UsersSchema + }) + } + }, + preHandler: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const user = await server.services.user.toggleUserMfa(req.auth.userId, req.body.isMfaEnabled); + return { user }; + } + }); + + server.route({ + url: "/me/name", + method: "PATCH", + schema: { + body: z.object({ + firstName: z.string().trim(), + lastName: z.string().trim() + }), + response: { + 200: z.object({ + user: UsersSchema + }) + } + }, + preHandler: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const user = await server.services.user.updateUserName( + req.auth.userId, + req.body.firstName, + req.body.lastName + ); + return { user }; + } + }); + + server.route({ + url: "/me/auth-methods", + method: "PUT", + schema: { + body: z.object({ + authMethods: z.nativeEnum(AuthMethod).array().min(1) + }), + response: { + 200: z.object({ + user: UsersSchema + }) + } + }, + preHandler: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const user = await server.services.user.updateAuthMethods( + req.auth.userId, + req.body.authMethods + ); + return { user }; + } + }); + + server.route({ + method: "GET", + url: "/me/organizations", + schema: { + response: { + 200: z.object({ + organizations: OrganizationsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]), + handler: async (req) => { + const organizations = await server.services.org.findAllOrganizationOfUser(req.auth.userId); + return { organizations }; + } + }); + + server.route({ + method: "GET", + url: "/me/api-keys", + schema: { + response: { + 200: ApiKeysSchema.omit({ secretHash: true }).array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const apiKeys = await server.services.apiKey.getMyApiKeys(req.auth.userId); + return apiKeys; + } + }); + + server.route({ + method: "POST", + url: "/me/api-keys", + schema: { + body: z.object({ + name: z.string().trim(), + expiresIn: z.number() + }), + response: { + 200: z.object({ + apiKey: z.string(), + apiKeyData: ApiKeysSchema.omit({ secretHash: true }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const apiKeys = await server.services.apiKey.createApiKey( + req.auth.userId, + req.body.name, + req.body.expiresIn + ); + return apiKeys; + } + }); + + server.route({ + method: "DELETE", + url: "/me/api-keys/:apiKeyDataId", + schema: { + params: z.object({ + apiKeyDataId: z.string().trim() + }), + response: { + 200: z.object({ + apiKeyData: ApiKeysSchema.omit({ secretHash: true }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const apiKeyData = await server.services.apiKey.deleteApiKey( + req.auth.userId, + req.params.apiKeyDataId + ); + return { apiKeyData }; + } + }); + + server.route({ + method: "GET", + url: "/me/sessions", + schema: { + response: { + 200: AuthTokenSessionsSchema.array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const sessions = await server.services.authToken.getTokenSessionByUser(req.auth.userId); + return sessions; + } + }); + + server.route({ + method: "DELETE", + url: "/me/sessions", + schema: { + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.authToken.revokeAllMySessions(req.auth.userId); + return { + message: "Successfully revoked all sessions" + }; + } + }); + + server.route({ + method: "GET", + url: "/me", + schema: { + response: { + 200: z.object({ + user: UsersSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const user = await server.services.user.getMe(req.auth.userId); + return { user }; + } + }); + + server.route({ + method: "DELETE", + url: "/me", + schema: { + response: { + 200: z.object({ + user: UsersSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const user = await server.services.user.deleteMe(req.auth.userId); + return { user }; + } + }); +}; diff --git a/backend-pg/src/server/routes/v3/index.ts b/backend-pg/src/server/routes/v3/index.ts index 982dee111..f9bd180c0 100644 --- a/backend-pg/src/server/routes/v3/index.ts +++ b/backend-pg/src/server/routes/v3/index.ts @@ -1,7 +1,9 @@ import { registerLoginRouter } from "./login-router"; import { registerSignupRouter } from "./signup-router"; +import { registerUserRouter } from "./user-router"; export const registerV3Routes = async (server: FastifyZodProvider) => { await server.register(registerSignupRouter, { prefix: "/signup" }); await server.register(registerLoginRouter, { prefix: "/auth" }); + await server.register(registerUserRouter, { prefix: "/users" }); }; diff --git a/backend-pg/src/server/routes/v3/signup-router.ts b/backend-pg/src/server/routes/v3/signup-router.ts index 731b79548..7e36069fd 100644 --- a/backend-pg/src/server/routes/v3/signup-router.ts +++ b/backend-pg/src/server/routes/v3/signup-router.ts @@ -100,4 +100,54 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { return { message: "Successfully set up account", user, token: accessToken }; } }); + + server.route({ + url: "/complete-account/invite", + method: "POST", + schema: { + body: z.object({ + email: z.string().email().trim(), + firstName: z.string().trim(), + lastName: z.string().trim().optional(), + protectedKey: z.string().trim(), + protectedKeyIV: z.string().trim(), + protectedKeyTag: z.string().trim(), + publicKey: z.string().trim(), + encryptedPrivateKey: z.string().trim(), + encryptedPrivateKeyIV: z.string().trim(), + encryptedPrivateKeyTag: z.string().trim(), + salt: z.string().trim(), + verifier: z.string().trim() + }), + response: { + 200: z.object({ + message: z.string(), + user: UsersSchema, + token: z.string() + }) + } + }, + handler: async (req, res) => { + const userAgent = req.headers["user-agent"]; + if (!userAgent) throw new Error("user agent header is required"); + const appCfg = getConfig(); + + const { user, accessToken, refreshToken } = + await server.services.signup.completeAccountInvite({ + ...req.body, + ip: req.realIp, + userAgent + }); + + res.setCookie("jid", refreshToken, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED + }); + // TODO(akhilmhdh-pg): add telemetry service + + return { message: "Successfully set up account", user, token: accessToken }; + } + }); }; diff --git a/backend-pg/src/server/routes/v3/user-router.ts b/backend-pg/src/server/routes/v3/user-router.ts new file mode 100644 index 000000000..223abfcff --- /dev/null +++ b/backend-pg/src/server/routes/v3/user-router.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +import { ApiKeysSchema } from "@app/db/schemas/api-keys"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerUserRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/me/api-keys", + schema: { + response: { + 200: z.object({ + apiKeyData: ApiKeysSchema.omit({ secretHash: true }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const apiKeyData = await server.services.apiKey.getMyApiKeys(req.auth.userId); + return { apiKeyData }; + } + }); +}; diff --git a/backend-pg/src/services/api-key/api-key-dal.ts b/backend-pg/src/services/api-key/api-key-dal.ts new file mode 100644 index 000000000..057ab3a1b --- /dev/null +++ b/backend-pg/src/services/api-key/api-key-dal.ts @@ -0,0 +1,7 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TApiKeyDalFactory = ReturnType; + +export const apiKeyDalFactory = (db: TDbClient) => ormify(db, TableName.ApiKey); diff --git a/backend-pg/src/services/api-key/api-key-service.ts b/backend-pg/src/services/api-key/api-key-service.ts new file mode 100644 index 000000000..a1cbb8ec1 --- /dev/null +++ b/backend-pg/src/services/api-key/api-key-service.ts @@ -0,0 +1,55 @@ +import crypto from "node:crypto"; +import bcrypt from "bcrypt"; + +import { TApiKeys } from "@app/db/schemas/api-keys"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; + +import { TApiKeyDalFactory } from "./api-key-dal"; + +type TApiKeyServiceFactoryDep = { + apiKeyDal: TApiKeyDalFactory; +}; + +export type TApiKeyServiceFactory = ReturnType; + +const formatApiKey = ({ secretHash, ...data }: TApiKeys) => data; + +export const apiKeyServiceFactory = ({ apiKeyDal }: TApiKeyServiceFactoryDep) => { + const getMyApiKeys = async (userId: string) => { + const apiKeys = await apiKeyDal.find({ userId }); + return apiKeys.map((key) => formatApiKey(key)); + }; + + const createApiKey = async (userId: string, name: string, expiresIn: number) => { + const appCfg = getConfig(); + const secret = crypto.randomBytes(16).toString("hex"); + const secretHash = await bcrypt.hash(secret, appCfg.SALT_ROUNDS); + const expiresAt = new Date(); + expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); + + const apiKeyData = await apiKeyDal.create({ + userId, + name, + expiresAt, + secretHash, + lastUsed: new Date() + }); + const apiKey = `ak.${apiKeyData.id}.${secret}`; + + return { apiKey, apiKeyData: formatApiKey(apiKeyData) }; + }; + + const deleteApiKey = async (userId: string, apiKeyId: string) => { + const [apiKeyData] = await apiKeyDal.delete({ id: apiKeyId, userId }); + if (!apiKeyData) + throw new BadRequestError({ message: "Failed to find api key", name: "delete api key" }); + return formatApiKey(apiKeyData); + }; + + return { + getMyApiKeys, + createApiKey, + deleteApiKey + }; +}; diff --git a/backend-pg/src/services/api-key/api-key-types.ts b/backend-pg/src/services/api-key/api-key-types.ts new file mode 100644 index 000000000..e69de29bb diff --git a/backend-pg/src/services/auth/auth-dal.ts b/backend-pg/src/services/auth/auth-dal.ts index 004cde5b4..b6f8f3591 100644 --- a/backend-pg/src/services/auth/auth-dal.ts +++ b/backend-pg/src/services/auth/auth-dal.ts @@ -1,110 +1,22 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TBackupPrivateKey, TUserEncryptionKeys, TUsers } from "@app/db/schemas"; +import { TableName, TBackupPrivateKey } from "@app/db/schemas"; import { withTransaction } from "@app/lib/knex"; export type TAuthDalFactory = ReturnType; export const authDalFactory = (db: TDbClient) => { - // getters - const getUserByEmail = async (email: string): Promise => - db(TableName.Users).where({ email }).select("*").first(); - - const getUserById = async (userId: string): Promise => - db(TableName.Users).where({ id: userId }).select("*").first(); - - const getUserEncKeyByEmail = async (email: string) => - db(TableName.Users) - .where({ email }) - .join( - TableName.UserEncryptionKey, - `${TableName.Users}.id`, - `${TableName.UserEncryptionKey}.userId` - ) - .first(); - - const getUserEncKeyByUserId = async (userId: string) => - db(TableName.Users) - .where({ id: userId }) - .join( - TableName.UserEncryptionKey, - `${TableName.Users}.id`, - `${TableName.UserEncryptionKey}.userId` - ) - .first(); - const getBackupPrivateKeyByUserId = async (userId: string) => db(TableName.BackupPrivateKey).where({ userId }).first("*"); - // all inserts and updates - const createUser = async ( - email: string, - data: Partial = {} - ): Promise => { - const [user] = await db(TableName.Users) - .insert({ email, ...data }) - .returning("*"); - return user; - }; - - const updateUser = async ( - email: string, - data: Partial = {} - ): Promise => { - const [user] = await db(TableName.Users) - .where({ email }) - .update({ ...data }) - .returning("*"); - return user; - }; - - const updateUserById = async ( - id: string, - data: Partial = {}, - tx?: Knex - ): Promise => { - const [user] = await (tx ? tx(TableName.Users) : db(TableName.Users)) - .where({ id }) - .update({ ...data }) - .returning("*"); - return user; - }; - - const updateUserEncryptionByUserId = async ( - userId: string, - data: Partial = {}, - tx?: Knex - ): Promise => { - const [userEnc] = await (tx ? tx(TableName.UserEncryptionKey) : db(TableName.UserEncryptionKey)) - .where({ userId }) - .update({ ...data }) - .returning("*"); - return userEnc; - }; - - // all upserts - const upsertUserEncryptionKey = async ( - userId: string, - data: Partial, - tx?: Knex - ) => { - const [userEnc] = await (tx ? tx(TableName.UserEncryptionKey) : db(TableName.UserEncryptionKey)) - // if user insert make sure to pass all required data - .insert({ userId, ...data } as TUserEncryptionKeys) - .onConflict("userId") - .merge() - .returning("*"); - return userEnc; - }; - const upsertBackupKey = async ( userId: string, data: Partial, tx?: Knex ): Promise => { - const [backupKey] = await (tx ? tx(TableName.BackupPrivateKey) : db(TableName.BackupPrivateKey)) - .insert({ userId, ...data, updatedAt: new Date().toUTCString() } as TBackupPrivateKey) + const [backupKey] = await (tx || db)(TableName.BackupPrivateKey) + .insert({ userId, ...data, updatedAt: new Date() } as TBackupPrivateKey) .onConflict("userId") .merge() .returning("*"); @@ -112,16 +24,7 @@ export const authDalFactory = (db: TDbClient) => { }; return withTransaction(db, { - getUserByEmail, - getUserById, - getUserEncKeyByEmail, - getUserEncKeyByUserId, getBackupPrivateKeyByUserId, - createUser, - updateUser, - updateUserById, - updateUserEncryptionByUserId, - upsertUserEncryptionKey, upsertBackupKey }); }; diff --git a/backend-pg/src/services/auth/auth-login-service.ts b/backend-pg/src/services/auth/auth-login-service.ts index 4b44adcc6..e5bf89224 100644 --- a/backend-pg/src/services/auth/auth-login-service.ts +++ b/backend-pg/src/services/auth/auth-login-service.ts @@ -7,7 +7,7 @@ import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TAuthTokenServiceFactory } from "../token/token-service"; import { TokenType } from "../token/token-types"; -import { TAuthDalFactory } from "./auth-dal"; +import { TUserDalFactory } from "../user/user-dal"; import { TLoginClientProofDTO, TLoginGenServerPublicKeyDTO, @@ -25,14 +25,14 @@ const isValidProviderAuthToken = (email: string, jwtSecret: string, providerAuth }; type TAuthLoginServiceFactoryDep = { - authDal: TAuthDalFactory; + userDal: TUserDalFactory; tokenService: TAuthTokenServiceFactory; smtpService: TSmtpService; }; export type TAuthLoginFactory = ReturnType; export const authLoginServiceFactory = ({ - authDal, + userDal, tokenService, smtpService }: TAuthLoginServiceFactoryDep) => { @@ -42,14 +42,14 @@ export const authLoginServiceFactory = ({ * If new device is found. Will be saved and a mail will be send */ const updateUserDeviceSession = async (user: TUsers, ip: string, userAgent: string) => { - const devices = await UserDeviceSchema.parseAsync(JSON.parse(user.devices || "[]")); + const devices = await UserDeviceSchema.parseAsync(user.devices || []); const isDeviceSeen = devices.some( (device) => device.ip === ip && device.userAgent === userAgent ); if (!isDeviceSeen) { const newDeviceList = devices.concat([{ ip, userAgent }]); - await authDal.updateUserById(user.id, { devices: JSON.stringify(newDeviceList) }); + await userDal.updateById(user.id, { devices: JSON.stringify(newDeviceList) }); await smtpService.sendMail({ template: SmtpTemplates.NewDeviceJoin, subjectLine: "Successful login from new device", @@ -68,23 +68,23 @@ export const authLoginServiceFactory = ({ * Private * Send mfa code via email * */ - const sendUserMfaCode = async (user: TUsers) => { + const sendUserMfaCode = async (userId: string, email: string) => { const code = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_MFA, - userId: user.id + userId }); await smtpService.sendMail({ template: SmtpTemplates.EmailMfa, subjectLine: "Infisical MFA code", - recipients: [user.email], + recipients: [email], substitutions: { code } }); }; - /* Private + /* * Check user device and send mail if new device * generate the auth and refresh token. fn shared by mfa verification and login verification with mfa disabled */ @@ -130,20 +130,19 @@ export const authLoginServiceFactory = ({ providerAuthToken, clientPublicKey }: TLoginGenServerPublicKeyDTO) => { - const user = await authDal.getUserEncKeyByEmail(email); - if (!user || (user && !user.isAccepted)) { + const userEnc = await userDal.findUserEncKeyByEmail(email); + if (!userEnc || (userEnc && !userEnc.isAccepted)) { throw new Error("Failed to find user"); } const cfg = getConfig(); - if ( - !user.authMethods?.includes(AuthMethod.EMAIL) && + !userEnc.authMethods?.includes(AuthMethod.EMAIL) && !isValidProviderAuthToken(email, cfg.JWT_AUTH_SECRET, providerAuthToken) ) { throw new Error("Invalid authorization request"); } - const serverSrpKey = await generateSrpServerKey(user.salt, user.verifier); - const userEncKeys = await authDal.updateUserEncryptionByUserId(user.id, { + const serverSrpKey = await generateSrpServerKey(userEnc.salt, userEnc.verifier); + const userEncKeys = await userDal.updateUserEncryptionByUserId(userEnc.userId, { clientPublicKey, serverPrivateKey: serverSrpKey.privateKey }); @@ -161,46 +160,46 @@ export const authLoginServiceFactory = ({ ip, userAgent }: TLoginClientProofDTO) => { - const user = await authDal.getUserEncKeyByEmail(email); - if (!user) throw new Error("Failed to find user"); + const userEnc = await userDal.findUserEncKeyByEmail(email); + if (!userEnc) throw new Error("Failed to find user"); const cfg = getConfig(); if ( - !user.authMethods?.includes(AuthMethod.EMAIL) && + !userEnc.authMethods?.includes(AuthMethod.EMAIL) && !isValidProviderAuthToken(email, cfg.JWT_AUTH_SECRET, providerAuthToken) ) { throw new Error("Invalid authorization request"); } - if (!user.serverPrivateKey || !user.clientPublicKey) + if (!userEnc.serverPrivateKey || !userEnc.clientPublicKey) throw new Error("Failed to authenticate. Try again?"); const isValidClientProof = await srpCheckClientProof( - user.salt, - user.verifier, - user.serverPrivateKey, - user.clientPublicKey, + userEnc.salt, + userEnc.verifier, + userEnc.serverPrivateKey, + userEnc.clientPublicKey, clientProof ); if (!isValidClientProof) throw new Error("Failed to authenticate. Try again?"); - await authDal.updateUserEncryptionByUserId(user.id, { + await userDal.updateUserEncryptionByUserId(userEnc.userId, { serverPrivateKey: null, clientPublicKey: null }); // send multi factor auth token if they it enabled - if (user.isMfaEnabled) { + if (userEnc.isMfaEnabled) { const mfaToken = jwt.sign( - { authTokenType: AuthTokenType.MFA_TOKEN, userId: user.id }, + { authTokenType: AuthTokenType.MFA_TOKEN, userId: userEnc.userId }, cfg.JWT_AUTH_SECRET, { expiresIn: cfg.JWT_MFA_LIFETIME } ); - await sendUserMfaCode(user); + await sendUserMfaCode(userEnc.userId, userEnc.email); return { isMfaEnabled: true, token: mfaToken } as const; } - const token = await generateUserTokens(user, ip, userAgent); - return { token, isMfaEnabled: false, user } as const; + const token = await generateUserTokens({ ...userEnc, id: userEnc.userId }, ip, userAgent); + return { token, isMfaEnabled: false, user: userEnc } as const; }; /* @@ -208,9 +207,9 @@ export const authLoginServiceFactory = ({ * saved in frontend */ const resendMfaToken = async (userId: string) => { - const user = await authDal.getUserById(userId); + const user = await userDal.findById(userId); if (!user) return; - await sendUserMfaCode(user); + await sendUserMfaCode(user.id, user.email); }; /* @@ -223,11 +222,11 @@ export const authLoginServiceFactory = ({ userId, code: mfaToken }); - const user = await authDal.getUserEncKeyByUserId(userId); - if (!user) throw new Error("Failed to authenticate user"); + const userEnc = await userDal.findUserEncKeyByUserId(userId); + if (!userEnc) throw new Error("Failed to authenticate user"); - const token = await generateUserTokens(user, ip, userAgent); - return { token, user }; + const token = await generateUserTokens({ ...userEnc, id: userEnc.userId }, ip, userAgent); + return { token, user: userEnc }; }; /* @@ -243,6 +242,7 @@ export const authLoginServiceFactory = ({ loginExchangeClientProof, logout, resendMfaToken, - verifyMfaToken + verifyMfaToken, + generateUserTokens }; }; diff --git a/backend-pg/src/services/auth/auth-password-service.ts b/backend-pg/src/services/auth/auth-password-service.ts index cf748958e..d15ae4424 100644 --- a/backend-pg/src/services/auth/auth-password-service.ts +++ b/backend-pg/src/services/auth/auth-password-service.ts @@ -6,6 +6,7 @@ import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TAuthTokenServiceFactory } from "../token/token-service"; import { TokenType } from "../token/token-types"; +import { TUserDalFactory } from "../user/user-dal"; import { TAuthDalFactory } from "./auth-dal"; import { TChangePasswordDTO, @@ -16,6 +17,7 @@ import { AuthTokenType } from "./auth-type"; type TAuthPasswordServiceFactoryDep = { authDal: TAuthDalFactory; + userDal: TUserDalFactory; tokenService: TAuthTokenServiceFactory; smtpService: TSmtpService; }; @@ -23,6 +25,7 @@ type TAuthPasswordServiceFactoryDep = { export type TAuthPasswordFactory = ReturnType; export const authPaswordServiceFactory = ({ authDal, + userDal, tokenService, smtpService }: TAuthPasswordServiceFactoryDep) => { @@ -31,11 +34,11 @@ export const authPaswordServiceFactory = ({ * Gets srp server user salt and server public key */ const generateServerPubKey = async (userId: string, clientPublicKey: string) => { - const user = await authDal.getUserEncKeyByUserId(userId); - if (!user) throw new Error("Failed to find user"); + const userEnc = await userDal.findUserEncKeyByUserId(userId); + if (!userEnc) throw new Error("Failed to find user"); - const serverSrpKey = await generateSrpServerKey(user.salt, user.verifier); - const userEncKeys = await authDal.updateUserEncryptionByUserId(user.id, { + const serverSrpKey = await generateSrpServerKey(userEnc.salt, userEnc.verifier); + const userEncKeys = await userDal.updateUserEncryptionByUserId(userEnc.userId, { clientPublicKey, serverPrivateKey: serverSrpKey.privateKey }); @@ -59,10 +62,10 @@ export const authPaswordServiceFactory = ({ verifier, tokenVersionId }: TChangePasswordDTO) => { - const userEnc = await authDal.getUserEncKeyByUserId(userId); + const userEnc = await userDal.findUserEncKeyByUserId(userId); if (!userEnc) throw new Error("Failed to find user"); - await authDal.updateUserEncryptionByUserId(userEnc.userId, { + await userDal.updateUserEncryptionByUserId(userEnc.userId, { serverPrivateKey: null, clientPublicKey: null }); @@ -77,7 +80,7 @@ export const authPaswordServiceFactory = ({ ); if (!isValidClientProof) throw new Error("Failed to authenticate. Try again?"); - await authDal.updateUserEncryptionByUserId(userId, { + await userDal.updateUserEncryptionByUserId(userId, { encryptionVersion: 2, protectedKey, protectedKeyIV, @@ -100,7 +103,7 @@ export const authPaswordServiceFactory = ({ * Email password reset flow via email. Step 1 send email */ const sendPasswordResetEmail = async (email: string) => { - const user = await authDal.getUserByEmail(email); + const user = await userDal.findUserByEmail(email); // ignore as user is not found to avoid an outside entity to identify infisical registered accounts if (!user || (user && !user.isAccepted)) return; @@ -127,7 +130,7 @@ export const authPaswordServiceFactory = ({ * */ const verifyPasswordResetEmail = async (email: string, code: string) => { const cfg = getConfig(); - const user = await authDal.getUserByEmail(email); + const user = await userDal.findUserByEmail(email); // ignore as user is not found to avoid an outside entity to identify infisical registered accounts if (!user || (user && !user.isAccepted)) { throw new Error("Failed email verification for pass reset"); @@ -166,7 +169,7 @@ export const authPaswordServiceFactory = ({ encryptedPrivateKeyTag }: TResetPasswordViaBackupKeyDTO ) => { - await authDal.updateUserEncryptionByUserId(userId, { + await userDal.updateUserEncryptionByUserId(userId, { encryptionVersion: 2, protectedKey, protectedKeyIV, @@ -193,23 +196,23 @@ export const authPaswordServiceFactory = ({ tag, userId }: TCreateBackupPrivateKeyDTO) => { - const user = await authDal.getUserEncKeyByUserId(userId); - if (!user || (user && !user.isAccepted)) { + const userEnc = await userDal.findUserEncKeyByUserId(userId); + if (!userEnc || (userEnc && !userEnc.isAccepted)) { throw new Error("Failed to find user"); } - if (!user.clientPublicKey || !user.serverPrivateKey) + if (!userEnc.clientPublicKey || !userEnc.serverPrivateKey) throw new Error("failed to create backup key"); const isValidClientProff = await srpCheckClientProof( - user.salt, - user.verifier, - user.serverPrivateKey, - user.clientPublicKey, + userEnc.salt, + userEnc.verifier, + userEnc.serverPrivateKey, + userEnc.clientPublicKey, clientProof ); if (!isValidClientProff) throw new Error("failed to create backup key"); const backup = await authDal.transaction(async (tx) => { - const backupKey = await authDal.upsertBackupKey(user.id, { + const backupKey = await authDal.upsertBackupKey(userEnc.userId, { encryptedPrivateKey, iv, tag, @@ -217,8 +220,8 @@ export const authPaswordServiceFactory = ({ verifier }); - await authDal.updateUserEncryptionByUserId( - user.id, + await userDal.updateUserEncryptionByUserId( + userEnc.userId, { serverPrivateKey: null, clientPublicKey: null @@ -235,7 +238,7 @@ export const authPaswordServiceFactory = ({ * Return user back up * */ const getBackupPrivateKeyOfUser = async (userId: string) => { - const user = await authDal.getUserEncKeyByUserId(userId); + const user = await userDal.findUserEncKeyByUserId(userId); if (!user || (user && !user.isAccepted)) { throw new Error("Failed to find user"); } diff --git a/backend-pg/src/services/auth/auth-signup-service.ts b/backend-pg/src/services/auth/auth-signup-service.ts index adefb385a..8049a642b 100644 --- a/backend-pg/src/services/auth/auth-signup-service.ts +++ b/backend-pg/src/services/auth/auth-signup-service.ts @@ -1,17 +1,25 @@ import jwt from "jsonwebtoken"; +import { OrgMembershipStatus } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; import { isDisposableEmail } from "@app/lib/validator"; +import { TOrgDalFactory } from "../org/org-dal"; +import { TOrgServiceFactory } from "../org/org-service"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TAuthTokenServiceFactory } from "../token/token-service"; import { TokenType } from "../token/token-types"; +import { TUserDalFactory } from "../user/user-dal"; import { TAuthDalFactory } from "./auth-dal"; -import { TCompleteAccountSignupDTO } from "./auth-signup-type"; +import { TCompleteAccountInviteDTO, TCompleteAccountSignupDTO } from "./auth-signup-type"; import { AuthMethod, AuthTokenType } from "./auth-type"; type TAuthSignupDep = { authDal: TAuthDalFactory; + userDal: TUserDalFactory; + orgService: Pick; + orgDal: TOrgDalFactory; tokenService: TAuthTokenServiceFactory; smtpService: TSmtpService; }; @@ -19,8 +27,11 @@ type TAuthSignupDep = { export type TAuthSignupFactory = ReturnType; export const authSignupServiceFactory = ({ authDal, + userDal, tokenService, - smtpService + smtpService, + orgService, + orgDal }: TAuthSignupDep) => { // first step of signup. create user and send email const beginEmailSignupProcess = async (email: string) => { @@ -29,13 +40,13 @@ export const authSignupServiceFactory = ({ throw new Error("Provided a disposable email"); } - let user = await authDal.getUserByEmail(email); + let user = await userDal.findUserByEmail(email); if (user && user.isAccepted) { // TODO(akhilmhdh-pg): copy as old one. this needs to be changed due to security issues throw new Error("Failed to send verification code for complete account"); } if (!user) { - user = await authDal.createUser(email, { authMethods: [AuthMethod.EMAIL] }); + user = await userDal.create({ authMethods: [AuthMethod.EMAIL], email }); } if (!user) throw new Error("Failed to create user"); @@ -55,7 +66,7 @@ export const authSignupServiceFactory = ({ }; const verifyEmailSignup = async (email: string, code: string) => { - const user = await authDal.getUserByEmail(email); + const user = await userDal.findUserByEmail(email); if (!user || (user && user.isAccepted)) { // TODO(akhilmhdh): copy as old one. this needs to be changed due to security issues throw new Error("Failed to send verification code for complete account"); @@ -91,7 +102,7 @@ export const authSignupServiceFactory = ({ protectedKey, protectedKeyIV, protectedKeyTag, - // organizationName, + organizationName, // attributionSource, encryptedPrivateKey, encryptedPrivateKeyIV, @@ -99,19 +110,15 @@ export const authSignupServiceFactory = ({ ip, userAgent }: TCompleteAccountSignupDTO) => { - const user = await authDal.getUserByEmail(email); + const user = await userDal.findUserByEmail(email); if (!user || (user && user.isAccepted)) { throw new Error("Failed to complete account for complete user"); } const updateduser = await authDal.transaction(async (tx) => { - const us = await authDal.updateUserById( - user.id, - { firstName, lastName, isAccepted: true }, - tx - ); + const us = await userDal.updateById(user.id, { firstName, lastName, isAccepted: true }, tx); if (!us) throw new Error("User not found"); - const userEncKey = await authDal.upsertUserEncryptionKey( + const userEncKey = await userDal.upsertUserEncryptionKey( us.id, { salt, @@ -129,7 +136,116 @@ export const authSignupServiceFactory = ({ return { info: us, key: userEncKey }; }); - // TODO(akhilmhdh-pg): add default org memberships + const hasSamlEnabled = user?.authMethods?.some((authMethod) => + [AuthMethod.OKTA_SAML, AuthMethod.AZURE_SAML, AuthMethod.JUMPCLOUD_SAML].includes( + authMethod as AuthMethod + ) + ); + + if (!hasSamlEnabled) { + await orgService.createOrganization(user.id, organizationName); + } + + await orgDal.updateMembership( + { inviteEmail: email, status: OrgMembershipStatus.Invited }, + { userId: user.id, status: OrgMembershipStatus.Accepted } + ); + + const tokenSession = await tokenService.getUserTokenSession({ + userAgent, + ip, + userId: updateduser.info.id + }); + if (!tokenSession) throw new Error("Failed to create token"); + const appCfg = getConfig(); + + const accessToken = jwt.sign( + { + authTokenType: AuthTokenType.ACCESS_TOKEN, + userId: updateduser.info.id, + tokenVersionId: tokenSession.id, + accessVersion: tokenSession.accessVersion + }, + appCfg.JWT_AUTH_SECRET, + { expiresIn: appCfg.JWT_AUTH_LIFETIME } + ); + + const refreshToken = jwt.sign( + { + authTokenType: AuthTokenType.REFRESH_TOKEN, + userId: updateduser.info.id, + tokenVersionId: tokenSession.id, + refreshVersion: tokenSession.refreshVersion + }, + appCfg.JWT_AUTH_SECRET, + { expiresIn: appCfg.JWT_REFRESH_LIFETIME } + ); + + return { user: updateduser.info, accessToken, refreshToken }; + }; + + /* + * User signup flow when they are invited to join the org + * */ + const completeAccountInvite = async ({ + ip, + salt, + email, + verifier, + firstName, + publicKey, + userAgent, + lastName, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag + }: TCompleteAccountInviteDTO) => { + const user = await userDal.findUserByEmail(email); + if (!user || (user && user.isAccepted)) { + throw new Error("Failed to complete account for complete user"); + } + + const [orgMembership] = await orgDal.findMembership({ + inviteEmail: email, + status: OrgMembershipStatus.Invited + }); + if (!orgMembership) + throw new BadRequestError({ + message: "Failed to find invitation for email", + name: "complete account invite" + }); + + const updateduser = await authDal.transaction(async (tx) => { + const us = await userDal.updateById(user.id, { firstName, lastName, isAccepted: true }, tx); + if (!us) throw new Error("User not found"); + const userEncKey = await userDal.upsertUserEncryptionKey( + us.id, + { + salt, + encryptionVersion: 2, + verifier, + publicKey, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag + }, + tx + ); + + await orgDal.updateMembership( + { inviteEmail: email, status: OrgMembershipStatus.Invited }, + { userId: us.id, status: OrgMembershipStatus.Accepted }, + tx + ); + return { info: us, key: userEncKey }; + }); + const tokenSession = await tokenService.getUserTokenSession({ userAgent, ip, @@ -166,6 +282,7 @@ export const authSignupServiceFactory = ({ return { beginEmailSignupProcess, verifyEmailSignup, - completeEmailAccountSignup + completeEmailAccountSignup, + completeAccountInvite }; }; diff --git a/backend-pg/src/services/auth/auth-signup-type.ts b/backend-pg/src/services/auth/auth-signup-type.ts index 2d1310a24..3188e3e09 100644 --- a/backend-pg/src/services/auth/auth-signup-type.ts +++ b/backend-pg/src/services/auth/auth-signup-type.ts @@ -17,3 +17,20 @@ export type TCompleteAccountSignupDTO = { ip: string; userAgent: string; }; + +export type TCompleteAccountInviteDTO = { + email: string; + firstName: string; + lastName?: string; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; + publicKey: string; + encryptedPrivateKey: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; + salt: string; + verifier: string; + ip: string; + userAgent: string; +}; diff --git a/backend-pg/src/services/auth/auth-type.ts b/backend-pg/src/services/auth/auth-type.ts index b80cb6d42..e7537704f 100644 --- a/backend-pg/src/services/auth/auth-type.ts +++ b/backend-pg/src/services/auth/auth-type.ts @@ -33,3 +33,10 @@ export type AuthModeJwtTokenPayload = { tokenVersionId: string; accessVersion: number; }; + +export type AuthModeRefreshJwtTokenPayload = { + authTokenType: AuthTokenType.REFRESH_TOKEN; + userId: string; + tokenVersionId: string; + refreshVersion: number; +}; diff --git a/backend-pg/src/services/org/incident-contacts-dal.ts b/backend-pg/src/services/org/incident-contacts-dal.ts new file mode 100644 index 000000000..ff43c6e02 --- /dev/null +++ b/backend-pg/src/services/org/incident-contacts-dal.ts @@ -0,0 +1,57 @@ +import { TDbClient } from "@app/db"; +import { TableName, TIncidentContacts } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; + +export type TIncidentContactsDalFactory = ReturnType; + +export const incidentContactDalFactory = (db: TDbClient) => { + const create = async (orgId: string, email: string) => { + try { + const [incidentContact] = await db(TableName.IncidentContact) + .insert({ orgId, email }) + .returning("*"); + return incidentContact; + } catch (error) { + throw new DatabaseError({ name: "Incident contact create", error }); + } + }; + + const findByOrgId = async (orgId: string) => { + try { + const incidentContacts = await db(TableName.IncidentContact).where({ orgId }); + return incidentContacts; + } catch (error) { + throw new DatabaseError({ name: "Incident contact list", error }); + } + }; + + const findOne = async (orgId: string, data: Partial) => { + try { + const incidentContacts = await db(TableName.IncidentContact) + .where({ orgId, ...data }) + .first(); + return incidentContacts; + } catch (error) { + throw new DatabaseError({ name: "Incident contact find one", error }); + } + }; + + const deleteById = async (id: string, orgId: string) => { + try { + const [incidentContact] = await db(TableName.IncidentContact) + .where({ orgId, id }) + .delete() + .returning("*"); + return incidentContact; + } catch (error) { + throw new DatabaseError({ name: "Incident contact delete", error }); + } + }; + + return { + findByOrgId, + findOne, + create, + deleteById + }; +}; diff --git a/backend-pg/src/services/org/org-dal.ts b/backend-pg/src/services/org/org-dal.ts new file mode 100644 index 000000000..b6262f2de --- /dev/null +++ b/backend-pg/src/services/org/org-dal.ts @@ -0,0 +1,184 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { + TableName, + TOrganizations, + TOrgMemberships, + TOrgMembershipsInsert, + TOrgMembershipsUpdate +} from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { withTransaction } from "@app/lib/knex"; + +export type TOrgDalFactory = ReturnType; + +export const orgDalFactory = (db: TDbClient) => { + const findOrgById = async (orgId: string) => { + try { + const org = await db(TableName.Organization).where({ id: orgId }).first(); + return org; + } catch (error) { + throw new DatabaseError({ error, name: "Find org by id" }); + } + }; + + // special query + const findAllOrgsByUserId = async (userId: string): Promise => { + try { + const org = await db(TableName.OrgMembership) + .where({ userId }) + .join( + TableName.Organization, + `${TableName.OrgMembership}.orgId`, + `${TableName.Organization}.id` + ) + .select(`${TableName.Organization}.*`); + return org; + } catch (error) { + throw new DatabaseError({ error, name: "Find all org by user id" }); + } + }; + + // special query + const findAllOrgMembers = async (orgId: string) => { + try { + const members = await db(TableName.OrgMembership) + .where({ orgId }) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .join( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .select( + db.ref("id").withSchema(TableName.OrgMembership), + db.ref("inviteEmail").withSchema(TableName.OrgMembership), + db.ref("orgId").withSchema(TableName.OrgMembership), + db.ref("role").withSchema(TableName.OrgMembership), + db.ref("roleId").withSchema(TableName.OrgMembership), + db.ref("status").withSchema(TableName.OrgMembership), + db.ref("email").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("publicKey").withSchema(TableName.UserEncryptionKey) + ); + return members.map(({ email, firstName, lastName, userId, publicKey, ...data }) => ({ + ...data, + user: { email, firstName, lastName, id: userId, publicKey } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find all org members" }); + } + }; + + const create = async ({ name }: { name: string }, tx?: Knex) => { + try { + const [organization] = await (tx || db)(TableName.Organization) + .insert({ name }) + .returning("*"); + return organization; + } catch (error) { + throw new DatabaseError({ error, name: "Create organization" }); + } + }; + + const deleteById = async (orgId: string, tx?: Knex) => { + try { + const [org] = await (tx || db)(TableName.Organization) + .where({ id: orgId }) + .delete() + .returning("*"); + return org; + } catch (error) { + throw new DatabaseError({ error, name: "Update organization" }); + } + }; + + const updateById = async (orgId: string, data: Partial) => { + try { + const [org] = await db(TableName.Organization) + .where({ id: orgId }) + .update({ ...data }) + .returning("*"); + return org; + } catch (error) { + throw new DatabaseError({ error, name: "Update organization" }); + } + }; + + // MEMBERSHIP OPERATIONS + // -------------------- + const findMembership = async (filter: Partial, tx?: Knex) => { + try { + const membership = await (tx || db)(TableName.OrgMembership).where(filter); + return membership; + } catch (error) { + throw new DatabaseError({ error, name: "Find org membership" }); + } + }; + + const createMembership = async (data: TOrgMembershipsInsert, tx?: Knex) => { + try { + const [membership] = await (tx || db)(TableName.OrgMembership).insert(data).returning("*"); + return membership; + } catch (error) { + throw new DatabaseError({ error, name: "Create org membership" }); + } + }; + + const updateMembershipById = async (id: string, data: TOrgMembershipsUpdate, tx?: Knex) => { + try { + const [membership] = await (tx || db)(TableName.OrgMembership) + .where({ id }) + .update(data) + .returning("*"); + return membership; + } catch (error) { + throw new DatabaseError({ error, name: "Update org membership" }); + } + }; + + const updateMembership = async ( + filter: Partial, + data: TOrgMembershipsUpdate, + tx?: Knex + ) => { + try { + const membership = await (tx || db)(TableName.OrgMembership) + .where(filter) + .update(data) + .returning("*"); + return membership; + } catch (error) { + throw new DatabaseError({ error, name: "Update org memberships" }); + } + }; + + const deleteMembershipById = async (id: string, orgId: string, tx?: Knex) => { + try { + const [membership] = await (tx || db)(TableName.OrgMembership) + .where({ id, orgId }) + .delete() + .returning("*"); + return membership; + } catch (error) { + throw new DatabaseError({ error, name: "Delete org membership" }); + } + }; + + return withTransaction(db, { + findAllOrgMembers, + findOrgById, + findAllOrgsByUserId, + create, + updateById, + deleteById, + findMembership, + createMembership, + updateMembershipById, + deleteMembershipById, + updateMembership + }); +}; diff --git a/backend-pg/src/services/org/org-role-dal.ts b/backend-pg/src/services/org/org-role-dal.ts new file mode 100644 index 000000000..3a77cafcc --- /dev/null +++ b/backend-pg/src/services/org/org-role-dal.ts @@ -0,0 +1,67 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName,TOrgRolesInsert, TOrgRolesUpdate } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { withTransaction } from "@app/lib/knex"; + +export type TOrgRoleDalFactory = ReturnType; + +export const orgRoleDalFactory = (db: TDbClient) => { + const find = async (data: TOrgRolesUpdate, tx?: Knex) => { + try { + const role = await (tx || db)(TableName.OrgRoles).where(data); + return role; + } catch (error) { + throw new DatabaseError({ error, name: "Org role find one" }); + } + }; + + const findOne = async (data: TOrgRolesUpdate, tx?: Knex) => { + try { + const role = await (tx || db)(TableName.OrgRoles).where(data).first(); + return role; + } catch (error) { + throw new DatabaseError({ error, name: "Org role find one" }); + } + }; + + const create = async (data: TOrgRolesInsert, tx?: Knex) => { + try { + const [role] = await (tx || db)(TableName.OrgRoles).insert(data).returning("*"); + return role; + } catch (error) { + throw new DatabaseError({ error, name: "Org role create" }); + } + }; + + const updateOne = async ( + filter: { id: string; orgId: string }, + data: TOrgRolesUpdate, + tx?: Knex + ) => { + try { + const [role] = await (tx || db)(TableName.OrgRoles).where(filter).update(data).returning("*"); + return role; + } catch (error) { + throw new DatabaseError({ error, name: "Org role create" }); + } + }; + + const deleteOne = async (filter: { id: string; orgId: string }, tx?: Knex) => { + try { + const [role] = await (tx || db)(TableName.OrgRoles).where(filter).delete().returning("*"); + return role; + } catch (error) { + throw new DatabaseError({ error, name: "Org role create" }); + } + }; + + return withTransaction(db, { + find, + findOne, + create, + updateOne, + deleteOne + }); +}; diff --git a/backend-pg/src/services/org/org-role-service.ts b/backend-pg/src/services/org/org-role-service.ts new file mode 100644 index 000000000..bcec8b231 --- /dev/null +++ b/backend-pg/src/services/org/org-role-service.ts @@ -0,0 +1,120 @@ +import { TOrgRolesInsert, TOrgRolesUpdate } from "@app/db/schemas"; +import { + orgAdminPermissions, + orgMemberPermissions, + OrgPermissionActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { BadRequestError } from "@app/lib/errors"; + +import { TOrgRoleDalFactory } from "./org-role-dal"; + +import { ForbiddenError } from "@casl/ability"; +import { packRules } from "@casl/ability/extra"; + +type TOrgRoleServiceFactoryDep = { + orgRoleDal: TOrgRoleDalFactory; + permissionService: TPermissionServiceFactory; +}; + +export type TOrgRoleServiceFactory = ReturnType; + +export const orgRoleServiceFactory = ({ + orgRoleDal, + permissionService +}: TOrgRoleServiceFactoryDep) => { + const createRole = async ( + userId: string, + orgId: string, + data: Omit + ) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.Role + ); + const existingRole = await orgRoleDal.findOne({ slug: data.slug, orgId }); + if (existingRole) throw new BadRequestError({ name: "Create Role", message: "Duplicate role" }); + + const role = await orgRoleDal.create({ ...data, orgId }); + return role; + }; + + const updateRole = async ( + userId: string, + orgId: string, + roleId: string, + data: Omit + ) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.Role + ); + if (data?.slug) { + const existingRole = await orgRoleDal.findOne({ slug: data.slug, orgId }); + if (existingRole && existingRole.id !== roleId) + throw new BadRequestError({ name: "Update Role", message: "Duplicate role" }); + } + const updatedRole = await orgRoleDal.updateOne({ id: roleId, orgId }, { ...data }); + if (!updateRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); + return updatedRole; + }; + + const deleteRole = async (userId: string, orgId: string, roleId: string) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Delete, + OrgPermissionSubjects.Role + ); + const deletedRole = await orgRoleDal.deleteOne({ id: roleId, orgId }); + if (!deleteRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); + + return deletedRole; + }; + + const listRoles = async (userId: string, orgId: string) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Role + ); + const customRoles = await orgRoleDal.find({ orgId }); + const roles = [ + { + id: "admin", + orgId: "", + name: "Admin", + slug: "admin", + description: "Complete administration access over the organization", + permissions: packRules(orgAdminPermissions.rules), + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "member", + orgId: "", + name: "Member", + slug: "member", + description: "Non-administrative role in an organization", + permissions: packRules(orgMemberPermissions.rules), + createdAt: new Date(), + updatedAt: new Date() + }, + ...(customRoles || []).map(({ permissions, ...data }) => ({ + ...data, + permissions + })) + ]; + + return roles; + }; + + const getUserPermission = async (userId: string, orgId: string) => { + const { permission, membership } = await permissionService.getUserOrgPermission(userId, orgId); + return { permissions: packRules(permission.rules), membership }; + }; + + return { createRole, updateRole, deleteRole, listRoles, getUserPermission }; +}; diff --git a/backend-pg/src/services/org/org-service.ts b/backend-pg/src/services/org/org-service.ts new file mode 100644 index 000000000..f986ad229 --- /dev/null +++ b/backend-pg/src/services/org/org-service.ts @@ -0,0 +1,368 @@ +import jwt from "jsonwebtoken"; + +import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas"; +import { + OrgPermissionActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { isDisposableEmail } from "@app/lib/validator"; + +import { AuthTokenType } from "../auth/auth-type"; +import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; +import { TAuthTokenServiceFactory } from "../token/token-service"; +import { TokenType } from "../token/token-types"; +import { TUserDalFactory } from "../user/user-dal"; +import { TIncidentContactsDalFactory } from "./incident-contacts-dal"; +import { TOrgDalFactory } from "./org-dal"; +import { TOrgRoleDalFactory } from "./org-role-dal"; +import { + TDeleteOrgMembershipDTO, + TInviteUserToOrgDTO, + TUpdateOrgMembershipDTO, + TVerifyUserToOrgDTO +} from "./org-types"; + +import { ForbiddenError } from "@casl/ability"; + +type TOrgServiceFactoryDep = { + orgDal: TOrgDalFactory; + orgRoleDal: TOrgRoleDalFactory; + userDal: TUserDalFactory; + incidentContactDal: TIncidentContactsDalFactory; + smtpService: TSmtpService; + tokenService: TAuthTokenServiceFactory; + permissionService: TPermissionServiceFactory; +}; + +export type TOrgServiceFactory = ReturnType; + +export const orgServiceFactory = ({ + orgDal, + userDal, + orgRoleDal, + incidentContactDal, + permissionService, + smtpService, + tokenService +}: TOrgServiceFactoryDep) => { + /* + * Get organization details by the organization id + * */ + const findOrganizationById = async (userId: string, orgId: string) => { + await permissionService.getUserOrgPermission(userId, orgId); + const org = await orgDal.findOrgById(orgId); + if (!org) + throw new BadRequestError({ name: "Org not found", message: "Organization not found" }); + return org; + }; + /* + * Get all organization a user part of + * */ + const findAllOrganizationOfUser = async (userId: string) => { + const orgs = await orgDal.findAllOrgsByUserId(userId); + return orgs; + }; + /* + * Get all workspace members + * */ + const findAllOrgMembers = async (userId: string, orgId: string) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.Member + ); + + const members = await orgDal.findAllOrgMembers(orgId); + return members; + }; + /* + * Update organization settings + * */ + const updateOrgName = async (userId: string, orgId: string, name: string) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.Settings + ); + const org = await orgDal.updateById(orgId, { name }); + if (!org) + throw new BadRequestError({ name: "Org not found", message: "Organization not found" }); + return org; + }; + /* + * Create organization + * */ + const createOrganization = async (userId: string, orgName: string) => { + const organization = await orgDal.transaction(async (tx) => { + const org = await orgDal.create({ name: orgName }, tx); + await orgDal.createMembership( + { + userId, + orgId: org.id, + role: OrgMembershipRole.Admin, + status: OrgMembershipStatus.Accepted + }, + tx + ); + return org; + }); + + return organization; + }; + + /* + * Delete organization by id + * */ + const deleteOrganizationById = async (userId: string, orgId: string) => { + const { membership } = await permissionService.getUserOrgPermission(userId, orgId); + if (membership.role !== OrgMembershipRole.Admin) + throw new UnauthorizedError({ name: "Delete org by id", message: "Not an admin" }); + + const organization = await orgDal.deleteById(orgId); + return organization; + }; + /* + * Org membership management + * Not another service because it has close ties with how an org works doesn't make sense to seperate them + * */ + const updateOrgMembership = async ({ + role, + orgId, + userId, + membershipId + }: TUpdateOrgMembershipDTO) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.Member + ); + + const isCustomRole = !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole); + if (isCustomRole) { + const customRole = await orgRoleDal.findOne({ slug: role, orgId }); + if (!customRole) + throw new BadRequestError({ name: "Update membership", message: "Role not found" }); + const membership = await orgDal.updateMembershipById(membershipId, { + role: OrgMembershipRole.Custom, + roleId: customRole.id + }); + return membership; + } + + const membership = await orgDal.updateMembershipById(membershipId, { role, roleId: null }); + return membership; + }; + /* + * Invite user to organization + */ + const inviteUserToOrganization = async ({ orgId, userId, inviteeEmail }: TInviteUserToOrgDTO) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.Member + ); + + // TODO(akhilmhdh-pg): SAML SSO check and licence check limit org members + const invitee = await orgDal.transaction(async (tx) => { + const inviteeUser = await userDal.findUserByEmail(inviteeEmail, tx); + if (inviteeUser) { + // if user already exist means its already part of infisical + // Thus the signup flow is not needed anymore + const [inviteeMembership] = await orgDal.findMembership( + { orgId, userId: inviteeUser.id }, + tx + ); + if (inviteeMembership && inviteeMembership.status === OrgMembershipStatus.Accepted) { + throw new BadRequestError({ + message: "Failed to invite an existing member of org", + name: "Invite user to org" + }); + } + + if (!inviteeMembership) { + await orgDal.createMembership( + { + userId: inviteeUser.id, + inviteEmail: inviteeEmail, + orgId, + role: OrgMembershipRole.Member, + status: OrgMembershipStatus.Invited + }, + tx + ); + } + return inviteeUser; + } + const isEmailInvalid = await isDisposableEmail(inviteeEmail); + if (isEmailInvalid) { + throw new BadRequestError({ + message: "Provided a disposable email", + name: "Org invite" + }); + } + // not invited before + const user = await userDal.create({ email: inviteeEmail, isAccepted: false }); + await orgDal.createMembership({ + inviteEmail: inviteeEmail, + orgId, + role: OrgMembershipRole.Member, + status: OrgMembershipStatus.Invited + }); + return user; + }); + + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_ORG_INVITATION, + userId: invitee.id, + orgId + }); + + const org = await orgDal.findOrgById(orgId); + const user = await userDal.findById(userId); + const appCfg = getConfig(); + await smtpService.sendMail({ + template: SmtpTemplates.OrgInvite, + subjectLine: "Infisical organization invitation", + recipients: [inviteeEmail], + substitutions: { + inviterFirstName: user.firstName, + inviterEmail: user.email, + organizationName: org?.name, + email: inviteeEmail, + organizationId: org?.id.toString(), + token, + callback_url: `${appCfg.SITE_URL}/signupinvite` + } + }); + + if (!appCfg.isSmtpConfigured) { + return `${appCfg.SITE_URL}/signupinvite?token=${token}&to=${inviteeEmail}&organization_id=${org?.id}`; + } + }; + + /** + * Organization invitation step 2: Verify that code [code] was sent to email [email] as part of + * magic link and issue a temporary signup token for user to complete setting up their account + */ + const verifyUserToOrg = async ({ orgId, email, code }: TVerifyUserToOrgDTO) => { + const user = await userDal.findUserByEmail(email); + if (!user) { + throw new BadRequestError({ message: "Invalid request", name: "Verify user to org" }); + } + const [orgMembership] = await orgDal.findMembership({ + userId: user.id, + status: OrgMembershipStatus.Invited, + orgId + }); + if (!orgMembership) + throw new BadRequestError({ + message: "Failed to find invitation", + name: "Verify user to org" + }); + + await tokenService.validateTokenForUser({ + type: TokenType.TOKEN_EMAIL_ORG_INVITATION, + userId: user.id, + orgId: orgMembership.orgId, + code + }); + + if (user.isAccepted) { + // this means user has already completed signup process + // isAccepted is set true when keys are exchanged + await orgDal.updateMembershipById(orgMembership.id, { + orgId, + status: OrgMembershipStatus.Accepted + }); + // TODO(akhilmhdh-pg): update org licence subscription + return { user }; + } + + const appCfg = getConfig(); + const token = jwt.sign( + { + authTokenType: AuthTokenType.SIGNUP_TOKEN, + userId: user.id + }, + appCfg.JWT_AUTH_SECRET, + { + expiresIn: appCfg.JWT_SIGNUP_LIFETIME + } + ); + + return { token, user }; + }; + + const deleteOrgMembership = async ({ orgId, userId, membershipId }: TDeleteOrgMembershipDTO) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Delete, + OrgPermissionSubjects.Member + ); + + const membership = await orgDal.deleteMembershipById(membershipId, orgId); + return membership; + }; + + /* + * CRUD operations of incident contacts + * */ + const findIncidentContacts = async (userId: string, orgId: string) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.IncidentAccount + ); + const incidentContacts = await incidentContactDal.findByOrgId(orgId); + return incidentContacts; + }; + + const createIncidentContact = async (userId: string, orgId: string, email: string) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.IncidentAccount + ); + const doesIncidentContactExist = await incidentContactDal.findOne(orgId, { email }); + if (doesIncidentContactExist) { + throw new BadRequestError({ + message: "Incident contact already exist", + name: "Incident contact exist" + }); + } + + const incidentContact = await incidentContactDal.create(orgId, email); + return incidentContact; + }; + + const deleteIncidentContact = async (userId: string, orgId: string, id: string) => { + const { permission } = await permissionService.getUserOrgPermission(userId, orgId); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Delete, + OrgPermissionSubjects.IncidentAccount + ); + + const incidentContact = await incidentContactDal.deleteById(id, orgId); + return incidentContact; + }; + + return { + findOrganizationById, + findAllOrgMembers, + findAllOrganizationOfUser, + inviteUserToOrganization, + verifyUserToOrg, + updateOrgName, + createOrganization, + deleteOrganizationById, + deleteOrgMembership, + updateOrgMembership, + // incident contacts + findIncidentContacts, + createIncidentContact, + deleteIncidentContact + }; +}; diff --git a/backend-pg/src/services/org/org-types.ts b/backend-pg/src/services/org/org-types.ts new file mode 100644 index 000000000..6456e5de1 --- /dev/null +++ b/backend-pg/src/services/org/org-types.ts @@ -0,0 +1,24 @@ +export type TUpdateOrgMembershipDTO = { + userId: string; + orgId: string; + membershipId: string; + role: string; +}; + +export type TDeleteOrgMembershipDTO = { + userId: string; + orgId: string; + membershipId: string; +}; + +export type TInviteUserToOrgDTO = { + userId: string; + orgId: string; + inviteeEmail: string; +}; + +export type TVerifyUserToOrgDTO = { + email: string; + orgId: string; + code: string; +}; diff --git a/backend-pg/src/services/server-cfg/server-cfg-dal.ts b/backend-pg/src/services/server-cfg/server-cfg-dal.ts new file mode 100644 index 000000000..a0bac5ce9 --- /dev/null +++ b/backend-pg/src/services/server-cfg/server-cfg-dal.ts @@ -0,0 +1,7 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TServerCfgDalFactory = ReturnType; + +export const serverCfgDalFactory = (db: TDbClient) => ormify(db, TableName.ServerConfig, {}); diff --git a/backend-pg/src/services/server-cfg/server-cfg-service.ts b/backend-pg/src/services/server-cfg/server-cfg-service.ts new file mode 100644 index 000000000..958cf3328 --- /dev/null +++ b/backend-pg/src/services/server-cfg/server-cfg-service.ts @@ -0,0 +1,106 @@ +import { TServerConfig, TServerConfigUpdate } from "@app/db/schemas"; +import { BadRequestError } from "@app/lib/errors"; + +import { TAuthLoginFactory } from "../auth/auth-login-service"; +import { TUserDalFactory } from "../user/user-dal"; +import { TServerCfgDalFactory } from "./server-cfg-dal"; +import { TAdminSignUpDTO } from "./server-cfg-types"; + +type TServerCfgServiceFactoryDep = { + serverCfgDal: TServerCfgDalFactory; + userDal: TUserDalFactory; + authService: Pick; +}; + +export type TServerCfgServiceFactory = ReturnType; + +export const serverCfgServiceFactory = ({ + serverCfgDal, + userDal, + authService +}: TServerCfgServiceFactoryDep) => { + let serverCfg: TServerConfig; + + const initServerCfg = async () => { + serverCfg = await serverCfgDal.findOne({}); + if (!serverCfg) { + const newCfg = await serverCfgDal.create({ initialized: true, allowSignUp: true }); + serverCfg = newCfg; + return newCfg; + } + return serverCfg; + }; + + const getServerCfg = () => { + if (!serverCfg) + throw new BadRequestError({ name: "Get server cfg", message: "Server cfg not initialized" }); + return serverCfg; + }; + + const updateServerCfg = async (data: TServerConfigUpdate) => { + const cfg = await serverCfgDal.updateById(serverCfg.id, data); + return cfg; + }; + + const adminSignUp = async ({ + lastName, + firstName, + salt, + email, + verifier, + publicKey, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + encryptedPrivateKeyIV, + encryptedPrivateKeyTag, + ip, + userAgent + }: TAdminSignUpDTO) => { + const existingUser = await userDal.findOne({ email }); + if (!existingUser) + throw new BadRequestError({ name: "Admin sign up", message: "User already exist" }); + + const userInfo = await userDal.transaction(async (tx) => { + const newUser = await userDal.create( + { + firstName, + lastName, + email, + superAdmin: true + }, + tx + ); + const userEnc = await userDal.createUserEncryption( + { + salt, + encryptionVersion: 2, + protectedKey, + protectedKeyIV, + protectedKeyTag, + publicKey, + encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag, + verifier, + userId: newUser.id + }, + tx + ); + return { user: newUser, enc: userEnc }; + }); + + await updateServerCfg({ initialized: true }); + const token = await authService.generateUserTokens(userInfo.user, ip, userAgent); + // TODO(akhilmhdh-pg): telemetry service + return { token, user: userInfo }; + }; + + return { + initServerCfg, + getServerCfg, + updateServerCfg, + adminSignUp + }; +}; diff --git a/backend-pg/src/services/server-cfg/server-cfg-types.ts b/backend-pg/src/services/server-cfg/server-cfg-types.ts new file mode 100644 index 000000000..e586946f2 --- /dev/null +++ b/backend-pg/src/services/server-cfg/server-cfg-types.ts @@ -0,0 +1,16 @@ +export type TAdminSignUpDTO = { + email: string; + publicKey: string; + salt: string; + lastName?: string; + verifier: string; + firstName: string; + protectedKey: string; + protectedKeyIV: string; + protectedKeyTag: string; + encryptedPrivateKey: string; + encryptedPrivateKeyIV: string; + encryptedPrivateKeyTag: string; + ip: string; + userAgent: string; +}; diff --git a/backend-pg/src/services/token/token-dal.ts b/backend-pg/src/services/token/token-dal.ts index 16db26468..1b4bc37e3 100644 --- a/backend-pg/src/services/token/token-dal.ts +++ b/backend-pg/src/services/token/token-dal.ts @@ -1,55 +1,31 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; import { TableName, TAuthTokens, TAuthTokenSessions } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; -import { - TDeleteTokenForUserDalDTO, - TGetTokenForUserDalDTO, - TUpsertTokenForUserDalDTO -} from "./token-types"; +import { TDeleteTokenForUserDalDTO } from "./token-types"; export type TTokenDalConfig = {}; export type TTokenDalFactory = ReturnType; +// TODO(akhilmhdh-pg): wrap all with database error export const tokenDalFactory = (db: TDbClient) => { - const upsertTokenForUser = async ({ - tokenHash, - expiresAt, - userId, - type, - triesLeft - }: TUpsertTokenForUserDalDTO): Promise => { - const token = await db.transaction(async (tx) => { - await tx(TableName.AuthTokens).where({ userId, type }).delete().returning("*"); - const [newToken] = await tx(TableName.AuthTokens) - .insert({ tokenHash, expiresAt: expiresAt.toUTCString(), type, userId, triesLeft }) - .returning("*"); - return newToken; - }); - return token; - }; + const authOrm = ormify(db, TableName.AuthTokens); - const getTokenForUser = async ({ - userId, - type - }: TGetTokenForUserDalDTO): Promise => - db(TableName.AuthTokens).where({ userId, type }).first(); - - const getTokenSession = async ( - userId: string, - ip: string, - userAgent: string + const findOneTokenSession = async ( + filter: Partial ): Promise => - db(TableName.AuthTokenSession).where({ userId, ip, userAgent }).first(); - - const getTokenSessionById = async (id: string, userId: string) => - db(TableName.AuthTokenSession).where({ id, userId }).first(); + db(TableName.AuthTokenSession).where(filter).first(); const deleteTokenForUser = async ({ userId, - type + type, + orgId }: TDeleteTokenForUserDalDTO): Promise => - db(TableName.AuthTokens).where({ userId, type }).delete().returning("*"); + db(TableName.AuthTokens).where({ userId, type, orgId }).delete().returning("*"); const decrementTriesField = async ({ userId, @@ -58,6 +34,15 @@ export const tokenDalFactory = (db: TDbClient) => { await db(TableName.AuthTokens).where({ userId, type }).decrement("triesLeft", 1); }; + const findTokenSessions = async (filter: Partial, tx?: Knex) => { + try { + const sessions = await (tx || db)(TableName.AuthTokenSession).where(filter); + return sessions; + } catch (error) { + throw new DatabaseError({ name: "Find all token session", error }); + } + }; + const insertTokenSession = async ( userId: string, ip: string, @@ -70,13 +55,13 @@ export const tokenDalFactory = (db: TDbClient) => { userAgent, accessVersion: 1, refreshVersion: 1, - lastUsed: new Date().toUTCString() + lastUsed: new Date() }) .returning("*"); return session; }; - const incrementVersion = async ( + const incrementTokenSessionVersion = async ( userId: string, sessionId: string ): Promise => { @@ -88,14 +73,26 @@ export const tokenDalFactory = (db: TDbClient) => { return session; }; + const deleteTokenSession = async (filter: Partial, tx?: Knex) => { + try { + const sessions = await (tx || db)(TableName.AuthTokenSession) + .where(filter) + .del() + .returning("*"); + return sessions; + } catch (error) { + throw new DatabaseError({ name: "Delete token session", error }); + } + }; + return { - getTokenForUser, - getTokenSessionById, - upsertTokenForUser, + ...authOrm, + findTokenSessions, deleteTokenForUser, decrementTriesField, - getTokenSession, + findOneTokenSession, insertTokenSession, - incrementVersion + incrementTokenSessionVersion, + deleteTokenSession }; }; diff --git a/backend-pg/src/services/token/token-service.ts b/backend-pg/src/services/token/token-service.ts index 37f577a7d..283ff8bff 100644 --- a/backend-pg/src/services/token/token-service.ts +++ b/backend-pg/src/services/token/token-service.ts @@ -56,30 +56,37 @@ export const getTokenConfig = (tokenType: TokenType) => { }; export const tokenServiceFactory = ({ tokenDal }: TAuthTokenServiceFactoryDep) => { - const createTokenForUser = async ({ type, userId }: TCreateTokenForUserDTO) => { + const createTokenForUser = async ({ type, userId, orgId }: TCreateTokenForUserDTO) => { const { token, ...tkCfg } = getTokenConfig(type); const appCfg = getConfig(); const tokenHash = await bcrypt.hash(token, appCfg.SALT_ROUNDS); - await tokenDal.upsertTokenForUser({ - userId, - type, - expiresAt: tkCfg.expiresAt, - tokenHash, - triesLeft: tkCfg?.triesLeft + await tokenDal.transaction(async (tx) => { + await tokenDal.delete({ userId, type, orgId: orgId || null }, tx); + const newToken = await tokenDal.create({ + tokenHash, + expiresAt: tkCfg.expiresAt.toUTCString(), + type, + userId, + orgId, + triesLeft: tkCfg?.triesLeft + }); + return newToken; }); + return token; }; const validateTokenForUser = async ({ type, userId, - code + code, + orgId }: TValidateTokenForUserDTO): Promise => { - const token = await tokenDal.getTokenForUser({ type, userId }); + const token = await tokenDal.findOne({ type, userId, orgId: orgId || null }); // validate token if (!token) throw new Error("Failed to find token"); if (token?.expiresAt && new Date(token.expiresAt) < new Date()) { - await tokenDal.deleteTokenForUser({ type, userId }); + await tokenDal.delete({ type, userId, orgId }); throw new Error("Token expired. Please try again"); } @@ -87,15 +94,15 @@ export const tokenServiceFactory = ({ tokenDal }: TAuthTokenServiceFactoryDep) = if (!isValidToken) { if (token?.triesLeft) { if (token.triesLeft === 1) { - await tokenDal.deleteTokenForUser({ type, userId }); + await tokenDal.deleteTokenForUser({ type, userId, orgId: orgId || null }); } else { - await tokenDal.decrementTriesField({ type, userId }); + await tokenDal.decrementTriesField({ type, userId, orgId: orgId || null }); } } throw new Error("Invalid token"); } - const deletedToken = await tokenDal.deleteTokenForUser({ type, userId }); + const deletedToken = await tokenDal.delete({ type, userId, orgId: orgId || null }); return deletedToken?.[0]; }; @@ -104,7 +111,7 @@ export const tokenServiceFactory = ({ tokenDal }: TAuthTokenServiceFactoryDep) = ip, userAgent }: TIssueAuthTokenDTO): Promise => { - let session = await tokenDal.getTokenSession(userId, ip, userAgent); + let session = await tokenDal.findOneTokenSession({ userId, ip, userAgent }); if (!session) { session = await tokenDal.insertTokenSession(userId, ip, userAgent); } @@ -112,18 +119,25 @@ export const tokenServiceFactory = ({ tokenDal }: TAuthTokenServiceFactoryDep) = }; const getUserTokenSessionById = async (id: string, userId: string) => - tokenDal.getTokenSessionById(id, userId); + tokenDal.findOneTokenSession({ id, userId }); const clearTokenSessionById = async ( userId: string, sessionId: string - ): Promise => tokenDal.incrementVersion(userId, sessionId); + ): Promise => + tokenDal.incrementTokenSessionVersion(userId, sessionId); + + const getTokenSessionByUser = async (userId: string) => tokenDal.findTokenSessions({ userId }); + + const revokeAllMySessions = async (userId: string) => tokenDal.deleteTokenSession({ userId }); return { createTokenForUser, validateTokenForUser, getUserTokenSession, clearTokenSessionById, - getUserTokenSessionById + getUserTokenSessionById, + getTokenSessionByUser, + revokeAllMySessions }; }; diff --git a/backend-pg/src/services/token/token-types.ts b/backend-pg/src/services/token/token-types.ts index 454dd7f76..733f3d968 100644 --- a/backend-pg/src/services/token/token-types.ts +++ b/backend-pg/src/services/token/token-types.ts @@ -8,12 +8,19 @@ export enum TokenType { export type TCreateTokenForUserDTO = { type: TokenType; userId: string; + orgId?: string; +}; + +export type TCreateOrgInviteTokenDTO = { + userId: string; + orgId: string; }; export type TValidateTokenForUserDTO = { type: TokenType; code: string; userId: string; + orgId?: string; }; export type TUpsertTokenForUserDalDTO = { @@ -32,6 +39,7 @@ export type TGetTokenForUserDalDTO = { export type TDeleteTokenForUserDalDTO = { userId: string; type: TokenType; + orgId: string | null; }; export type TIssueAuthTokenDTO = { diff --git a/backend-pg/src/services/user/user-dal.ts b/backend-pg/src/services/user/user-dal.ts new file mode 100644 index 000000000..de8b2ac0d --- /dev/null +++ b/backend-pg/src/services/user/user-dal.ts @@ -0,0 +1,108 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { + TableName, + TUserActionsInsert, + TUserActionsUpdate, + TUserEncryptionKeys, + TUserEncryptionKeysInsert, + TUserEncryptionKeysUpdate +} from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TUserDalFactory = ReturnType; + +export const userDalFactory = (db: TDbClient) => { + const userOrm = ormify(db, TableName.Users); + const findUserByEmail = async (email: string, tx?: Knex) => userOrm.findOne({ email }, tx); + + // USER ENCRYPTION FUNCTIONS + // ------------------------- + const findUserEncKeyByEmail = async (email: string) => + db(TableName.Users) + .where({ email }) + .join( + TableName.UserEncryptionKey, + `${TableName.Users}.id`, + `${TableName.UserEncryptionKey}.userId` + ) + .first(); + + const findUserEncKeyByUserId = async (userId: string) => + db(TableName.Users) + .where({ [`${TableName.Users}.id`]: userId }) + .join( + TableName.UserEncryptionKey, + `${TableName.Users}.id`, + `${TableName.UserEncryptionKey}.userId` + ) + .first(); + + const createUserEncryption = async (data: TUserEncryptionKeysInsert, tx?: Knex) => { + try { + const [userEnc] = await (tx || db)(TableName.UserEncryptionKey).insert(data).returning("*"); + return userEnc; + } catch (error) { + throw new DatabaseError({ error, name: "Create user encryption" }); + } + }; + + const updateUserEncryptionByUserId = async ( + userId: string, + data: TUserEncryptionKeysUpdate, + tx?: Knex + ) => { + const [userEnc] = await (tx || db)(TableName.UserEncryptionKey) + .where({ userId }) + .update({ ...data }) + .returning("*"); + return userEnc; + }; + + const upsertUserEncryptionKey = async ( + userId: string, + data: Omit, + tx?: Knex + ) => { + const [userEnc] = await (tx ? tx(TableName.UserEncryptionKey) : db(TableName.UserEncryptionKey)) + // if user insert make sure to pass all required data + .insert({ userId, ...data } as TUserEncryptionKeys) + .onConflict("userId") + .merge() + .returning("*"); + return userEnc; + }; + + // USER ACTION FUNCTIONS + // --------------------- + const findOneUserAction = (filter: TUserActionsUpdate, tx?: Knex) => { + try { + return (tx || db)(TableName.UserAction).where(filter).first("*"); + } catch (error) { + throw new DatabaseError({ error, name: "Find one user action" }); + } + }; + + const createUserAction = async (data: TUserActionsInsert, tx?: Knex) => { + try { + const [userAction] = await (tx || db)(TableName.UserAction).insert(data).returning("*"); + return userAction; + } catch (error) { + throw new DatabaseError({ error, name: "Create user action" }); + } + }; + + return { + ...userOrm, + findUserByEmail, + findUserEncKeyByEmail, + findUserEncKeyByUserId, + updateUserEncryptionByUserId, + upsertUserEncryptionKey, + createUserEncryption, + findOneUserAction, + createUserAction + }; +}; diff --git a/backend-pg/src/services/user/user-service.ts b/backend-pg/src/services/user/user-service.ts new file mode 100644 index 000000000..62d8cd2ba --- /dev/null +++ b/backend-pg/src/services/user/user-service.ts @@ -0,0 +1,84 @@ +import { BadRequestError } from "@app/lib/errors"; + +import { AuthMethod } from "../auth/auth-type"; +import { TUserDalFactory } from "./user-dal"; + +type TUserServiceFactoryDep = { + userDal: TUserDalFactory; +}; + +export type TUserServiceFactory = ReturnType; + +export const userServiceFactory = ({ userDal }: TUserServiceFactoryDep) => { + const toggleUserMfa = async (userId: string, isMfaEnabled: boolean) => { + const updatedUser = await userDal.updateById(userId, { + isMfaEnabled, + mfaMethods: isMfaEnabled ? ["email"] : [] + }); + return updatedUser; + }; + + const updateUserName = async (userId: string, firstName: string, lastName: string) => { + const updatedUser = await userDal.updateById(userId, { + firstName, + lastName + }); + return updatedUser; + }; + + const updateAuthMethods = async (userId: string, authMethods: AuthMethod[]) => { + const user = await userDal.findById(userId); + if (!user) throw new BadRequestError({ name: "Update auth methods" }); + + const hasSamlEnabled = user?.authMethods?.some((method) => + [AuthMethod.OKTA_SAML, AuthMethod.AZURE_SAML, AuthMethod.JUMPCLOUD_SAML].includes( + method as AuthMethod + ) + ); + if (hasSamlEnabled) + throw new BadRequestError({ + name: "Update auth method", + message: "Failed to update auth methods due to SAML SSO " + }); + + const updatedUser = await userDal.updateById(userId, { authMethods }); + return updatedUser; + }; + + const getMe = async (userId: string) => { + const user = await userDal.findUserEncKeyByUserId(userId); + if (!user) throw new BadRequestError({ message: "user not found", name: "Get Me" }); + return user; + }; + + const deleteMe = async (userId: string) => { + const user = await userDal.deleteById(userId); + return user; + }; + + // user actions operations + const createUserAction = async (userId: string, action: string) => { + const userAction = await userDal.transaction(async (tx) => { + const existingAction = await userDal.findOneUserAction({ action, userId }, tx); + if (existingAction) return existingAction; + return userDal.createUserAction({ action, userId }, tx); + }); + + return userAction; + }; + + const getUserAction = async (userId: string, action: string) => { + const userAction = await userDal.findOneUserAction({ action, userId }); + return userAction; + }; + + return { + toggleUserMfa, + updateUserName, + updateAuthMethods, + deleteMe, + getMe, + createUserAction, + getUserAction + }; +}; diff --git a/backend-pg/src/services/user/user-types.ts b/backend-pg/src/services/user/user-types.ts new file mode 100644 index 000000000..e69de29bb diff --git a/backend-pg/tsconfig.json b/backend-pg/tsconfig.json index 9dccf2f1d..a3f99201e 100644 --- a/backend-pg/tsconfig.json +++ b/backend-pg/tsconfig.json @@ -22,6 +22,6 @@ "@server/*": ["./src/server/*"] } }, - "include": ["src/**/*"], + "include": ["src/**/*","scripts/**/*"], "exclude": ["node_modules"] } diff --git a/backend/src/routes/v1/membershipOrg.ts b/backend/src/routes/v1/membershipOrg.ts index 1c7c47f88..d841f0c4b 100644 --- a/backend/src/routes/v1/membershipOrg.ts +++ b/backend/src/routes/v1/membershipOrg.ts @@ -5,6 +5,8 @@ import { requireAuth, validateRequest } from "../../middleware"; import { membershipOrgController } from "../../controllers/v1"; import { AuthMode } from "../../variables"; +// depreciated completely +// ignored for new codebase router.post( // TODO endpoint: check dashboard "/membershipOrg/:membershipOrgId/change-role", diff --git a/docker-compose.pg.yml b/docker-compose.pg.yml index 8f8fe9cc4..87513d2be 100644 --- a/docker-compose.pg.yml +++ b/docker-compose.pg.yml @@ -25,6 +25,7 @@ services: POSTGRES_DB: infisical backend: + container_name: infisical-dev-api build: context: ./backend-pg dockerfile: Dockerfile.dev diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 697fe69eb..3d32161d1 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -72,7 +72,7 @@ export const completeAccountSignup = async (details: CompleteAccountSignupDTO) = }; export const completeAccountSignupInvite = async (details: CompleteAccountDTO) => { - const { data } = await apiRequest.post("/api/v2/signup/complete-account/invite", details); + const { data } = await apiRequest.post("/api/v3/signup/complete-account/invite", details); return data; }; diff --git a/frontend/src/hooks/api/incidentContacts/queries.tsx b/frontend/src/hooks/api/incidentContacts/queries.tsx index aecf56a3e..98f95d785 100644 --- a/frontend/src/hooks/api/incidentContacts/queries.tsx +++ b/frontend/src/hooks/api/incidentContacts/queries.tsx @@ -42,10 +42,10 @@ export const useDeleteIncidentContact = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, DeleteIncidentContactDTO>({ - mutationFn: async ({ orgId, email }) => { - const { data } = await apiRequest.delete(`/api/v1/organization/${orgId}/incidentContactOrg`, { - data: { email } - }); + mutationFn: async ({ orgId, incidentContactId }) => { + const { data } = await apiRequest.delete( + `/api/v1/organization/${orgId}/incidentContactOrg/${incidentContactId}` + ); return data; }, onSuccess: (_, { orgId }) => { diff --git a/frontend/src/hooks/api/incidentContacts/types.ts b/frontend/src/hooks/api/incidentContacts/types.ts index bf79e2f83..2c93fb7f9 100644 --- a/frontend/src/hooks/api/incidentContacts/types.ts +++ b/frontend/src/hooks/api/incidentContacts/types.ts @@ -9,7 +9,7 @@ export type IncidentContact = { export type DeleteIncidentContactDTO = { orgId: string; - email: string; + incidentContactId: string; }; export type AddIncidentContactDTO = { diff --git a/frontend/src/hooks/api/roles/queries.tsx b/frontend/src/hooks/api/roles/queries.tsx index 5694ccfd6..f4d29073f 100644 --- a/frontend/src/hooks/api/roles/queries.tsx +++ b/frontend/src/hooks/api/roles/queries.tsx @@ -67,13 +67,11 @@ const getUserOrgPermissions = async ({ orgId }: TGetUserOrgPermissionsDTO) => { if (orgId === "") return { permissions: [], membership: null }; const { data } = await apiRequest.get<{ - data: { - permissions: PackRule>>[]; - membership: OrgUser; - }; - }>(`/api/v1/roles/organization/${orgId}/permissions`); + permissions: PackRule>>[]; + membership: OrgUser; + }>(`/api/ee/v1/organization/${orgId}/permissions`); - return data.data; + return data; }; export const useGetUserOrgPermissions = ({ orgId }: TGetUserOrgPermissionsDTO) => diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 715e7b9c0..82829e410 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -82,13 +82,14 @@ const App = ({ Component, pageProps, ...appProps }: NextAppProp): JSX.Element => publicPaths.includes(`/${appProps.router.pathname.split("/")[1]}`) || !Component.requireAuth ) { - // TODO(akhilmhdh): bring back server config later return ( - - - + + + + + ); diff --git a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx index 53fd509c3..73abbb56e 100644 --- a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx +++ b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx @@ -95,6 +95,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: } } } catch (err) { + console.error(err); setLoginError(true); createNotification({ text: "Login unsuccessful. Double-check your credentials and try again.", @@ -236,7 +237,7 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: ) : (
)} -
+
Forgot password? Recover your account diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/OrgIncidentContactsTable.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/OrgIncidentContactsTable.tsx index df5ab5f5c..05465fe4b 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/OrgIncidentContactsTable.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgIncidentContactsSection/OrgIncidentContactsTable.tsx @@ -35,12 +35,12 @@ export const OrgIncidentContactsTable = () => { const onRemoveIncidentContact = async () => { try { - const incidentContactEmail = (popUp?.removeContact?.data as { email: string })?.email; + const incidentContactId = (popUp?.removeContact?.data as { id: string })?.id; if (!currentOrg?.id) return; await mutateAsync({ orgId: currentOrg.id, - email: incidentContactEmail + incidentContactId }); createNotification({ @@ -80,7 +80,7 @@ export const OrgIncidentContactsTable = () => { {isLoading && } - {filteredContacts?.map(({ email }) => ( + {filteredContacts?.map(({ email, id }) => ( {email} @@ -92,7 +92,7 @@ export const OrgIncidentContactsTable = () => { handlePopUpOpen("removeContact", { email })} + onClick={() => handlePopUpOpen("removeContact", { email, id })} isDisabled={!isAllowed} >