diff --git a/backend/package-lock.json b/backend/package-lock.json index 15c3e6236..0423f0cb0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,6 +16,7 @@ "@sentry/tracing": "^7.46.0", "@types/crypto-js": "^4.1.1", "@types/libsodium-wrappers": "^0.7.10", + "argon2": "^0.30.3", "await-to-js": "^3.0.0", "aws-sdk": "^2.1338.0", "axios": "^1.1.3", @@ -3581,6 +3582,14 @@ "@octokit/openapi-types": "^16.0.0" } }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "engines": { + "node": ">=10" + } + }, "node_modules/@posthog/plugin-scaffold": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/@posthog/plugin-scaffold/-/plugin-scaffold-1.4.2.tgz", @@ -3644,58 +3653,6 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, - "node_modules/@sentry-internal/tracing": { - "version": "7.45.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.45.0.tgz", - "integrity": "sha512-0aIDY2OvUX7k2XHaimOlWkboXoQvJ9dEKvfpu0Wh0YxfUTGPa+wplUdg3WVdkk018sq1L11MKmj4MPZyYUvXhw==", - "dependencies": { - "@sentry/core": "7.45.0", - "@sentry/types": "7.45.0", - "@sentry/utils": "7.45.0", - "tslib": "^1.9.3" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@sentry-internal/tracing/node_modules/@sentry/core": { - "version": "7.45.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.45.0.tgz", - "integrity": "sha512-xJfdTS4lRmHvZI/A5MazdnKhBJFkisKu6G9EGNLlZLre+6W4PH5sb7QX4+xoBdqG7v10Jvdia112vi762ojO2w==", - "dependencies": { - "@sentry/types": "7.45.0", - "@sentry/utils": "7.45.0", - "tslib": "^1.9.3" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@sentry-internal/tracing/node_modules/@sentry/types": { - "version": "7.45.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.45.0.tgz", - "integrity": "sha512-iFt7msfUK8LCodFF3RKUyaxy9tJv/gpWhzxUFyNxtuVwlpmd+q6mtsFGn8Af3pbpm8A+MKyz1ebMwXj0PQqknw==", - "engines": { - "node": ">=8" - } - }, - "node_modules/@sentry-internal/tracing/node_modules/@sentry/utils": { - "version": "7.45.0", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.45.0.tgz", - "integrity": "sha512-aTY7qqtNUudd09SH5DVSKMm3iQ6ZeWufduc0I9bPZe6UMM09BDc4KmjmrzRkdQ+VaOmHo7+v+HZKQk5f+AbuTQ==", - "dependencies": { - "@sentry/types": "7.45.0", - "tslib": "^1.9.3" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@sentry-internal/tracing/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, "node_modules/@sentry/core": { "version": "7.46.0", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.46.0.tgz", @@ -3714,24 +3671,6 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, - "node_modules/@sentry/node": { - "version": "7.45.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-7.45.0.tgz", - "integrity": "sha512-x8mq+DrJWpSi716Rap/2w70DKWD8vjl87Y70OYFu+Dn6CxWDHClObSxLzuJcE5lww0Sq9RnU6UHQWzjXSb/pVQ==", - "dependencies": { - "@sentry/types": "7.46.0", - "@sentry/utils": "7.46.0", - "tslib": "^1.9.3" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@sentry/core/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - }, "node_modules/@sentry/node": { "version": "7.46.0", "resolved": "https://registry.npmjs.org/@sentry/node/-/node-7.46.0.tgz", @@ -4728,6 +4667,20 @@ "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "dev": true }, + "node_modules/argon2": { + "version": "0.30.3", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.30.3.tgz", + "integrity": "sha512-DoH/kv8c9127ueJSBxAVJXinW9+EuPA3EMUxoV2sAY1qDE5H9BjTyVF/aD2XyHqbqUWabgBkIfcP3ZZuGhbJdg==", + "hasInstallScript": true, + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.10", + "@phc/format": "^1.0.0", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4800,9 +4753,9 @@ } }, "node_modules/aws-sdk": { - "version": "2.1359.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1359.0.tgz", - "integrity": "sha512-uGNIU4czx8P0YITV8uhuLFhmyYvLWsFYINlHJX77/fea4VuTwcCGktYy2OEnrErp3FK9NHQvwXBxZCbY0lcxBg==", + "version": "2.1358.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1358.0.tgz", + "integrity": "sha512-ZolqFlnm0mDNgub7FGrVi7r5A1rw+58zZziKhlis3IxOtIpHdx4BQU5pH4htAMuD0Ct557p/dC/wmnZH/1Rc9Q==", "dependencies": { "buffer": "4.9.2", "events": "1.1.1", @@ -15949,6 +15902,11 @@ "@octokit/openapi-types": "^16.0.0" } }, + "@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==" + }, "@posthog/plugin-scaffold": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/@posthog/plugin-scaffold/-/plugin-scaffold-1.4.2.tgz", @@ -16012,48 +15970,6 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, - "@sentry-internal/tracing": { - "version": "7.45.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.45.0.tgz", - "integrity": "sha512-0aIDY2OvUX7k2XHaimOlWkboXoQvJ9dEKvfpu0Wh0YxfUTGPa+wplUdg3WVdkk018sq1L11MKmj4MPZyYUvXhw==", - "requires": { - "@sentry/core": "7.45.0", - "@sentry/types": "7.45.0", - "@sentry/utils": "7.45.0", - "tslib": "^1.9.3" - }, - "dependencies": { - "@sentry/core": { - "version": "7.45.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.45.0.tgz", - "integrity": "sha512-xJfdTS4lRmHvZI/A5MazdnKhBJFkisKu6G9EGNLlZLre+6W4PH5sb7QX4+xoBdqG7v10Jvdia112vi762ojO2w==", - "requires": { - "@sentry/types": "7.45.0", - "@sentry/utils": "7.45.0", - "tslib": "^1.9.3" - } - }, - "@sentry/types": { - "version": "7.45.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.45.0.tgz", - "integrity": "sha512-iFt7msfUK8LCodFF3RKUyaxy9tJv/gpWhzxUFyNxtuVwlpmd+q6mtsFGn8Af3pbpm8A+MKyz1ebMwXj0PQqknw==" - }, - "@sentry/utils": { - "version": "7.45.0", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.45.0.tgz", - "integrity": "sha512-aTY7qqtNUudd09SH5DVSKMm3iQ6ZeWufduc0I9bPZe6UMM09BDc4KmjmrzRkdQ+VaOmHo7+v+HZKQk5f+AbuTQ==", - "requires": { - "@sentry/types": "7.45.0", - "tslib": "^1.9.3" - } - }, - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, "@sentry/core": { "version": "7.46.0", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.46.0.tgz", @@ -16071,23 +15987,6 @@ } } }, - "@sentry/node": { - "version": "7.45.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-7.45.0.tgz", - "integrity": "sha512-x8mq+DrJWpSi716Rap/2w70DKWD8vjl87Y70OYFu+Dn6CxWDHClObSxLzuJcE5lww0Sq9RnU6UHQWzjXSb/pVQ==", - "requires": { - "@sentry/types": "7.46.0", - "@sentry/utils": "7.46.0", - "tslib": "^1.9.3" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, "@sentry/node": { "version": "7.46.0", "resolved": "https://registry.npmjs.org/@sentry/node/-/node-7.46.0.tgz", @@ -16168,27 +16067,6 @@ } } }, - "@sentry/types": { - "version": "7.46.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.46.0.tgz", - "integrity": "sha512-2FMEMgt2h6u7AoELhNhu9L54GAh67KKfK2pJ1kEXJHmWxM9FSCkizjLs/t+49xtY7jEXr8qYq8bV967VfDPQ9g==" - }, - "@sentry/utils": { - "version": "7.46.0", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.46.0.tgz", - "integrity": "sha512-elRezDAF84guMG0OVIIZEWm6wUpgbda4HGks98CFnPsrnMm3N1bdBI9XdlxYLtf+ir5KsGR5YlEIf/a0kRUwAQ==", - "requires": { - "@sentry/types": "7.46.0", - "tslib": "^1.9.3" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, "@sinclair/typebox": { "version": "0.25.24", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz", @@ -16884,6 +16762,16 @@ "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", "dev": true }, + "argon2": { + "version": "0.30.3", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.30.3.tgz", + "integrity": "sha512-DoH/kv8c9127ueJSBxAVJXinW9+EuPA3EMUxoV2sAY1qDE5H9BjTyVF/aD2XyHqbqUWabgBkIfcP3ZZuGhbJdg==", + "requires": { + "@mapbox/node-pre-gyp": "^1.0.10", + "@phc/format": "^1.0.0", + "node-addon-api": "^5.0.0" + } + }, "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -16938,9 +16826,9 @@ "integrity": "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==" }, "aws-sdk": { - "version": "2.1359.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1359.0.tgz", - "integrity": "sha512-uGNIU4czx8P0YITV8uhuLFhmyYvLWsFYINlHJX77/fea4VuTwcCGktYy2OEnrErp3FK9NHQvwXBxZCbY0lcxBg==", + "version": "2.1358.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1358.0.tgz", + "integrity": "sha512-ZolqFlnm0mDNgub7FGrVi7r5A1rw+58zZziKhlis3IxOtIpHdx4BQU5pH4htAMuD0Ct557p/dC/wmnZH/1Rc9Q==", "requires": { "buffer": "4.9.2", "events": "1.1.1", diff --git a/backend/package.json b/backend/package.json index 34659dcdd..084f6b08e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,6 +8,7 @@ "@sentry/node": "^7.41.0", "@types/crypto-js": "^4.1.1", "@types/libsodium-wrappers": "^0.7.10", + "argon2": "^0.30.3", "await-to-js": "^3.0.0", "aws-sdk": "^2.1338.0", "axios": "^1.1.3", diff --git a/backend/spec.json b/backend/spec.json index f0af8d903..013b5fd4d 100644 --- a/backend/spec.json +++ b/backend/spec.json @@ -1532,7 +1532,15 @@ "/api/v1/invite-org/signup": { "post": { "description": "", - "parameters": [], + "parameters": [ + { + "name": "host", + "in": "header", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "OK" @@ -2071,6 +2079,15 @@ "targetEnvironment": { "example": "any" }, + "targetEnvironmentId": { + "example": "any" + }, + "targetService": { + "example": "any" + }, + "targetServiceId": { + "example": "any" + }, "owner": { "example": "any" }, @@ -2297,6 +2314,13 @@ "schema": { "type": "string" } + }, + { + "name": "teamId", + "in": "query", + "schema": { + "type": "string" + } } ], "responses": { @@ -2309,6 +2333,107 @@ } } }, + "/api/v1/integration-auth/{integrationAuthId}/teams": { + "get": { + "description": "", + "parameters": [ + { + "name": "integrationAuthId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/integration-auth/{integrationAuthId}/vercel/branches": { + "get": { + "description": "", + "parameters": [ + { + "name": "integrationAuthId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "appId", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/integration-auth/{integrationAuthId}/railway/environments": { + "get": { + "description": "", + "parameters": [ + { + "name": "integrationAuthId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "appId", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v1/integration-auth/{integrationAuthId}/railway/services": { + "get": { + "description": "", + "parameters": [ + { + "name": "integrationAuthId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "appId", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/v2/signup/complete-account/signup": { "post": { "description": "", @@ -2870,9 +2995,6 @@ } } } - }, - "400": { - "description": "Bad Request" } }, "security": [ @@ -2882,6 +3004,26 @@ ] } }, + "/api/v2/organizations/{organizationId}/service-accounts": { + "get": { + "description": "", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, "/api/v2/workspace/{workspaceId}/environments": { "post": { "description": "", @@ -4018,9 +4160,6 @@ "responses": { "200": { "description": "OK" - }, - "400": { - "description": "Bad Request" } }, "requestBody": { @@ -4073,6 +4212,138 @@ } } ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v2/service-accounts/me": { + "get": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v2/service-accounts/{serviceAccountId}": { + "get": { + "description": "", + "parameters": [ + { + "name": "serviceAccountId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "delete": { + "description": "", + "parameters": [ + { + "name": "serviceAccountId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v2/service-accounts/": { + "post": { + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v2/service-accounts/{serviceAccountId}/name": { + "patch": { + "description": "", + "parameters": [ + { + "name": "serviceAccountId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v2/service-accounts/{serviceAccountId}/permissions/workspace": { + "get": { + "description": "", + "parameters": [ + { + "name": "serviceAccountId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "post": { + "description": "", + "parameters": [ + { + "name": "serviceAccountId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "OK" @@ -4080,6 +4351,90 @@ "400": { "description": "Bad Request" } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "environment": { + "example": "any" + }, + "workspaceId": { + "example": "any" + }, + "read": { + "example": "any" + }, + "write": { + "example": "any" + }, + "encryptedKey": { + "example": "any" + }, + "nonce": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v2/service-accounts/{serviceAccountId}/permissions/workspace/{serviceAccountWorkspacePermissionId}": { + "delete": { + "description": "", + "parameters": [ + { + "name": "serviceAccountId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "serviceAccountWorkspacePermissionId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v2/service-accounts/{serviceAccountId}/keys": { + "get": { + "description": "", + "parameters": [ + { + "name": "serviceAccountId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "workspaceId", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } } } }, @@ -4149,6 +4504,297 @@ } } }, + "/api/v3/secrets/": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "environment", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v3/secrets/{secretName}": { + "post": { + "description": "", + "parameters": [ + { + "name": "secretName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspaceId": { + "example": "any" + }, + "environment": { + "example": "any" + }, + "type": { + "example": "any" + }, + "secretKeyCiphertext": { + "example": "any" + }, + "secretKeyIV": { + "example": "any" + }, + "secretKeyTag": { + "example": "any" + }, + "secretValueCiphertext": { + "example": "any" + }, + "secretValueIV": { + "example": "any" + }, + "secretValueTag": { + "example": "any" + }, + "secretCommentCiphertext": { + "example": "any" + }, + "secretCommentIV": { + "example": "any" + }, + "secretCommentTag": { + "example": "any" + } + } + } + } + } + } + }, + "get": { + "description": "", + "parameters": [ + { + "name": "secretName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "workspaceId", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "environment", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "type", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + }, + "patch": { + "description": "", + "parameters": [ + { + "name": "secretName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspaceId": { + "example": "any" + }, + "environment": { + "example": "any" + }, + "type": { + "example": "any" + }, + "secretValueCiphertext": { + "example": "any" + }, + "secretValueIV": { + "example": "any" + }, + "secretValueTag": { + "example": "any" + } + } + } + } + } + } + }, + "delete": { + "description": "", + "parameters": [ + { + "name": "secretName", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspaceId": { + "example": "any" + }, + "environment": { + "example": "any" + }, + "type": { + "example": "any" + } + } + } + } + } + } + } + }, + "/api/v3/workspaces/{workspaceId}/secrets/blind-index-status": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v3/workspaces/{workspaceId}/secrets": { + "get": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + }, + "/api/v3/workspaces/{workspaceId}/secrets/names": { + "post": { + "description": "", + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secretsToUpdate": { + "example": "any" + } + } + } + } + } + } + } + }, "/api/status": { "get": { "description": "", diff --git a/backend/src/controllers/v1/botController.ts b/backend/src/controllers/v1/botController.ts index ab86897cb..0fdce2cd9 100644 --- a/backend/src/controllers/v1/botController.ts +++ b/backend/src/controllers/v1/botController.ts @@ -1,4 +1,5 @@ import { Request, Response } from 'express'; +import { Types } from 'mongoose'; import * as Sentry from '@sentry/node'; import { Bot, BotKey } from '../../models'; import { createBot } from '../../helpers/bot'; @@ -29,7 +30,7 @@ export const getBotByWorkspaceId = async (req: Request, res: Response) => { // -> create a new bot and return it bot = await createBot({ name: 'Infisical Bot', - workspaceId + workspaceId: new Types.ObjectId(workspaceId) }); } } catch (err) { diff --git a/backend/src/controllers/v1/integrationController.ts b/backend/src/controllers/v1/integrationController.ts index 6b633fc2c..060f89b33 100644 --- a/backend/src/controllers/v1/integrationController.ts +++ b/backend/src/controllers/v1/integrationController.ts @@ -56,7 +56,8 @@ export const createIntegration = async (req: Request, res: Response) => { // trigger event - push secrets EventService.handleEvent({ event: eventPushSecrets({ - workspaceId: integration.workspace.toString() + workspaceId: integration.workspace, + environment: sourceEnvironment }) }); } @@ -117,7 +118,8 @@ export const updateIntegration = async (req: Request, res: Response) => { // trigger event - push secrets EventService.handleEvent({ event: eventPushSecrets({ - workspaceId: integration.workspace.toString(), + workspaceId: integration.workspace, + environment }), }); } diff --git a/backend/src/controllers/v1/secretController.ts b/backend/src/controllers/v1/secretController.ts index 4b377ec72..3ec69122a 100644 --- a/backend/src/controllers/v1/secretController.ts +++ b/backend/src/controllers/v1/secretController.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import * as Sentry from '@sentry/node'; +import { Types } from 'mongoose'; import { Key, Secret } from '../../models'; import { v1PushSecrets as push, @@ -84,7 +85,8 @@ export const pushSecrets = async (req: Request, res: Response) => { // trigger event - push secrets EventService.handleEvent({ event: eventPushSecrets({ - workspaceId + workspaceId: new Types.ObjectId(workspaceId), + environment }) }); diff --git a/backend/src/controllers/v2/secretController.ts b/backend/src/controllers/v2/secretController.ts index 91328ea7b..d551e8aa4 100644 --- a/backend/src/controllers/v2/secretController.ts +++ b/backend/src/controllers/v2/secretController.ts @@ -8,6 +8,8 @@ import { BadRequestError, InternalServerError, UnauthorizedRequestError, Validat import { AnyBulkWriteOperation } from 'mongodb'; import { SECRET_PERSONAL, SECRET_SHARED } from "../../variables"; import { TelemetryService } from '../../services'; +import { User } from "../../models"; +import { AccountNotFoundError } from '../../utils/errors'; /** * Create secret for workspace with id [workspaceId] and environment [environment] @@ -340,15 +342,18 @@ export const getSecrets = async (req: Request, res: Response) => { const { workspaceId } = req.params; let userId: Types.ObjectId | undefined = undefined // used for getting personal secrets for user - let userEmail: Types.ObjectId | undefined = undefined // used for posthog + let userEmail: string | undefined = undefined // used for posthog if (req.user) { userId = req.user._id; userEmail = req.user.email; } if (req.serviceTokenData) { - userId = req.serviceTokenData.user._id - userEmail = req.serviceTokenData.user.email; + userId = req.serviceTokenData.user; + + const user = await User.findById(req.serviceTokenData.user, 'email'); + if (!user) throw AccountNotFoundError(); + userEmail = user.email; } const [err, secrets] = await to(Secret.find( diff --git a/backend/src/controllers/v2/secretsController.ts b/backend/src/controllers/v2/secretsController.ts index 82b16edd6..f7b8349ff 100644 --- a/backend/src/controllers/v2/secretsController.ts +++ b/backend/src/controllers/v2/secretsController.ts @@ -2,7 +2,7 @@ import to from 'await-to-js'; import { Types } from 'mongoose'; import { Request, Response } from 'express'; import { ISecret, Secret } from '../../models'; -import { IAction } from '../../ee/models'; +import { IAction, SecretVersion } from '../../ee/models'; import { SECRET_PERSONAL, SECRET_SHARED, @@ -15,7 +15,7 @@ import { UnauthorizedRequestError, ValidationError } from '../../utils/errors'; import { EventService } from '../../services'; import { eventPushSecrets } from '../../events'; import { EESecretService, EELogService } from '../../ee/services'; -import { TelemetryService } from '../../services'; +import { TelemetryService, SecretService } from '../../services'; import { getChannelFromUserAgent } from '../../utils/posthog'; import { PERMISSION_WRITE_SECRETS } from '../../variables'; import { userHasNoAbility, userHasWorkspaceAccess, userHasWriteOnlyAbility } from '../../ee/helpers/checkMembershipPermissions'; @@ -33,6 +33,7 @@ import { * @param res */ export const batchSecrets = async (req: Request, res: Response) => { + const channel = getChannelFromUserAgent(req.headers['user-agent']); const postHogClient = TelemetryService.getPostHogClient(); @@ -50,29 +51,47 @@ export const batchSecrets = async (req: Request, res: Response) => { const updateSecrets: BatchSecret[] = []; const deleteSecrets: Types.ObjectId[] = []; const actions: IAction[] = []; + + // get secret blind index salt + const salt = await SecretService.getSecretBlindIndexSalt({ + workspaceId: new Types.ObjectId(workspaceId) + }); - requests.forEach((request) => { + for await (const request of requests) { + let secretBlindIndex = ''; switch (request.method) { case 'POST': + secretBlindIndex = await SecretService.generateSecretBlindIndexWithSalt({ + secretName: request.secret.secretName, + salt + }); + createSecrets.push({ ...request.secret, version: 1, user: request.secret.type === SECRET_PERSONAL ? req.user : undefined, environment, - workspace: new Types.ObjectId(workspaceId) + workspace: new Types.ObjectId(workspaceId), + secretBlindIndex }); break; case 'PATCH': + secretBlindIndex = await SecretService.generateSecretBlindIndexWithSalt({ + secretName: request.secret.secretName, + salt + }); + updateSecrets.push({ ...request.secret, - _id: new Types.ObjectId(request.secret._id) + _id: new Types.ObjectId(request.secret._id), + secretBlindIndex }); break; case 'DELETE': deleteSecrets.push(new Types.ObjectId(request.secret._id)); break; } - }); + } // handle create secrets let createdSecrets: ISecret[] = []; @@ -133,7 +152,10 @@ export const batchSecrets = async (req: Request, res: Response) => { const updateOperations = updateSecrets.map((u) => ({ updateOne: { - filter: { _id: new Types.ObjectId(u._id) }, + filter: { + _id: new Types.ObjectId(u._id), + workspace: new Types.ObjectId(workspaceId) + }, update: { $inc: { version: 1 @@ -146,13 +168,14 @@ export const batchSecrets = async (req: Request, res: Response) => { await Secret.bulkWrite(updateOperations); - const secretVersions = updateSecrets.map((u) => ({ + const secretVersions = updateSecrets.map((u) => new SecretVersion({ secret: new Types.ObjectId(u._id), version: listedSecretsObj[u._id.toString()].version, workspace: new Types.ObjectId(workspaceId), type: listedSecretsObj[u._id.toString()].type, environment, isDeleted: false, + secretBlindIndex: u.secretBlindIndex, secretKeyCiphertext: u.secretKeyCiphertext, secretKeyIV: u.secretKeyIV, secretKeyTag: u.secretKeyTag, @@ -247,13 +270,13 @@ export const batchSecrets = async (req: Request, res: Response) => { // // trigger event - push secrets await EventService.handleEvent({ event: eventPushSecrets({ - workspaceId + workspaceId: new Types.ObjectId(workspaceId) }) }); // (EE) take a secret snapshot await EESecretService.takeSecretSnapshot({ - workspaceId + workspaceId: new Types.ObjectId(workspaceId) }); const resObj: { [key: string]: ISecret[] | string[] } = {} @@ -351,8 +374,14 @@ export const createSecrets = async (req: Request, res: Response) => { listOfSecretsToCreate = [req.body.secrets]; } + // get secret blind index salt + const salt = await SecretService.getSecretBlindIndexSalt({ + workspaceId: new Types.ObjectId(workspaceId) + }); + type secretsToCreateType = { type: string; + secretName?: string; secretKeyCiphertext: string; secretKeyIV: string; secretKeyTag: string; @@ -365,25 +394,10 @@ export const createSecrets = async (req: Request, res: Response) => { tags: string[] } - const secretsToInsert: ISecret[] = listOfSecretsToCreate.map(({ - type, - secretKeyCiphertext, - secretKeyIV, - secretKeyTag, - secretValueCiphertext, - secretValueIV, - secretValueTag, - secretCommentCiphertext, - secretCommentIV, - secretCommentTag, - tags - }: secretsToCreateType) => { - return ({ - version: 1, - workspace: new Types.ObjectId(workspaceId), + const secretsToInsert: ISecret[] = await Promise.all( + listOfSecretsToCreate.map(async ({ type, - user: (req.user && type === SECRET_PERSONAL) ? req.user : undefined, - environment, + secretName, secretKeyCiphertext, secretKeyIV, secretKeyTag, @@ -394,16 +408,43 @@ export const createSecrets = async (req: Request, res: Response) => { secretCommentIV, secretCommentTag, tags - }); - }); + }: secretsToCreateType) => { + let secretBlindIndex; + if (secretName) { + secretBlindIndex = await SecretService.generateSecretBlindIndexWithSalt({ + secretName, + salt + }); + } + return ({ + version: 1, + workspace: new Types.ObjectId(workspaceId), + type, + ...(secretBlindIndex ? { secretBlindIndex } : {}), + user: (req.user && type === SECRET_PERSONAL) ? req.user : undefined, + environment, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag, + tags + }); + }) + ); + const newlyCreatedSecrets: ISecret[] = (await Secret.insertMany(secretsToInsert)).map((insertedSecret) => insertedSecret.toObject()); - + setTimeout(async () => { // trigger event - push secrets await EventService.handleEvent({ event: eventPushSecrets({ - workspaceId + workspaceId: new Types.ObjectId(workspaceId) }) }); }, 5000); @@ -417,35 +458,28 @@ export const createSecrets = async (req: Request, res: Response) => { type, user, environment, + secretBlindIndex, secretKeyCiphertext, secretKeyIV, secretKeyTag, secretValueCiphertext, secretValueIV, - secretValueTag, - secretCommentCiphertext, - secretCommentIV, - secretCommentTag, - tags - }) => ({ - _id: new Types.ObjectId(), + secretValueTag + }) => new SecretVersion({ secret: _id, version, workspace, type, user, environment, + secretBlindIndex, isDeleted: false, secretKeyCiphertext, secretKeyIV, secretKeyTag, secretValueCiphertext, secretValueIV, - secretValueTag, - secretCommentCiphertext, - secretCommentIV, - secretCommentTag, - tags + secretValueTag })) }); @@ -471,17 +505,15 @@ export const createSecrets = async (req: Request, res: Response) => { // (EE) take a secret snapshot await EESecretService.takeSecretSnapshot({ - workspaceId + workspaceId: new Types.ObjectId(workspaceId) }); const postHogClient = TelemetryService.getPostHogClient(); if (postHogClient) { postHogClient.capture({ event: 'secrets added', - distinctId: TelemetryService.getDistinctId({ - user: req.user, - serviceAccount: req.serviceAccount, - serviceTokenData: req.serviceTokenData + distinctId: await TelemetryService.getDistinctId({ + authData: req.authData }), properties: { numberOfSecrets: listOfSecretsToCreate.length, @@ -595,7 +627,7 @@ export const getSecrets = async (req: Request, res: Response) => { // case: client authorization is via service token if (req.serviceTokenData) { - const userId = req.serviceTokenData.user._id + const userId = req.serviceTokenData.user; const secretQuery: any = { workspace: workspaceId, @@ -655,10 +687,8 @@ export const getSecrets = async (req: Request, res: Response) => { if (postHogClient) { postHogClient.capture({ event: 'secrets pulled', - distinctId: TelemetryService.getDistinctId({ - user: req.user, - serviceAccount: req.serviceAccount, - serviceTokenData: req.serviceTokenData + distinctId: await TelemetryService.getDistinctId({ + authData: req.authData }), properties: { numberOfSecrets: secrets.length, @@ -845,7 +875,7 @@ export const updateSecrets = async (req: Request, res: Response) => { setTimeout(async () => { await EventService.handleEvent({ event: eventPushSecrets({ - workspaceId: key + workspaceId: new Types.ObjectId(key) }) }); }, 10000); @@ -872,17 +902,15 @@ export const updateSecrets = async (req: Request, res: Response) => { // (EE) take a secret snapshot await EESecretService.takeSecretSnapshot({ - workspaceId: key + workspaceId: new Types.ObjectId(key) }) const postHogClient = TelemetryService.getPostHogClient(); if (postHogClient) { postHogClient.capture({ event: 'secrets modified', - distinctId: TelemetryService.getDistinctId({ - user: req.user, - serviceAccount: req.serviceAccount, - serviceTokenData: req.serviceTokenData + distinctId: await TelemetryService.getDistinctId({ + authData: req.authData }), properties: { numberOfSecrets: workspaceSecretObj[key].length, @@ -955,10 +983,6 @@ export const deleteSecrets = async (req: Request, res: Response) => { } */ - return res.status(200).send({ - message: 'delete secrets!!' - }); - const channel = getChannelFromUserAgent(req.headers['user-agent']) const toDelete = req.secrets.map((s: any) => s._id); @@ -987,7 +1011,7 @@ export const deleteSecrets = async (req: Request, res: Response) => { // trigger event - push secrets await EventService.handleEvent({ event: eventPushSecrets({ - workspaceId: key + workspaceId: new Types.ObjectId(key) }) }); const deleteAction = await EELogService.createAction({ @@ -1012,17 +1036,15 @@ export const deleteSecrets = async (req: Request, res: Response) => { // (EE) take a secret snapshot await EESecretService.takeSecretSnapshot({ - workspaceId: key - }) + workspaceId: new Types.ObjectId(key) + }); const postHogClient = TelemetryService.getPostHogClient(); if (postHogClient) { postHogClient.capture({ event: 'secrets deleted', - distinctId: TelemetryService.getDistinctId({ - user: req.user, - serviceAccount: req.serviceAccount, - serviceTokenData: req.serviceTokenData + distinctId: await TelemetryService.getDistinctId({ + authData: req.authData }), properties: { numberOfSecrets: workspaceSecretObj[key].length, diff --git a/backend/src/controllers/v2/serviceTokenDataController.ts b/backend/src/controllers/v2/serviceTokenDataController.ts index 328749472..332e7e34a 100644 --- a/backend/src/controllers/v2/serviceTokenDataController.ts +++ b/backend/src/controllers/v2/serviceTokenDataController.ts @@ -11,9 +11,11 @@ import { userHasWorkspaceAccess } from '../../ee/helpers/checkMembershipPermissi import { PERMISSION_READ_SECRETS, AUTH_MODE_JWT, - AUTH_MODE_SERVICE_ACCOUNT + AUTH_MODE_SERVICE_ACCOUNT, + AUTH_MODE_SERVICE_TOKEN } from '../../variables'; import { getSaltRounds } from '../../config'; +import { BadRequestError } from '../../utils/errors'; /** * Return service token data associated with service token on request @@ -48,7 +50,16 @@ export const getServiceTokenData = async (req: Request, res: Response) => { } */ - return res.status(200).json(req.serviceTokenData); + if (!(req.authData.authPayload instanceof ServiceTokenData)) throw BadRequestError({ + message: 'Failed accepted client validation for service token data' + }); + + const serviceTokenData = await ServiceTokenData + .findById(req.authData.authPayload._id) + .select('+encryptedKey +iv +tag') + .populate('user'); + + return res.status(200).json(serviceTokenData); } /** @@ -76,7 +87,7 @@ export const createServiceTokenData = async (req: Request, res: Response) => { const secretHash = await bcrypt.hash(secret, getSaltRounds()); let expiresAt; - if (!!expiresIn) { + if (expiresIn) { expiresAt = new Date() expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn); } diff --git a/backend/src/controllers/v2/workspaceController.ts b/backend/src/controllers/v2/workspaceController.ts index ec32dcbaf..dd0efe91f 100644 --- a/backend/src/controllers/v2/workspaceController.ts +++ b/backend/src/controllers/v2/workspaceController.ts @@ -95,7 +95,8 @@ export const pushWorkspaceSecrets = async (req: Request, res: Response) => { // trigger event - push secrets EventService.handleEvent({ event: eventPushSecrets({ - workspaceId + workspaceId: new Types.ObjectId(workspaceId), + environment }) }); @@ -131,7 +132,7 @@ export const pullSecrets = async (req: Request, res: Response) => { if (req.user) { userId = req.user._id.toString(); } else if (req.serviceTokenData) { - userId = req.serviceTokenData.user._id + userId = req.serviceTokenData.user.toString(); } // validate environment const workspaceEnvs = req.membership.workspace.environments; diff --git a/backend/src/controllers/v3/index.ts b/backend/src/controllers/v3/index.ts new file mode 100644 index 000000000..1cbb36595 --- /dev/null +++ b/backend/src/controllers/v3/index.ts @@ -0,0 +1,7 @@ +import * as secretsController from './secretsController'; +import * as workspacesController from './workspacesController'; + +export { + secretsController, + workspacesController +} \ No newline at end of file diff --git a/backend/src/controllers/v3/secretsController.ts b/backend/src/controllers/v3/secretsController.ts new file mode 100644 index 000000000..dac41b53d --- /dev/null +++ b/backend/src/controllers/v3/secretsController.ts @@ -0,0 +1,183 @@ +import { Request, Response } from 'express'; +import { Types } from 'mongoose'; +import { + SecretService, + TelemetryService, + EventService +} from '../../services'; +import { eventPushSecrets } from '../../events'; +import { getAuthDataPayloadIdObj } from '../../utils/auth'; +import { BadRequestError } from '../../utils/errors'; + +/** + * Get secrets for workspace with id [workspaceId] and environment + * [environment] + * @param req + * @param res + */ +export const getSecrets = async (req: Request, res: Response) => { + const workspaceId = req.query.workspaceId as string; + const environment = req.query.environment as string; + + const secrets = await SecretService.getSecrets({ + workspaceId: new Types.ObjectId(workspaceId), + environment, + authData: req.authData + }); + + return res.status(200).send({ + secrets + }); +} + +/** + * Get secret with name [secretName] + * @param req + * @param res + */ +export const getSecretByName = async (req: Request, res: Response) => { + const { secretName } = req.params; + const workspaceId = req.query.workspaceId as string; + const environment = req.query.environment as string; + const type = req.query.type as 'shared' | 'personal' | undefined; + + const secret = await SecretService.getSecret({ + secretName, + workspaceId: new Types.ObjectId(workspaceId), + environment, + type, + authData: req.authData + }); + + return res.status(200).send({ + secret + }); +} + +/** + * Create secret with name [secretName] + * @param req + * @param res + */ +export const createSecret = async (req: Request, res: Response) => { + const { secretName } = req.params; + const { + workspaceId, + environment, + type, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag + } = req.body; + + const secret = await SecretService.createSecret({ + secretName, + workspaceId: new Types.ObjectId(workspaceId), + environment, + type, + authData: req.authData, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + ...((secretCommentCiphertext && secretCommentIV && secretCommentTag) ? { + secretCommentCiphertext, + secretCommentIV, + secretCommentTag + } : {}) + }); + + await EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId: new Types.ObjectId(workspaceId), + environment + }) + }); + + const secretWithoutBlindIndex = secret.toObject(); + delete secretWithoutBlindIndex.secretBlindIndex; + + return res.status(200).send({ + secret: secretWithoutBlindIndex + }); +} + +/** + * Update secret with name [secretName] + * @param req + * @param res + */ +export const updateSecretByName = async (req: Request, res: Response) => { + const { secretName } = req.params; + const { + workspaceId, + environment, + type, + secretValueCiphertext, + secretValueIV, + secretValueTag + } = req.body; + + const secret = await SecretService.updateSecret({ + secretName, + workspaceId, + environment, + type, + authData: req.authData, + secretValueCiphertext, + secretValueIV, + secretValueTag + }); + + await EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId: new Types.ObjectId(workspaceId), + environment + }) + }); + + return res.status(200).send({ + secret + }); +} + +/** + * Delete secret with name [secretName] + * @param req + * @param res + */ +export const deleteSecretByName = async (req: Request, res: Response) => { + const { secretName } = req.params; + const { + workspaceId, + environment, + type + } = req.body; + + const { secret, secrets } = await SecretService.deleteSecret({ + secretName, + workspaceId, + environment, + type, + authData: req.authData + }); + + await EventService.handleEvent({ + event: eventPushSecrets({ + workspaceId: new Types.ObjectId(workspaceId), + environment + }) + }); + + return res.status(200).send({ + secret + }); +} \ No newline at end of file diff --git a/backend/src/controllers/v3/workspacesController.ts b/backend/src/controllers/v3/workspacesController.ts new file mode 100644 index 000000000..aac6682d7 --- /dev/null +++ b/backend/src/controllers/v3/workspacesController.ts @@ -0,0 +1,90 @@ +import { Request, Response } from 'express'; +import { Types } from 'mongoose'; +import { Secret } from '../../models'; +import { SecretService } from'../../services'; + +/** + * Return whether or not all secrets in workspace with id [workspaceId] + * are blind-indexed + * @param req + * @param res + * @returns + */ +export const getWorkspaceBlindIndexStatus = async (req: Request, res: Response) => { + const { workspaceId } = req.params; + + const secretsWithoutBlindIndex = await Secret.countDocuments({ + workspace: new Types.ObjectId(workspaceId), + secretBlindIndex: { + $exists: false + } + }); + + return res.status(200).send(secretsWithoutBlindIndex === 0); +} + +/** + * Get all secrets for workspace with id [workspaceId] + */ +export const getWorkspaceSecrets = async (req: Request, res: Response) => { + const { workspaceId } = req.params; + + const secrets = await Secret.find({ + workspace: new Types.ObjectId (workspaceId) + }); + + return res.status(200).send({ + secrets + }); +} + +/** + * Update blind indices for secrets in workspace with id [workspaceId] + * @param req + * @param res + */ +export const nameWorkspaceSecrets = async (req: Request, res: Response) => { + interface SecretToUpdate { + secretName: string; + _id: string; + } + + const { workspaceId } = req.params; + const { + secretsToUpdate + }: { + secretsToUpdate: SecretToUpdate[]; + } = req.body; + + // get secret blind index salt + const salt = await SecretService.getSecretBlindIndexSalt({ + workspaceId: new Types.ObjectId(workspaceId) + }); + + // update secret blind indices + const operations = await Promise.all( + secretsToUpdate.map(async (secretToUpdate: SecretToUpdate) => { + const secretBlindIndex = await SecretService.generateSecretBlindIndexWithSalt({ + secretName: secretToUpdate.secretName, + salt + }); + + return ({ + updateOne: { + filter: { + _id: new Types.ObjectId(secretToUpdate._id) + }, + update: { + secretBlindIndex + } + } + }); + }) + ); + + await Secret.bulkWrite(operations); + + return res.status(200).send({ + message: 'Successfully named workspace secrets' + }); +} \ No newline at end of file diff --git a/backend/src/ee/controllers/v1/secretController.ts b/backend/src/ee/controllers/v1/secretController.ts index e1aca670f..cfdd93cf3 100644 --- a/backend/src/ee/controllers/v1/secretController.ts +++ b/backend/src/ee/controllers/v1/secretController.ts @@ -146,7 +146,7 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { const oldSecretVersion = await SecretVersion.findOne({ secret: secretId, version - }); + }).select('+secretBlindIndex') if (!oldSecretVersion) throw new Error('Failed to find secret version'); @@ -155,6 +155,7 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { type, user, environment, + secretBlindIndex, secretKeyCiphertext, secretKeyIV, secretKeyTag, @@ -174,6 +175,7 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { type, user, environment, + ...(secretBlindIndex ? { secretBlindIndex } : {}), secretKeyCiphertext, secretKeyIV, secretKeyTag, @@ -197,6 +199,7 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { user, environment, isDeleted: false, + ...(secretBlindIndex ? { secretBlindIndex } : {}), secretKeyCiphertext, secretKeyIV, secretKeyTag, @@ -207,7 +210,7 @@ export const rollbackSecretVersion = async (req: Request, res: Response) => { // take secret snapshot await EESecretService.takeSecretSnapshot({ - workspaceId: secret.workspace.toString() + workspaceId: secret.workspace }); } catch (err) { diff --git a/backend/src/ee/controllers/v1/secretSnapshotController.ts b/backend/src/ee/controllers/v1/secretSnapshotController.ts index ae3af7958..15b346f98 100644 --- a/backend/src/ee/controllers/v1/secretSnapshotController.ts +++ b/backend/src/ee/controllers/v1/secretSnapshotController.ts @@ -15,16 +15,10 @@ export const getSecretSnapshot = async (req: Request, res: Response) => { secretSnapshot = await SecretSnapshot .findById(secretSnapshotId) - .populate({ - path: 'secretVersions', - populate: { - path: 'tags', - model: 'Tag', - } - }); + .populate('secretVersions'); if (!secretSnapshot) throw new Error('Failed to find secret snapshot'); - + } catch (err) { Sentry.setUser({ email: req.user.email }); Sentry.captureException(err); diff --git a/backend/src/ee/controllers/v1/workspaceController.ts b/backend/src/ee/controllers/v1/workspaceController.ts index 5a18ae08e..e3f46eb71 100644 --- a/backend/src/ee/controllers/v1/workspaceController.ts +++ b/backend/src/ee/controllers/v1/workspaceController.ts @@ -173,6 +173,7 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons } } */ + let secrets; try { const { workspaceId } = req.params; @@ -182,7 +183,10 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons const secretSnapshot = await SecretSnapshot.findOne({ workspace: workspaceId, version - }).populate<{ secretVersions: ISecretVersion[]}>('secretVersions'); + }).populate<{ secretVersions: ISecretVersion[]}>({ + path: 'secretVersions', + select: '+secretBlindIndex' + }); if (!secretSnapshot) throw new Error('Failed to find secret snapshot'); @@ -222,6 +226,7 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons type, user, environment, + secretBlindIndex, secretKeyCiphertext, secretKeyIV, secretKeyTag, @@ -240,6 +245,7 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons type, user, environment, + secretBlindIndex: secretBlindIndex ?? undefined, secretKeyCiphertext, secretKeyIV, secretKeyTag, @@ -257,7 +263,7 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons ); // add secret versions - await SecretVersion.insertMany( + const secretV = await SecretVersion.insertMany( secrets.map(({ _id, version, @@ -265,6 +271,7 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons type, user, environment, + secretBlindIndex, secretKeyCiphertext, secretKeyIV, secretKeyTag, @@ -282,6 +289,7 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons user, environment, isDeleted: false, + secretBlindIndex: secretBlindIndex ?? undefined, secretKeyCiphertext, secretKeyIV, secretKeyTag, @@ -304,7 +312,7 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons // take secret snapshot await EESecretService.takeSecretSnapshot({ - workspaceId + workspaceId: new Types.ObjectId(workspaceId) }); } catch (err) { Sentry.setUser({ email: req.user.email }); diff --git a/backend/src/ee/helpers/log.ts b/backend/src/ee/helpers/log.ts index 77d280ee1..077d5fe5a 100644 --- a/backend/src/ee/helpers/log.ts +++ b/backend/src/ee/helpers/log.ts @@ -4,6 +4,7 @@ import { Log, IAction } from '../models'; + /** * Create an (audit) log * @param {Object} obj diff --git a/backend/src/ee/helpers/secret.ts b/backend/src/ee/helpers/secret.ts index 7edee915f..a642859ef 100644 --- a/backend/src/ee/helpers/secret.ts +++ b/backend/src/ee/helpers/secret.ts @@ -2,7 +2,7 @@ import { Types } from 'mongoose'; import * as Sentry from '@sentry/node'; import { Secret, - ISecret + ISecret, } from '../../models'; import { SecretSnapshot, @@ -21,7 +21,7 @@ import { const takeSecretSnapshotHelper = async ({ workspaceId }: { - workspaceId: string; + workspaceId: Types.ObjectId; }) => { let secretSnapshot; @@ -143,7 +143,7 @@ const initSecretVersioningHelper = async () => { if (unversionedSecrets.length > 0) { await addSecretVersionsHelper({ - secretVersions: unversionedSecrets.map((s, idx) => ({ + secretVersions: unversionedSecrets.map((s, idx) => new SecretVersion({ ...s, secret: s._id, version: s.version ? s.version : 1, diff --git a/backend/src/ee/models/secretVersion.ts b/backend/src/ee/models/secretVersion.ts index 3095b52fb..a430834a9 100644 --- a/backend/src/ee/models/secretVersion.ts +++ b/backend/src/ee/models/secretVersion.ts @@ -5,6 +5,7 @@ import { } from '../../variables'; export interface ISecretVersion { + _id: Types.ObjectId; secret: Types.ObjectId; version: number; workspace: Types.ObjectId; // new @@ -12,13 +13,13 @@ export interface ISecretVersion { user?: Types.ObjectId; // new environment: string; // new isDeleted: boolean; + secretBlindIndex?: string; secretKeyCiphertext: string; secretKeyIV: string; secretKeyTag: string; secretValueCiphertext: string; secretValueIV: string; secretValueTag: string; - tags?: string[]; } const secretVersionSchema = new Schema( @@ -57,6 +58,10 @@ const secretVersionSchema = new Schema( default: false, required: true }, + secretBlindIndex: { + type: String, + select: false + }, secretKeyCiphertext: { type: String, required: true @@ -80,12 +85,7 @@ const secretVersionSchema = new Schema( secretValueTag: { type: String, // symmetric required: true - }, - tags: { - ref: 'Tag', - type: [Schema.Types.ObjectId], - default: [] - }, + } }, { timestamps: true diff --git a/backend/src/ee/services/EESecretService.ts b/backend/src/ee/services/EESecretService.ts index cea614c96..e408e848e 100644 --- a/backend/src/ee/services/EESecretService.ts +++ b/backend/src/ee/services/EESecretService.ts @@ -25,7 +25,7 @@ class EESecretService { static async takeSecretSnapshot({ workspaceId }: { - workspaceId: string; + workspaceId: Types.ObjectId; }) { if (!EELicenseService.isLicenseValid) return; return await takeSecretSnapshotHelper({ workspaceId }); diff --git a/backend/src/events/secret.ts b/backend/src/events/secret.ts index 479255a31..6007dd682 100644 --- a/backend/src/events/secret.ts +++ b/backend/src/events/secret.ts @@ -1,3 +1,4 @@ +import { Types } from 'mongoose'; import { EVENT_PUSH_SECRETS, EVENT_PULL_SECRETS @@ -22,13 +23,16 @@ interface PushSecret { * @returns */ const eventPushSecrets = ({ - workspaceId + workspaceId, + environment }: { - workspaceId: string; + workspaceId: Types.ObjectId; + environment?: string; }) => { return ({ name: EVENT_PUSH_SECRETS, workspaceId, + environment, payload: { } diff --git a/backend/src/helpers/auth.ts b/backend/src/helpers/auth.ts index 9cbe82e34..47b1ef9b1 100644 --- a/backend/src/helpers/auth.ts +++ b/backend/src/helpers/auth.ts @@ -157,7 +157,7 @@ const getAuthSTDPayload = async ({ }, { new: true }) - .select('+encryptedKey +iv +tag').populate('user serviceAccount'); + .select('+encryptedKey +iv +tag'); if (!serviceTokenData) throw ServiceTokenDataNotFoundError({ message: 'Failed to find service token data' }); diff --git a/backend/src/helpers/bot.ts b/backend/src/helpers/bot.ts index 1e242778d..ff55a5e2c 100644 --- a/backend/src/helpers/bot.ts +++ b/backend/src/helpers/bot.ts @@ -112,7 +112,7 @@ const createBot = async ({ workspaceId, }: { name: string; - workspaceId: string; + workspaceId: Types.ObjectId; }) => { let bot; try { @@ -151,7 +151,7 @@ const getSecretsHelper = async ({ workspaceId, environment }: { - workspaceId: string; + workspaceId: Types.ObjectId; environment: string; }) => { const content = {} as any; @@ -196,7 +196,7 @@ const getSecretsHelper = async ({ * @param {String} obj.workspaceId - id of workspace * @returns {String} key - decrypted workspace key */ -const getKey = async ({ workspaceId }: { workspaceId: string }) => { +const getKey = async ({ workspaceId }: { workspaceId: Types.ObjectId }) => { let key; try { const botKey = await BotKey.findOne({ @@ -245,7 +245,7 @@ const encryptSymmetricHelper = async ({ workspaceId, plaintext }: { - workspaceId: string; + workspaceId: Types.ObjectId; plaintext: string; }) => { @@ -282,7 +282,7 @@ const decryptSymmetricHelper = async ({ iv, tag }: { - workspaceId: string; + workspaceId: Types.ObjectId; ciphertext: string; iv: string; tag: string; diff --git a/backend/src/helpers/database.ts b/backend/src/helpers/database.ts index 9d128ea47..aa29f7e46 100644 --- a/backend/src/helpers/database.ts +++ b/backend/src/helpers/database.ts @@ -1,5 +1,6 @@ import mongoose from 'mongoose'; import { EESecretService } from '../ee/services'; +import { SecretService } from '../services'; import { getLogger } from '../utils/logger'; /** @@ -22,6 +23,7 @@ const initDatabaseHelper = async ({ getLogger("database").info("Database connection established"); await EESecretService.initSecretVersioning(); + await SecretService.initSecretBlindIndexDataHelper(); } catch (err) { getLogger("database").error(`Unable to establish Database connection due to the error.\n${err}`); } diff --git a/backend/src/helpers/event.ts b/backend/src/helpers/event.ts index 4128752e5..43375814d 100644 --- a/backend/src/helpers/event.ts +++ b/backend/src/helpers/event.ts @@ -1,11 +1,13 @@ -import { Bot, IBot } from '../models'; +import { Types } from 'mongoose'; import * as Sentry from '@sentry/node'; +import { Bot, IBot } from '../models'; import { EVENT_PUSH_SECRETS } from '../variables'; import { IntegrationService } from '../services'; interface Event { name: string; - workspaceId: string; + workspaceId: Types.ObjectId; + environment?: string; payload: any; } @@ -22,7 +24,10 @@ const handleEventHelper = async ({ }: { event: Event; }) => { - const { workspaceId } = event; + const { + workspaceId, + environment + } = event; // TODO: moduralize bot check into separate function const bot = await Bot.findOne({ @@ -36,7 +41,8 @@ const handleEventHelper = async ({ switch (event.name) { case EVENT_PUSH_SECRETS: IntegrationService.syncIntegrations({ - workspaceId + workspaceId, + environment }); break; } diff --git a/backend/src/helpers/integration.ts b/backend/src/helpers/integration.ts index fbbc2f807..4d2666de4 100644 --- a/backend/src/helpers/integration.ts +++ b/backend/src/helpers/integration.ts @@ -217,14 +217,19 @@ const handleOAuthExchangeHelper = async ({ * @param {Object} obj.workspaceId - id of workspace */ const syncIntegrationsHelper = async ({ - workspaceId + workspaceId, + environment }: { - workspaceId: string; + workspaceId: Types.ObjectId; + environment?: string; }) => { let integrations; try { integrations = await Integration.find({ workspace: workspaceId, + ...(environment ? { + environment + } : {}), isActive: true, app: { $ne: null } }); @@ -234,7 +239,7 @@ const syncIntegrationsHelper = async ({ for await (const integration of integrations) { // get workspace, environment (shared) secrets const secrets = await BotService.getSecrets({ // issue here? - workspaceId: integration.workspace.toString(), + workspaceId: integration.workspace, environment: integration.environment }); @@ -281,7 +286,7 @@ const syncIntegrationsHelper = async ({ if (!integrationAuth) throw UnauthorizedRequestError({message: 'Failed to locate Integration Authentication credentials'}); refreshToken = await BotService.decryptSymmetric({ - workspaceId: integrationAuth.workspace.toString(), + workspaceId: integrationAuth.workspace, ciphertext: integrationAuth.refreshCiphertext as string, iv: integrationAuth.refreshIV as string, tag: integrationAuth.refreshTag as string @@ -318,7 +323,7 @@ const getIntegrationAuthAccessHelper = async ({ integrationAuthId }: { integrati if (!integrationAuth) throw UnauthorizedRequestError({message: 'Failed to locate Integration Authentication credentials'}); accessToken = await BotService.decryptSymmetric({ - workspaceId: integrationAuth.workspace.toString(), + workspaceId: integrationAuth.workspace, ciphertext: integrationAuth.accessCiphertext as string, iv: integrationAuth.accessIV as string, tag: integrationAuth.accessTag as string @@ -340,7 +345,7 @@ const getIntegrationAuthAccessHelper = async ({ integrationAuthId }: { integrati if (integrationAuth?.accessIdCiphertext && integrationAuth?.accessIdIV && integrationAuth?.accessIdTag) { accessId = await BotService.decryptSymmetric({ - workspaceId: integrationAuth.workspace.toString(), + workspaceId: integrationAuth.workspace, ciphertext: integrationAuth.accessIdCiphertext as string, iv: integrationAuth.accessIdIV as string, tag: integrationAuth.accessIdTag as string @@ -386,7 +391,7 @@ const setIntegrationAuthRefreshHelper = async ({ if (!integrationAuth) throw new Error('Failed to find integration auth'); const obj = await BotService.encryptSymmetric({ - workspaceId: integrationAuth.workspace.toString(), + workspaceId: integrationAuth.workspace, plaintext: refreshToken }); @@ -435,14 +440,14 @@ const setIntegrationAuthAccessHelper = async ({ if (!integrationAuth) throw new Error('Failed to find integration auth'); const encryptedAccessTokenObj = await BotService.encryptSymmetric({ - workspaceId: integrationAuth.workspace.toString(), + workspaceId: integrationAuth.workspace, plaintext: accessToken }); let encryptedAccessIdObj; if (accessId) { encryptedAccessIdObj = await BotService.encryptSymmetric({ - workspaceId: integrationAuth.workspace.toString(), + workspaceId: integrationAuth.workspace, plaintext: accessId }); } diff --git a/backend/src/helpers/secret.ts b/backend/src/helpers/secret.ts index 7a7979357..d9e9f807b 100644 --- a/backend/src/helpers/secret.ts +++ b/backend/src/helpers/secret.ts @@ -10,7 +10,8 @@ import { EELogService } from '../ee/services'; import { - IAction + IAction, + SecretVersion } from '../ee/models'; import { SECRET_SHARED, @@ -189,8 +190,7 @@ const v1PushSecrets = async ({ secretKeyHash, }) => { const newSecret = newSecretsObj[`${type}-${secretKeyHash}`]; - return ({ - _id: new Types.ObjectId(), + return new SecretVersion({ secret: _id, version: version ? version + 1 : 1, workspace: new Types.ObjectId(workspaceId), @@ -261,8 +261,7 @@ const v1PushSecrets = async ({ secretValueIV, secretValueTag, secretValueHash - }) => ({ - _id: new Types.ObjectId(), + }) => new SecretVersion({ secret: _id, version, workspace, @@ -284,7 +283,7 @@ const v1PushSecrets = async ({ // (EE) take a secret snapshot await EESecretService.takeSecretSnapshot({ - workspaceId + workspaceId: new Types.ObjectId(workspaceId) }); } catch (err) { Sentry.setUser(null); @@ -475,12 +474,12 @@ const v2PushSecrets = async ({ // (EE) add secret versions for new secrets EESecretService.addSecretVersions({ - secretVersions: newSecrets.map((secretDocument) => { - return { - ...secretDocument.toObject(), + secretVersions: newSecrets.map((secretDocument: ISecret) => { + return new SecretVersion({ + ...secretDocument, secret: secretDocument._id, isDeleted: false - } + }) }) }); @@ -495,7 +494,7 @@ const v2PushSecrets = async ({ // (EE) take a secret snapshot await EESecretService.takeSecretSnapshot({ - workspaceId + workspaceId: new Types.ObjectId(workspaceId) }) // (EE) create (audit) log diff --git a/backend/src/helpers/secrets.ts b/backend/src/helpers/secrets.ts index 7d3e54105..6e33b187d 100644 --- a/backend/src/helpers/secrets.ts +++ b/backend/src/helpers/secrets.ts @@ -1,14 +1,24 @@ import { Types } from 'mongoose'; +import { + CreateSecretParams, + GetSecretsParams, + GetSecretParams, + UpdateSecretParams, + DeleteSecretParams +} from '../interfaces/services/SecretService'; +import { + AuthData +} from '../interfaces/middleware'; import { User, - IUser, + Workspace, ServiceAccount, - IServiceAccount, ServiceTokenData, - IServiceTokenData, Secret, - ISecret + ISecret, + SecretBlindIndexData, } from '../models'; +import { SecretVersion } from '../ee/models'; import { validateMembership } from '../helpers/membership'; @@ -17,7 +27,8 @@ import { validateUserClientForSecrets } from '../helpers/user'; import { - validateServiceTokenDataClientForSecrets, validateServiceTokenDataClientForWorkspace + validateServiceTokenDataClientForSecrets, + validateServiceTokenDataClientForWorkspace } from '../helpers/serviceTokenData'; import { validateServiceAccountClientForSecrets, @@ -26,14 +37,37 @@ import { import { BadRequestError, UnauthorizedRequestError, - SecretNotFoundError + SecretNotFoundError, + SecretBlindIndexDataNotFoundError } from '../utils/errors'; import { AUTH_MODE_JWT, AUTH_MODE_SERVICE_ACCOUNT, AUTH_MODE_SERVICE_TOKEN, - AUTH_MODE_API_KEY + AUTH_MODE_API_KEY, + SECRET_PERSONAL, + SECRET_SHARED, + ACTION_ADD_SECRETS, + ACTION_READ_SECRETS, + ACTION_UPDATE_SECRETS, + ACTION_DELETE_SECRETS } from '../variables'; +import crypto from 'crypto'; +import * as argon2 from 'argon2'; +import { + encryptSymmetric, + decryptSymmetric +} from '../utils/crypto'; +import { getEncryptionKey } from '../config'; +import { TelemetryService } from '../services'; +import { + EESecretService, + EELogService +} from '../ee/services'; +import { + getAuthDataPayloadIdObj, + getAuthDataPayloadUserObj +} from '../utils/auth'; /** * Validate authenticated clients for secrets with id [secretId] based @@ -50,10 +84,7 @@ const validateClientForSecret = async ({ acceptedRoles, requiredPermissions }: { - authData: { - authMode: string; - authPayload: IUser | IServiceAccount | IServiceTokenData; - }, + authData: AuthData; secretId: Types.ObjectId; acceptedRoles: Array<'admin' | 'member'>; requiredPermissions: string[]; @@ -127,10 +158,7 @@ const validateClientForSecrets = async ({ secretIds, requiredPermissions }: { - authData: { - authMode: string; - authPayload: IUser | IServiceAccount | IServiceTokenData; - }, + authData: AuthData; secretIds: Types.ObjectId[]; requiredPermissions: string[]; }) => { @@ -192,7 +220,726 @@ const validateClientForSecrets = async ({ }); } +/** + * Initialize secret blind index data by setting previously + * un-initialized projects to have secret blind index data + * (Ensures that all projects have associated blind index data) + */ +const initSecretBlindIndexDataHelper = async () => { + const workspaceIdsBlindIndexed = await SecretBlindIndexData.distinct('workspace'); + const workspaceIdsToBlindIndex = await Workspace.distinct('_id', { + _id: { + $nin: workspaceIdsBlindIndexed + } + }); + + const secretBlindIndexDataToInsert = workspaceIdsToBlindIndex.map((workspaceToBlindIndex) => { + const salt = crypto.randomBytes(16).toString('base64'); + + const { + ciphertext: encryptedSaltCiphertext, + iv: saltIV, + tag: saltTag + } = encryptSymmetric({ + plaintext: salt, + key: getEncryptionKey() + }); + + const secretBlindIndexData = new SecretBlindIndexData({ + workspace: workspaceToBlindIndex, + encryptedSaltCiphertext, + saltIV, + saltTag + }) + + return secretBlindIndexData; + }); + + if (secretBlindIndexDataToInsert.length > 0) { + await SecretBlindIndexData.insertMany(secretBlindIndexDataToInsert); + } +} + +/** + * Create secret blind index data containing encrypted blind index [salt] + * for workspace with id [workspaceId] + * @param {Object} obj + * @param {Types.ObjectId} obj.workspaceId + */ +const createSecretBlindIndexDataHelper = async ({ + workspaceId +}: { + workspaceId: Types.ObjectId; +}) => { + // initialize random blind index salt for workspace + const salt = crypto.randomBytes(16).toString('base64'); + + const { + ciphertext: encryptedSaltCiphertext, + iv: saltIV, + tag: saltTag + } = encryptSymmetric({ + plaintext: salt, + key: getEncryptionKey() + }); + + const secretBlindIndexData = await new SecretBlindIndexData({ + workspace: workspaceId, + encryptedSaltCiphertext, + saltIV, + saltTag + }).save(); + + return secretBlindIndexData; +} + +/** + * Get secret blind index salt for workspace with id [workspaceId] + * @param {Object} obj + * @param {Types.ObjectId} obj.workspaceId - id of workspace to get salt for + * @returns + */ +const getSecretBlindIndexSaltHelper = async ({ + workspaceId +}: { + workspaceId: Types.ObjectId; +}) => { + // check if workspace blind index data exists + const secretBlindIndexData = await SecretBlindIndexData.findOne({ + workspace: workspaceId + }); + + if (!secretBlindIndexData) throw SecretBlindIndexDataNotFoundError(); + + // decrypt workspace salt + const salt = decryptSymmetric({ + ciphertext: secretBlindIndexData.encryptedSaltCiphertext, + iv: secretBlindIndexData.saltIV, + tag: secretBlindIndexData.saltTag, + key: getEncryptionKey() + }); + + return salt; +} + +/** + * Generate blind index for secret with name [secretName] + * and salt [salt] + * @param {Object} obj + * @param {String} obj.secretName - name of secret to generate blind index for + * @param {String} obj.salt - base64-salt + */ + const generateSecretBlindIndexWithSaltHelper = async ({ + secretName, + salt +}: { + secretName: string; + salt: string; +}) => { + + // generate secret blind index + const secretBlindIndex = (await argon2.hash(secretName, { + type: argon2.argon2id, + salt: Buffer.from(salt, 'base64'), + saltLength: 16, // default 16 bytes + memoryCost: 65536, // default pool of 64 MiB per thread. + hashLength: 32, + parallelism: 1, + raw: true + })).toString('base64'); + + return secretBlindIndex; +} + +/** + * Generate blind index for secret with name [secretName] + * for workspace with id [workspaceId] + * @param {Object} obj + * @param {Stringj} obj.secretName - name of secret to generate blind index for + * @param {Types.ObjectId} obj.workspaceId - id of workspace that secret belongs to + */ +const generateSecretBlindIndexHelper = async ({ + secretName, + workspaceId +}: { + secretName: string; + workspaceId: Types.ObjectId; +}) => { + + // check if workspace blind index data exists + const secretBlindIndexData = await SecretBlindIndexData.findOne({ + workspace: workspaceId + }); + + if (!secretBlindIndexData) throw SecretBlindIndexDataNotFoundError(); + + // decrypt workspace salt + const salt = decryptSymmetric({ + ciphertext: secretBlindIndexData.encryptedSaltCiphertext, + iv: secretBlindIndexData.saltIV, + tag: secretBlindIndexData.saltTag, + key: getEncryptionKey() + }); + + const secretBlindIndex = await generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }); + + return secretBlindIndex; +} + +/** + * Create secret with name [secretName] + * @param {Object} obj + * @param {String} obj.secretName - name of secret to create + * @param {Types.ObjectId} obj.workspaceId - id of workspace to create secret for + * @param {String} obj.environment - environment in workspace to create secret for + * @param {'shared' | 'personal'} obj.type - type of secret + * @param {AuthData} obj.authData - authentication data on request + * @returns + */ +const createSecretHelper = async ({ + secretName, + workspaceId, + environment, + type, + authData, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag +}: CreateSecretParams) => { + const secretBlindIndex = await generateSecretBlindIndexHelper({ + secretName, + workspaceId: new Types.ObjectId(workspaceId) + }); + + const exists = await Secret.exists({ + secretBlindIndex, + workspace: new Types.ObjectId(workspaceId), + type, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + }); + + if (exists) throw BadRequestError({ + message: 'Failed to create secret that already exists' + }); + + if (type === SECRET_PERSONAL) { + // case: secret type is personal -> check if a corresponding shared secret + // with the same blind index [secretBlindIndex] exists + + const exists = await Secret.exists({ + secretBlindIndex, + workspace: new Types.ObjectId(workspaceId), + type: SECRET_SHARED + }); + + if (!exists) throw BadRequestError({ + message: 'Failed to create personal secret override for no corresponding shared secret' + }); + } + + // create secret + const secret = await new Secret({ + version: 1, + workspace: new Types.ObjectId(workspaceId), + environment, + type, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}), + secretBlindIndex, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag + }).save(); + + const secretVersion = new SecretVersion({ + secret: secret._id, + version: secret.version, + workspace: secret.workspace, + type, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}), + environment: secret.environment, + isDeleted: false, + secretBlindIndex, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag + }); + + // // (EE) add version for new secret + await EESecretService.addSecretVersions({ + secretVersions: [secretVersion] + }); + + // (EE) create (audit) log + const action = await EELogService.createAction({ + name: ACTION_ADD_SECRETS, + ...getAuthDataPayloadIdObj(authData), + workspaceId, + secretIds: [secret._id] + }); + + action && await EELogService.createLog({ + ...getAuthDataPayloadIdObj(authData), + workspaceId, + actions: [action], + channel: authData.authChannel, + ipAddress: authData.authIP + }); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId + }); + + const postHogClient = TelemetryService.getPostHogClient(); + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets added', + distinctId: await TelemetryService.getDistinctId({ + authData + }), + properties: { + numberOfSecrets: 1, + environment, + workspaceId, + channel: authData.authChannel, + userAgent: authData.authUserAgent + } + }); + } + + return secret; +} + +/** + * Get secrets for workspace with id [workspaceId] and environment [environment] + * @param {Object} obj + * @param {Types.ObjectId} obj.workspaceId - id of workspace + * @param {String} obj.environment - environment in workspace + * @param {AuthData} obj.authData - authentication data on request + * @returns + */ +const getSecretsHelper = async ({ + workspaceId, + environment, + authData +}: GetSecretsParams) => { + let secrets: ISecret[] = []; + + // get personal secrets first + secrets = await Secret.find({ + workspace: new Types.ObjectId(workspaceId), + environment, + type: SECRET_PERSONAL, + ...getAuthDataPayloadUserObj(authData) + }); + + // concat with shared secrets + secrets = secrets.concat(await Secret.find({ + workspace: new Types.ObjectId(workspaceId), + environment, + type: SECRET_SHARED, + secretBlindIndex: { + $nin: secrets.map((secret) => secret.secretBlindIndex) + } + })); + + // (EE) create (audit) log + const action = await EELogService.createAction({ + name: ACTION_READ_SECRETS, + ...getAuthDataPayloadIdObj(authData), + workspaceId, + secretIds: secrets.map((secret) => secret._id) + }); + + action && await EELogService.createLog({ + ...getAuthDataPayloadIdObj(authData), + workspaceId, + actions: [action], + channel: authData.authChannel, + ipAddress: authData.authIP + }); + + const postHogClient = TelemetryService.getPostHogClient(); + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets pulled', + distinctId: await TelemetryService.getDistinctId({ + authData + }), + properties: { + numberOfSecrets: secrets.length, + environment, + workspaceId, + channel: authData.authChannel, + userAgent: authData.authUserAgent + } + }); + } + + return secrets; +} + +/** + * Get secret with name [secretName] + * @param {Object} obj + * @param {String} obj.secretName - name of secret to get + * @param {Types.ObjectId} obj.workspaceId - id of workspace that secret belongs to + * @param {String} obj.environment - environment in workspace that secret belongs to + * @param {'shared' | 'personal'} obj.type - type of secret + * @param {AuthData} obj.authData - authentication data on request + * @returns + */ +const getSecretHelper = async ({ + secretName, + workspaceId, + environment, + type, + authData +}: GetSecretParams) => { + const secretBlindIndex = await generateSecretBlindIndexHelper({ + secretName, + workspaceId: new Types.ObjectId(workspaceId) + }); + let secret: ISecret | null = null; + + // try getting personal secret first (if exists) + secret = await Secret.findOne({ + secretBlindIndex, + workspace: new Types.ObjectId(workspaceId), + environment, + type: type ?? SECRET_PERSONAL, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}) + }); + + if (!secret) { + // case: failed to find personal secret matching criteria + // -> find shared secret matching criteria + secret = await Secret.findOne({ + secretBlindIndex, + workspace: new Types.ObjectId(workspaceId), + environment, + type: SECRET_SHARED + }); + } + + if (!secret) throw SecretNotFoundError(); + + // (EE) create (audit) log + const action = await EELogService.createAction({ + name: ACTION_READ_SECRETS, + ...getAuthDataPayloadIdObj(authData), + workspaceId, + secretIds: [secret._id] + }); + + action && await EELogService.createLog({ + ...getAuthDataPayloadIdObj(authData), + workspaceId, + actions: [action], + channel: authData.authChannel, + ipAddress: authData.authIP + }); + + const postHogClient = TelemetryService.getPostHogClient(); + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets pull', + distinctId: await TelemetryService.getDistinctId({ + authData + }), + properties: { + numberOfSecrets: 1, + environment, + workspaceId, + channel: authData.authChannel, + userAgent: authData.authUserAgent + } + }); + } + + return secret; +} + +/** + * Update secret with name [secretName] + * @param {Object} obj + * @param {String} obj.secretName - name of secret to update + * @param {Types.ObjectId} obj.workspaceId - id of workspace that secret belongs to + * @param {String} obj.environment - environment in workspace that secret belongs to + * @param {'shared' | 'personal'} obj.type - type of secret + * @param {String} obj.secretValueCiphertext - ciphertext of secret value + * @param {String} obj.secretValueIV - IV of secret value + * @param {String} obj.secretValueTag - tag of secret value + * @param {AuthData} obj.authData - authentication data on request + * @returns + */ +const updateSecretHelper = async ({ + secretName, + workspaceId, + environment, + type, + authData, + secretValueCiphertext, + secretValueIV, + secretValueTag +}: UpdateSecretParams) => { + const secretBlindIndex = await generateSecretBlindIndexHelper({ + secretName, + workspaceId: new Types.ObjectId(workspaceId) + }); + + let secret: ISecret | null = null; + + if (type === SECRET_SHARED) { + // case: update shared secret + secret = await Secret.findOneAndUpdate( + { + secretBlindIndex, + workspace: new Types.ObjectId(workspaceId), + environment, + type + }, + { + secretValueCiphertext, + secretValueIV, + secretValueTag, + $inc: { version: 1 } + }, + { + new: true + } + ); + } else { + // case: update personal secret + + secret = await Secret.findOneAndUpdate( + { + secretBlindIndex, + workspace: new Types.ObjectId(workspaceId), + environment, + type, + ...getAuthDataPayloadUserObj(authData) + }, + { + secretValueCiphertext, + secretValueIV, + secretValueTag, + $inc: { version: 1 } + }, + { + new: true + } + ); + } + + if (!secret) throw SecretNotFoundError(); + + const secretVersion = new SecretVersion({ + secret: secret._id, + version: secret.version, + workspace: secret.workspace, + type, + ...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}), + environment: secret.environment, + isDeleted: false, + secretBlindIndex, + secretKeyCiphertext: secret.secretKeyCiphertext, + secretKeyIV: secret.secretKeyIV, + secretKeyTag: secret.secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag + }); + + // (EE) add version for new secret + await EESecretService.addSecretVersions({ + secretVersions: [secretVersion] + }); + + // (EE) create (audit) log + const action = await EELogService.createAction({ + name: ACTION_UPDATE_SECRETS, + ...getAuthDataPayloadIdObj(authData), + workspaceId, + secretIds: [secret._id] + }); + + action && await EELogService.createLog({ + ...getAuthDataPayloadIdObj(authData), + workspaceId, + actions: [action], + channel: authData.authChannel, + ipAddress: authData.authIP + }); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId + }); + + const postHogClient = TelemetryService.getPostHogClient(); + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets modified', + distinctId: await TelemetryService.getDistinctId({ + authData + }), + properties: { + numberOfSecrets: 1, + environment, + workspaceId, + channel: authData.authChannel, + userAgent: authData.authUserAgent + } + }); + } + + return secret; +} + +/** + * Delete secret with name [secretName] + * @param {Object} obj + * @param {String} obj.secretName - name of secret to delete + * @param {Types.ObjectId} obj.workspaceId - id of workspace that secret belongs to + * @param {String} obj.environment - environment in workspace that secret belongs to + * @param {'shared' | 'personal'} obj.type - type of secret + * @param {AuthData} obj.authData - authentication data on request + * @returns + */ +const deleteSecretHelper = async ({ + secretName, + workspaceId, + environment, + type, + authData +}: DeleteSecretParams) => { + const secretBlindIndex = await generateSecretBlindIndexHelper({ + secretName, + workspaceId: new Types.ObjectId(workspaceId) + }); + + let secrets: ISecret[] = []; + let secret: ISecret | null = null; + + if (type === SECRET_SHARED) { + secrets = await Secret.find({ + secretBlindIndex, + workspaceId: new Types.ObjectId(workspaceId), + environment + }); + + secret = await Secret.findOneAndDelete({ + secretBlindIndex, + workspaceId: new Types.ObjectId(workspaceId), + environment, + type + }); + + await Secret.deleteMany({ + secretBlindIndex, + workspaceId: new Types.ObjectId(workspaceId), + environment + }); + } else { + secret = await Secret.findOneAndDelete({ + secretBlindIndex, + workspaceId: new Types.ObjectId(workspaceId), + environment, + type, + ...getAuthDataPayloadUserObj(authData) + }); + + if (secret) { + secrets = [secret]; + } + } + + if (!secret) throw SecretNotFoundError(); + + await EESecretService.markDeletedSecretVersions({ + secretIds: secrets.map((secret) => secret._id) + }); + + // (EE) create (audit) log + const action = await EELogService.createAction({ + name: ACTION_DELETE_SECRETS, + ...getAuthDataPayloadIdObj(authData), + workspaceId, + secretIds: secrets.map((secret) => secret._id) + }); + + // (EE) take a secret snapshot + action && await EELogService.createLog({ + ...getAuthDataPayloadIdObj(authData), + workspaceId, + actions: [action], + channel: authData.authChannel, + ipAddress: authData.authIP + }); + + // (EE) take a secret snapshot + await EESecretService.takeSecretSnapshot({ + workspaceId + }); + + const postHogClient = TelemetryService.getPostHogClient(); + + if (postHogClient) { + postHogClient.capture({ + event: 'secrets deleted', + distinctId: await TelemetryService.getDistinctId({ + authData + }), + properties: { + numberOfSecrets: secrets.length, + environment, + workspaceId, + channel: authData.authChannel, + userAgent: authData.authUserAgent + } + }); + } + + return ({ + secrets, + secret + }); +} + export { validateClientForSecret, - validateClientForSecrets + validateClientForSecrets, + initSecretBlindIndexDataHelper, + createSecretBlindIndexDataHelper, + getSecretBlindIndexSaltHelper, + generateSecretBlindIndexWithSaltHelper, + generateSecretBlindIndexHelper, + createSecretHelper, + getSecretsHelper, + getSecretHelper, + updateSecretHelper, + deleteSecretHelper } \ No newline at end of file diff --git a/backend/src/helpers/serviceTokenData.ts b/backend/src/helpers/serviceTokenData.ts index 9a8bb1288..ecf46a4d1 100644 --- a/backend/src/helpers/serviceTokenData.ts +++ b/backend/src/helpers/serviceTokenData.ts @@ -111,7 +111,6 @@ const validateClientForServiceTokenData = async ({ environment?: string; requiredPermissions?: string[]; }) => { - if (!serviceTokenData.workspace.equals(workspaceId)) { // case: invalid workspaceId passed throw UnauthorizedRequestError({ diff --git a/backend/src/helpers/workspace.ts b/backend/src/helpers/workspace.ts index f3b27bf10..bb002176b 100644 --- a/backend/src/helpers/workspace.ts +++ b/backend/src/helpers/workspace.ts @@ -1,4 +1,5 @@ import * as Sentry from '@sentry/node'; +import crypto from 'crypto'; import { Types } from 'mongoose'; import { Workspace, @@ -13,6 +14,7 @@ import { IServiceAccount, ServiceTokenData, IServiceTokenData, + SecretBlindIndexData } from '../models'; import { createBot } from '../helpers/bot'; import { validateUserClientForWorkspace } from '../helpers/user'; @@ -26,6 +28,9 @@ import { AUTH_MODE_SERVICE_TOKEN, AUTH_MODE_API_KEY } from '../variables'; +import { getEncryptionKey } from '../config'; +import { encryptSymmetric } from '../utils/crypto'; +import { SecretService } from '../services'; /** * Validate authenticated clients for workspace with id [workspaceId] based @@ -42,7 +47,8 @@ const validateClientForWorkspace = async ({ workspaceId, environment, acceptedRoles, - requiredPermissions + requiredPermissions, + requireBlindIndicesEnabled }: { authData: { authMode: string; @@ -52,6 +58,7 @@ const validateClientForWorkspace = async ({ environment?: string; acceptedRoles: Array<'admin' | 'member'>; requiredPermissions?: string[]; + requireBlindIndicesEnabled: boolean; }) => { const workspace = await Workspace.findById(workspaceId); @@ -60,6 +67,20 @@ const validateClientForWorkspace = async ({ message: 'Failed to find workspace' }); + if (requireBlindIndicesEnabled) { + // case: blind indices are not enabled for secrets in this workspace + // (i.e. workspace was created before blind indices were introduced + // and no admin has enabled it) + + const secretBlindIndexData = await SecretBlindIndexData.exists({ + workspace: new Types.ObjectId(workspaceId) + }); + + if (!secretBlindIndexData) throw UnauthorizedRequestError({ + message: 'Failed workspace authorization due to blind indices not being enabled' + }); + } + if (authData.authMode === AUTH_MODE_JWT && authData.authPayload instanceof User) { const membership = await validateUserClientForWorkspace({ user: authData.authPayload, @@ -130,13 +151,21 @@ const createWorkspace = async ({ // create workspace workspace = await new Workspace({ name, - organization: organizationId + organization: organizationId, + autoCapitalization: true }).save(); - const bot = await createBot({ + // initialize bot for workspace + await createBot({ name: 'Infisical Bot', - workspaceId: workspace._id.toString() + workspaceId: workspace._id }); + + // initialize blind index salt for workspace + await SecretService.createSecretBlindIndexData({ + workspaceId: workspace._id + }); + } catch (err) { Sentry.setUser(null); Sentry.captureException(err); diff --git a/backend/src/index.ts b/backend/src/index.ts index c77fcd793..0636bb5a3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -61,6 +61,10 @@ import { environment as v2EnvironmentRouter, tags as v2TagsRouter, } from './routes/v2'; +import { + secrets as v3SecretsRouter, + workspaces as v3WorkspacesRouter +} from './routes/v3'; import { healthCheck } from './routes/status'; import { getLogger } from './utils/logger'; import { RouteNotFoundError } from './utils/errors'; @@ -122,7 +126,7 @@ const main = async () => { app.use('/api/v1/workspace', eeWorkspaceRouter); app.use('/api/v1/action', eeActionRouter); - // v1 routes + // v1 routes (default) app.use('/api/v1/signup', v1SignupRouter); app.use('/api/v1/auth', v1AuthRouter); app.use('/api/v1/bot', v1BotRouter); @@ -141,7 +145,7 @@ const main = async () => { app.use('/api/v1/integration', v1IntegrationRouter); app.use('/api/v1/integration-auth', v1IntegrationAuthRouter); - // v2 routes + // v2 routes (improvements) app.use('/api/v2/signup', v2SignupRouter); app.use('/api/v2/auth', v2AuthRouter); app.use('/api/v2/users', v2UsersRouter); @@ -154,6 +158,10 @@ const main = async () => { app.use('/api/v2/service-token', v2ServiceTokenDataRouter); // TODO: turn into plural route app.use('/api/v2/service-accounts', v2ServiceAccountsRouter); // new app.use('/api/v2/api-key', v2APIKeyDataRouter); + + // v3 routes (experimental) + app.use('/api/v3/secrets', v3SecretsRouter); + app.use('/api/v3/workspaces', v3WorkspacesRouter); // api docs app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerFile)) diff --git a/backend/src/interfaces/middleware/index.ts b/backend/src/interfaces/middleware/index.ts new file mode 100644 index 000000000..34230de05 --- /dev/null +++ b/backend/src/interfaces/middleware/index.ts @@ -0,0 +1,13 @@ +import { + IUser, + IServiceAccount, + IServiceTokenData +} from '../../models'; + +export interface AuthData { + authMode: string; + authPayload: IUser | IServiceAccount | IServiceTokenData; + authChannel: string; + authIP: string; + authUserAgent: string; +} \ No newline at end of file diff --git a/backend/src/interfaces/services/SecretService/index.ts b/backend/src/interfaces/services/SecretService/index.ts new file mode 100644 index 000000000..b72068b90 --- /dev/null +++ b/backend/src/interfaces/services/SecretService/index.ts @@ -0,0 +1,52 @@ +import { Types } from 'mongoose'; +import { AuthData } from '../../middleware'; + +export interface CreateSecretParams { + secretName: string; + workspaceId: Types.ObjectId; + environment: string; + type: 'shared' | 'personal'; + authData: AuthData; + secretKeyCiphertext: string; + secretKeyIV: string; + secretKeyTag: string; + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; + secretCommentCiphertext?: string; + secretCommentIV?: string; + secretCommentTag?: string; +} + +export interface GetSecretsParams { + workspaceId: Types.ObjectId; + environment: string; + authData: AuthData; +} + +export interface GetSecretParams { + secretName: string; + workspaceId: Types.ObjectId; + environment: string; + type?: 'shared' | 'personal'; + authData: AuthData; +} + +export interface UpdateSecretParams { + secretName: string; + workspaceId: Types.ObjectId; + environment: string; + type: 'shared' | 'personal', + authData: AuthData + secretValueCiphertext: string; + secretValueIV: string; + secretValueTag: string; +} + +export interface DeleteSecretParams { + secretName: string; + workspaceId: Types.ObjectId; + environment: string; + type: 'shared' | 'personal'; + authData: AuthData; +} \ No newline at end of file diff --git a/backend/src/middleware/requireAuth.ts b/backend/src/middleware/requireAuth.ts index 16191d607..86ebf416c 100644 --- a/backend/src/middleware/requireAuth.ts +++ b/backend/src/middleware/requireAuth.ts @@ -21,6 +21,7 @@ import { AUTH_MODE_SERVICE_TOKEN, AUTH_MODE_API_KEY } from '../variables'; +import { getChannelFromUserAgent } from '../utils/posthog'; declare module 'jsonwebtoken' { export interface UserIDJwtPayload extends jwt.JwtPayload { @@ -88,7 +89,10 @@ const requireAuth = ({ req.authData = { authMode, - authPayload // User, ServiceAccount, ServiceTokenData + authPayload, // User, ServiceAccount, ServiceTokenData + authChannel: getChannelFromUserAgent(req.headers['user-agent']), + authIP: req.ip, + authUserAgent: req.headers['user-agent'] ?? 'other' } return next(); diff --git a/backend/src/middleware/requireWorkspaceAuth.ts b/backend/src/middleware/requireWorkspaceAuth.ts index 5d094f972..76f723df2 100644 --- a/backend/src/middleware/requireWorkspaceAuth.ts +++ b/backend/src/middleware/requireWorkspaceAuth.ts @@ -17,12 +17,14 @@ const requireWorkspaceAuth = ({ acceptedRoles, locationWorkspaceId, locationEnvironment = undefined, - requiredPermissions = [] + requiredPermissions = [], + requireBlindIndicesEnabled = false }: { acceptedRoles: Array<'admin' | 'member'>; locationWorkspaceId: req; locationEnvironment?: req | undefined; requiredPermissions?: string[]; + requireBlindIndicesEnabled?: boolean; }) => { return async (req: Request, res: Response, next: NextFunction) => { const workspaceId = req[locationWorkspaceId]?.workspaceId; @@ -34,7 +36,8 @@ const requireWorkspaceAuth = ({ workspaceId: new Types.ObjectId(workspaceId), environment, acceptedRoles, - requiredPermissions + requiredPermissions, + requireBlindIndicesEnabled }); if (membership) { diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts index 886f7fec6..6cd6a01da 100644 --- a/backend/src/models/index.ts +++ b/backend/src/models/index.ts @@ -9,6 +9,7 @@ import Membership, { IMembership } from './membership'; import MembershipOrg, { IMembershipOrg } from './membershipOrg'; import Organization, { IOrganization } from './organization'; import Secret, { ISecret } from './secret'; +import SecretBlindIndexData, { ISecretBlindIndexData } from './secretBlindIndexData'; import ServiceToken, { IServiceToken } from './serviceToken'; import ServiceAccount, { IServiceAccount } from './serviceAccount'; // new import ServiceAccountKey, { IServiceAccountKey } from './serviceAccountKey'; // new @@ -45,6 +46,8 @@ export { IOrganization, Secret, ISecret, + SecretBlindIndexData, + ISecretBlindIndexData, ServiceToken, IServiceToken, ServiceAccount, diff --git a/backend/src/models/secret.ts b/backend/src/models/secret.ts index 463ed50fd..725741ccf 100644 --- a/backend/src/models/secret.ts +++ b/backend/src/models/secret.ts @@ -11,6 +11,7 @@ export interface ISecret { type: string; user: Types.ObjectId; environment: string; + secretBlindIndex?: string; secretKeyCiphertext: string; secretKeyIV: string; secretKeyTag: string; @@ -57,6 +58,10 @@ const secretSchema = new Schema( type: String, required: true }, + secretBlindIndex: { + type: String, + select: false + }, secretKeyCiphertext: { type: String, required: true diff --git a/backend/src/models/secretBlindIndexData.ts b/backend/src/models/secretBlindIndexData.ts new file mode 100644 index 000000000..47649dde4 --- /dev/null +++ b/backend/src/models/secretBlindIndexData.ts @@ -0,0 +1,35 @@ +import { Schema, model, Types, Document } from 'mongoose'; + +export interface ISecretBlindIndexData extends Document { + _id: Types.ObjectId; + workspace: Types.ObjectId; + encryptedSaltCiphertext: string; + saltIV: string; + saltTag: string; +} + +const secretBlindIndexDataSchema = new Schema( + { + workspace: { + type: Schema.Types.ObjectId, + ref: 'Workspace', + required: true + }, + encryptedSaltCiphertext: { + type: String, + required: true + }, + saltIV: { + type: String, + required: true + }, + saltTag: { + type: String, + required: true + } + } +); + +const SecretBlindIndexData = model('SecretBlindIndexData', secretBlindIndexDataSchema); + +export default SecretBlindIndexData; \ No newline at end of file diff --git a/backend/src/models/serviceTokenData.ts b/backend/src/models/serviceTokenData.ts index 86f0e44b3..36a63c094 100644 --- a/backend/src/models/serviceTokenData.ts +++ b/backend/src/models/serviceTokenData.ts @@ -33,7 +33,8 @@ const serviceTokenDataSchema = new Schema( }, user: { type: Schema.Types.ObjectId, - ref: 'User' + ref: 'User', + required: true }, serviceAccount: { type: Schema.Types.ObjectId, diff --git a/backend/src/models/workspace.ts b/backend/src/models/workspace.ts index d68ae9416..94a086e82 100644 --- a/backend/src/models/workspace.ts +++ b/backend/src/models/workspace.ts @@ -55,4 +55,4 @@ const workspaceSchema = new Schema({ const Workspace = model('Workspace', workspaceSchema); -export default Workspace; +export default Workspace; \ No newline at end of file diff --git a/backend/src/routes/v3/index.ts b/backend/src/routes/v3/index.ts new file mode 100644 index 000000000..e66723133 --- /dev/null +++ b/backend/src/routes/v3/index.ts @@ -0,0 +1,7 @@ +import secrets from './secrets'; +import workspaces from './workspaces'; + +export { + secrets, + workspaces +} \ No newline at end of file diff --git a/backend/src/routes/v3/secrets.ts b/backend/src/routes/v3/secrets.ts index b415139a9..6a18fdf73 100644 --- a/backend/src/routes/v3/secrets.ts +++ b/backend/src/routes/v3/secrets.ts @@ -1,8 +1,157 @@ import express from 'express'; const router = express.Router(); import { - requireAuth, validateRequest + requireAuth, + requireWorkspaceAuth, + validateRequest } from '../../middleware'; -import { body } from 'express-validator'; +import { body, param, query } from 'express-validator'; +import { secretsController } from '../../controllers/v3'; +import { + AUTH_MODE_JWT, + AUTH_MODE_API_KEY, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_SERVICE_ACCOUNT, + ADMIN, + MEMBER, + PERMISSION_WRITE_SECRETS, + SECRET_SHARED, + SECRET_PERSONAL, + PERMISSION_READ_SECRETS +} from '../../variables'; + +router.get( + '/', + query('workspaceId').exists().isString().trim(), + query('environment').exists().isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: [ + AUTH_MODE_JWT, + AUTH_MODE_API_KEY, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_SERVICE_ACCOUNT + ] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'query', + locationEnvironment: 'query', + requiredPermissions: [PERMISSION_READ_SECRETS], + requireBlindIndicesEnabled: true, + }), + secretsController.getSecrets +); + +router.post( + '/:secretName', + body('workspaceId').exists().isString().trim(), + body('environment').exists().isString().trim(), + body('type').exists().isIn([SECRET_SHARED, SECRET_PERSONAL]), + body('secretKeyCiphertext').exists().isString().trim(), + body('secretKeyIV').exists().isString().trim(), + body('secretKeyTag').exists().isString().trim(), + body('secretValueCiphertext').exists().isString().trim(), + body('secretValueIV').exists().isString().trim(), + body('secretValueTag').exists().isString().trim(), + body('secretCommentCiphertext').optional().isString().trim(), + body('secretCommentIV').optional().isString().trim(), + body('secretCommentTag').optional().isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: [ + AUTH_MODE_JWT, + AUTH_MODE_API_KEY, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_SERVICE_ACCOUNT + ] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'body', + locationEnvironment: 'body', + requiredPermissions: [PERMISSION_WRITE_SECRETS], + requireBlindIndicesEnabled: true, + }), + secretsController.createSecret +); + +router.get( + '/:secretName', + param('secretName').exists().isString().trim(), + query('workspaceId').exists().isString().trim(), + query('environment').exists().isString().trim(), + query('type').optional().isIn([SECRET_SHARED, SECRET_PERSONAL]), + validateRequest, + requireAuth({ + acceptedAuthModes: [ + AUTH_MODE_JWT, + AUTH_MODE_API_KEY, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_SERVICE_ACCOUNT + ] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'query', + locationEnvironment: 'query', + requiredPermissions: [PERMISSION_READ_SECRETS], + requireBlindIndicesEnabled: true, + }), + secretsController.getSecretByName +); + +router.patch( + '/:secretName', + param('secretName').exists().isString().trim(), + body('workspaceId').exists().isString().trim(), + body('environment').exists().isString().trim(), + body('type').exists().isIn([SECRET_SHARED, SECRET_PERSONAL]), + body('secretValueCiphertext').exists().isString().trim(), + body('secretValueIV').exists().isString().trim(), + body('secretValueTag').exists().isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: [ + AUTH_MODE_JWT, + AUTH_MODE_API_KEY, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_SERVICE_ACCOUNT + ] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'body', + locationEnvironment: 'body', + requiredPermissions: [PERMISSION_WRITE_SECRETS], + requireBlindIndicesEnabled: true, + }), + secretsController.updateSecretByName +); + +router.delete( + '/:secretName', + param('secretName').exists().isString().trim(), + body('workspaceId').exists().isString().trim(), + body('environment').exists().isString().trim(), + body('type').exists().isIn([SECRET_SHARED, SECRET_PERSONAL]), + validateRequest, + requireAuth({ + acceptedAuthModes: [ + AUTH_MODE_JWT, + AUTH_MODE_API_KEY, + AUTH_MODE_SERVICE_TOKEN, + AUTH_MODE_SERVICE_ACCOUNT + ] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN, MEMBER], + locationWorkspaceId: 'body', + locationEnvironment: 'body', + requiredPermissions: [PERMISSION_WRITE_SECRETS], + requireBlindIndicesEnabled: true, + }), + secretsController.deleteSecretByName +); export default router; \ No newline at end of file diff --git a/backend/src/routes/v3/workspaces.ts b/backend/src/routes/v3/workspaces.ts new file mode 100644 index 000000000..fdd734cf9 --- /dev/null +++ b/backend/src/routes/v3/workspaces.ts @@ -0,0 +1,80 @@ +import express from 'express'; +const router = express.Router(); +import { + requireAuth, + requireWorkspaceAuth, + validateRequest +} from '../../middleware'; +import { workspacesController } from '../../controllers/v3'; +import { + AUTH_MODE_JWT, + ADMIN, + PERMISSION_READ_SECRETS +} from '../../variables'; +import { param, body, validationResult } from 'express-validator'; + +// -- migration to blind indices endpoints + +router.get( + '/:workspaceId/secrets/blind-index-status', + param('workspaceId').exists().isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN], + locationWorkspaceId: 'params', + }), + workspacesController.getWorkspaceBlindIndexStatus +); + +router.get( // allow admins to get all workspace secrets (part of blind indices migration) + '/:workspaceId/secrets', + param('workspaceId').exists().isString().trim(), + validateRequest, + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN], + locationWorkspaceId: 'params', + }), + workspacesController.getWorkspaceSecrets +); + +router.post( // allow admins to name all workspace secrets (part of blind indices migration) + '/:workspaceId/secrets/names', + param('workspaceId').exists().isString().trim(), + body('secretsToUpdate') + .exists() + .isArray() + .withMessage('secretsToUpdate must be an array') + .customSanitizer((value) => { + return value.map((secret: any) => ({ + secretName: secret.secretName, + _id: secret._id + })); + }), + body('secretsToUpdate.*.secretName') + .exists() + .isString() + .withMessage('secretName must be a string'), + body('secretsToUpdate.*._id') + .exists() + .isString() + .withMessage('secretId must be a string'), + validateRequest, + requireAuth({ + acceptedAuthModes: [AUTH_MODE_JWT] + }), + requireWorkspaceAuth({ + acceptedRoles: [ADMIN], + locationWorkspaceId: 'params' + }), + workspacesController.nameWorkspaceSecrets +); + +// -- + +export default router; \ No newline at end of file diff --git a/backend/src/services/BotService.ts b/backend/src/services/BotService.ts index 792bd8e35..4e8118fbe 100644 --- a/backend/src/services/BotService.ts +++ b/backend/src/services/BotService.ts @@ -1,3 +1,4 @@ +import { Types } from 'mongoose'; import { getSecretsHelper, encryptSymmetricHelper, @@ -21,7 +22,7 @@ class BotService { workspaceId, environment }: { - workspaceId: string; + workspaceId: Types.ObjectId; environment: string; }) { return await getSecretsHelper({ @@ -41,7 +42,7 @@ class BotService { workspaceId, plaintext }: { - workspaceId: string; + workspaceId: Types.ObjectId; plaintext: string; }) { return await encryptSymmetricHelper({ @@ -65,7 +66,7 @@ class BotService { iv, tag }: { - workspaceId: string; + workspaceId: Types.ObjectId; ciphertext: string; iv: string; tag: string; diff --git a/backend/src/services/EventService.ts b/backend/src/services/EventService.ts index 9f982bf75..160086be8 100644 --- a/backend/src/services/EventService.ts +++ b/backend/src/services/EventService.ts @@ -1,8 +1,10 @@ +import { Types } from 'mongoose'; import { handleEventHelper } from '../helpers/event'; interface Event { name: string; - workspaceId: string; + workspaceId: Types.ObjectId; + environment?: string; payload: any; } diff --git a/backend/src/services/IntegrationService.ts b/backend/src/services/IntegrationService.ts index 0fd634c33..1e7409ad0 100644 --- a/backend/src/services/IntegrationService.ts +++ b/backend/src/services/IntegrationService.ts @@ -52,9 +52,11 @@ class IntegrationService { * @param {Object} obj.workspaceId - id of workspace */ static async syncIntegrations({ - workspaceId + workspaceId, + environment }: { - workspaceId: string; + workspaceId: Types.ObjectId; + environment?: string; }) { return await syncIntegrationsHelper({ workspaceId diff --git a/backend/src/services/SecretService.ts b/backend/src/services/SecretService.ts new file mode 100644 index 000000000..c215431f1 --- /dev/null +++ b/backend/src/services/SecretService.ts @@ -0,0 +1,183 @@ +// WIP +import { Types } from 'mongoose'; +import { + ISecret +} from '../models'; +import { + CreateSecretParams, + GetSecretsParams, + GetSecretParams, + UpdateSecretParams, + DeleteSecretParams +} from '../interfaces/services/SecretService'; +import { + initSecretBlindIndexDataHelper, + createSecretBlindIndexDataHelper, + getSecretBlindIndexSaltHelper, + generateSecretBlindIndexWithSaltHelper, + generateSecretBlindIndexHelper, + createSecretHelper, + getSecretsHelper, + getSecretHelper, + updateSecretHelper, + deleteSecretHelper +} from '../helpers/secrets'; + +class SecretService { + + /** + * + * @param param0 h + * @returns + */ + + static async initSecretBlindIndexDataHelper() { + return await initSecretBlindIndexDataHelper(); + } + + /** + * Create secret blind index data containing encrypted blind index salt + * for workspace with id [workspaceId] + * @param {Object} obj + * @param {Buffer} obj.salt - 16-byte random salt + * @param {Types.ObjectId} obj.workspaceId + */ + static async createSecretBlindIndexData({ + workspaceId, + }: { + workspaceId: Types.ObjectId; + }) { + return await createSecretBlindIndexDataHelper({ + workspaceId + }); + } + + /** + * Get secret blind index salt for workspace with id [workspaceId] + * @param {Object} obj + * @param {Types.ObjectId} obj.workspaceId - id of workspace to get salt for + * @returns + */ + static async getSecretBlindIndexSalt({ + workspaceId + }: { + workspaceId: Types.ObjectId; + }) { + return await getSecretBlindIndexSaltHelper({ + workspaceId + }); + } + + /** + * Generate blind index for secret with name [secretName] + * and salt [salt] + * @param {Object} obj + * @param {Object} obj.secretName - name of secret to generate blind index for + * @param {String} obj.salt - base64-salt + */ + static async generateSecretBlindIndexWithSalt({ + secretName, + salt + }: { + secretName: string; + salt: string; + }) { + return await generateSecretBlindIndexWithSaltHelper({ + secretName, + salt + }); + } + + /** + * Create and return blind index for secret with + * name [secretName] part of workspace with id [workspaceId] + * @param {Object} obj + * @param {String} obj.secretName - name of secret to generate blind index for + * @param {Types.ObjectId} obj.workspaceId - id of workspace that secret belongs to + */ + static async generateSecretBlindIndex({ + secretName, + workspaceId, + }: { + secretName: string; + workspaceId: Types.ObjectId; + }) { + return await generateSecretBlindIndexHelper({ + secretName, + workspaceId + }); + } + + /** + * Create secret with name [secretName] + * @param {Object} obj + * @param {String} obj.secretName - name of secret to create + * @param {Types.ObjectId} obj.workspaceId - id of workspace to create secret for + * @param {String} obj.environment - environment in workspace to create secret for + * @param {'shared' | 'personal'} obj.type - type of secret + * @param {AuthData} obj.authData - authentication data on request + * @returns + */ + static async createSecret(createSecretParams: CreateSecretParams) { + return await createSecretHelper(createSecretParams); + } + + /** + * Get secrets for workspace with id [workspaceId] and environment [environment] + * @param {Object} obj + * @param {Types.ObjectId} obj.workspaceId - id of workspace + * @param {String} obj.environment - environment in workspace + * @param {AuthData} obj.authData - authentication data on request + * @returns + */ + static async getSecrets(getSecretsParams: GetSecretsParams) { + return await getSecretsHelper(getSecretsParams); + } + + /** + * Get secret with name [secretName] + * @param {Object} obj + * @param {String} obj.secretName - name of secret to get + * @param {Types.ObjectId} obj.workspaceId - id of workspace that secret belongs to + * @param {String} obj.environment - environment in workspace that secret belongs to + * @param {'shared' | 'personal'} obj.type - type of secret + * @param {AuthData} obj.authData - authentication data on request + * @returns + */ + static async getSecret(getSecretParams: GetSecretParams) { + return await getSecretHelper(getSecretParams); + } + + /** + * Update secret with name [secretName] + * @param {Object} obj + * @param {String} obj.secretName - name of secret to update + * @param {Types.ObjectId} obj.workspaceId - id of workspace that secret belongs to + * @param {String} obj.environment - environment in workspace that secret belongs to + * @param {'shared' | 'personal'} obj.type - type of secret + * @param {String} obj.secretValueCiphertext - ciphertext of secret value + * @param {String} obj.secretValueIV - IV of secret value + * @param {String} obj.secretValueTag - tag of secret value + * @param {AuthData} obj.authData - authentication data on request + * @returns + */ + static async updateSecret(updateSecretParams: UpdateSecretParams) { + return await updateSecretHelper(updateSecretParams); + } + + /** + * Delete secret with name [secretName] + * @param {Object} obj + * @param {String} obj.secretName - name of secret to delete + * @param {Types.ObjectId} obj.workspaceId - id of workspace that secret belongs to + * @param {String} obj.environment - environment in workspace that secret belongs to + * @param {'shared' | 'personal'} obj.type - type of secret + * @param {AuthData} obj.authData - authentication data on request + * @returns + */ + static async deleteSecret(deleteSecretParams: DeleteSecretParams) { + return await deleteSecretHelper(deleteSecretParams); + } +} + +export default SecretService; \ No newline at end of file diff --git a/backend/src/services/TelemetryService.ts b/backend/src/services/TelemetryService.ts index 248ad3633..578b5292d 100644 --- a/backend/src/services/TelemetryService.ts +++ b/backend/src/services/TelemetryService.ts @@ -1,5 +1,6 @@ import { PostHog } from 'posthog-node'; import { getLogger } from '../utils/logger'; +import { AuthData } from '../interfaces/middleware'; import { getNodeEnv, getTelemetryEnabled, @@ -11,9 +12,11 @@ import { User, IServiceAccount, ServiceAccount, - IServiceTokenData + IServiceTokenData, + ServiceTokenData } from '../models'; import { + AccountNotFoundError, BadRequestError } from '../utils/errors'; @@ -48,39 +51,30 @@ class Telemetry { return postHogClient; } - /** - * Return a distinct id for client to be used for logging telemetry - */ - static getDistinctId = ({ - user, - serviceAccount, - serviceTokenData + static getDistinctId = async ({ + authData }: { - user?: IUser; - serviceAccount?: IServiceAccount; - serviceTokenData?: any; // TODO: fix (it's ServiceTokenData with user populated) + authData: AuthData; }) => { let distinctId = ''; - - if (user) { - distinctId = user.email; + if (authData.authPayload instanceof User) { + distinctId = authData.authPayload.email; + } else if (authData.authPayload instanceof ServiceAccount) { + distinctId = `sa.${authData.authPayload._id.toString()}`; + } else if (authData.authPayload instanceof ServiceTokenData) { + + if (authData.authPayload.user) { + const user = await User.findById(authData.authPayload.user, 'email'); + if (!user) throw AccountNotFoundError(); + distinctId = user.email; + } else if (authData.authPayload.serviceAccount) { + distinctId = distinctId = `sa.${authData.authPayload.serviceAccount.toString()}`; + } } - if (serviceAccount) { - distinctId = `sa.${serviceAccount._id.toString()}`; - } - - if (serviceTokenData?.user && serviceTokenData?.user instanceof User) { - distinctId = serviceTokenData.user.email; - } else if (serviceTokenData?.serviceAccount && serviceTokenData?.serviceAccount instanceof ServiceAccount) { - distinctId = `sa.${serviceTokenData.serviceAccount._id.toString()}`; - } - - if (distinctId === '') { - throw BadRequestError({ - message: 'Failed to obtain distinct id for logging telemetry' - }); - } + if (distinctId === '') throw BadRequestError({ + message: 'Failed to obtain distinct id for logging telemetry' + }); return distinctId; } diff --git a/backend/src/services/index.ts b/backend/src/services/index.ts index f93f45f58..db0bd39a3 100644 --- a/backend/src/services/index.ts +++ b/backend/src/services/index.ts @@ -5,14 +5,14 @@ import BotService from './BotService'; import EventService from './EventService'; import IntegrationService from './IntegrationService'; import TokenService from './TokenService'; +import SecretService from './SecretService'; export { TelemetryService, - // logTelemetryMessage, - // getPostHogClient, DatabaseService, BotService, EventService, IntegrationService, - TokenService + TokenService, + SecretService } \ No newline at end of file diff --git a/backend/src/types/express/index.d.ts b/backend/src/types/express/index.d.ts index bff5e96c3..6d2acd606 100644 --- a/backend/src/types/express/index.d.ts +++ b/backend/src/types/express/index.d.ts @@ -5,6 +5,9 @@ import { IServiceTokenData, ISecret } from '../../models'; +import { + AuthData +} from '../../interfaces/middleware'; // TODO: fix (any) types declare global { @@ -29,10 +32,7 @@ declare global { serviceTokenData: any; apiKeyData: any; query?: any; - authData: { - authMode: string; - authPayload: IUser | IServiceAccount | IServiceTokenData; - }; + authData: AuthData; requestData: { [key: string]: string }; diff --git a/backend/src/types/secret/index.d.ts b/backend/src/types/secret/index.d.ts index 257038f60..7a6ec7a5a 100644 --- a/backend/src/types/secret/index.d.ts +++ b/backend/src/types/secret/index.d.ts @@ -24,6 +24,7 @@ export interface BatchSecretRequest { export interface BatchSecret { _id: string; type: 'shared' | 'personal', + secretBlindIndex: string; secretKeyCiphertext: string; secretKeyIV: string; secretKeyTag: string; diff --git a/backend/src/utils/auth.ts b/backend/src/utils/auth.ts new file mode 100644 index 000000000..bfb51e6d2 --- /dev/null +++ b/backend/src/utils/auth.ts @@ -0,0 +1,54 @@ +import { AuthData } from '../interfaces/middleware'; +import { + User, + ServiceAccount, + ServiceTokenData, + ServiceToken +} from '../models'; + +// TODO: find a more optimal folder structure to store these types of functions + +/** + * Returns an object containing the id of the authentication data payload + * @param {AuthData} authData - authentication data object + * @returns + */ +const getAuthDataPayloadIdObj = (authData: AuthData) => { + if (authData.authPayload instanceof User) { + return { userId: authData.authPayload._id }; + } + + if (authData.authPayload instanceof ServiceAccount) { + return { serviceAccountId: authData.authPayload._id }; + } + + if (authData.authPayload instanceof ServiceTokenData) { + return { serviceTokenDataId: authData.authPayload._id }; + } +}; + + +/** + * Returns an object containing the user associated with the authentication data payload + * @param {AuthData} authData - authentication data object + * @returns + */ +const getAuthDataPayloadUserObj = (authData: AuthData) => { + + if (authData.authPayload instanceof User) { + return { user: authData.authPayload._id }; + } + + if (authData.authPayload instanceof ServiceAccount) { + return { user: authData.authPayload.user }; + } + + if (authData.authPayload instanceof ServiceTokenData) { + return { user: authData.authPayload.user }; + } +} + +export { + getAuthDataPayloadIdObj, + getAuthDataPayloadUserObj +} \ No newline at end of file diff --git a/backend/src/utils/errors.ts b/backend/src/utils/errors.ts index 987215091..4b698bee3 100644 --- a/backend/src/utils/errors.ts +++ b/backend/src/utils/errors.ts @@ -153,6 +153,16 @@ export const SecretNotFoundError = (error?: Partial) => new stack: error?.stack }); +//* ----->[SECRET BLIND INDEX DATA ERRORS]<----- +export const SecretBlindIndexDataNotFoundError = (error?: Partial) => new RequestError({ + logLevel: error?.logLevel ?? LogLevel.ERROR, + statusCode: error?.statusCode ?? 404, + type: error?.type ?? 'secret_blind_index_data_not_found_error', + message: error?.message ?? 'The requested secret was not found', + context: error?.context, + stack: error?.stack +}); + //* ----->[SECRET SNAPSHOT ERRORS]<----- export const SecretSnapshotNotFoundError = (error?: Partial) => new RequestError({ logLevel: error?.logLevel ?? LogLevel.ERROR, diff --git a/backend/src/variables/workspace.ts b/backend/src/variables/workspace.ts new file mode 100644 index 000000000..e69de29bb diff --git a/backend/swagger/index.ts b/backend/swagger/index.ts index 195a8198c..051f9d64f 100644 --- a/backend/swagger/index.ts +++ b/backend/swagger/index.ts @@ -220,7 +220,7 @@ const generateOpenAPISpec = async () => { const outputJSONFile = '../spec.json'; const outputYAMLFile = '../docs/spec.yaml'; - const endpointsFiles = ['../src/app.ts']; + const endpointsFiles = ['../src/index.ts']; const spec = await swaggerAutogen(outputJSONFile, endpointsFiles, doc); await fs.writeFile(outputYAMLFile, yaml.dump(spec.data)); diff --git a/docs/api-reference/endpoints/secrets/create.mdx b/docs/api-reference/endpoints/secrets/create.mdx index 2fba642a7..85d8ed350 100644 --- a/docs/api-reference/endpoints/secrets/create.mdx +++ b/docs/api-reference/endpoints/secrets/create.mdx @@ -1,11 +1,11 @@ --- title: "Create" -openapi: "POST /api/v2/secrets/" +openapi: "POST /api/v3/secrets/{secretName}" --- Using this route requires understanding Infisical's system and cryptography. It may be helpful to read through the [introduction](/api-reference/overview/introduction) and [guide for creating - secrets](/api-reference/overview/examples/create-secrets). + secrets](/api-reference/overview/examples/create-secret). diff --git a/docs/api-reference/endpoints/secrets/delete.mdx b/docs/api-reference/endpoints/secrets/delete.mdx index af87e7120..d4084a465 100644 --- a/docs/api-reference/endpoints/secrets/delete.mdx +++ b/docs/api-reference/endpoints/secrets/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete" -openapi: "DELETE /api/v2/secrets/" +openapi: "DELETE /api/v3/secrets/{secretName}" --- diff --git a/docs/api-reference/endpoints/secrets/read-one.mdx b/docs/api-reference/endpoints/secrets/read-one.mdx new file mode 100644 index 000000000..6dbbcd726 --- /dev/null +++ b/docs/api-reference/endpoints/secrets/read-one.mdx @@ -0,0 +1,11 @@ +--- +title: "Retrieve" +openapi: "GET /api/v3/secrets/{secretName}" +--- + + + Using this route requires understanding Infisical's system and cryptography. + It may be helpful to read through the + [introduction](/api-reference/overview/introduction) and [guide for retrieving + secrets](/api-reference/overview/examples/retrieve-secret). + diff --git a/docs/api-reference/endpoints/secrets/read.mdx b/docs/api-reference/endpoints/secrets/read.mdx index bc30ec8f2..ab258bdba 100644 --- a/docs/api-reference/endpoints/secrets/read.mdx +++ b/docs/api-reference/endpoints/secrets/read.mdx @@ -1,11 +1,11 @@ --- -title: "Retrieve" -openapi: "GET /api/v2/secrets/" +title: "Retrieve All" +openapi: "GET /api/v3/secrets/" --- Using this route requires understanding Infisical's system and cryptography. It may be helpful to read through the [introduction](/api-reference/overview/introduction) and [guide for retrieving - secrets](/api-reference/overview/examples/retrieve-secrets). + secrets](/api-reference/overview/examples/retrieve-secret). diff --git a/docs/api-reference/endpoints/secrets/update.mdx b/docs/api-reference/endpoints/secrets/update.mdx index c77a9de12..594d8201c 100644 --- a/docs/api-reference/endpoints/secrets/update.mdx +++ b/docs/api-reference/endpoints/secrets/update.mdx @@ -1,11 +1,11 @@ --- title: "Update" -openapi: "PATCH /api/v2/secrets/" +openapi: "PATCH /api/v3/secrets/{secretName}" --- Using this route requires understanding Infisical's system and cryptography. It may be helpful to read through the [introduction](/api-reference/overview/introduction) and [guide for updating - secrets](/api-reference/overview/examples/update-secrets). + secrets](/api-reference/overview/examples/update-secret). diff --git a/docs/api-reference/overview/blind-indices.mdx b/docs/api-reference/overview/blind-indices.mdx new file mode 100644 index 000000000..755ba682f --- /dev/null +++ b/docs/api-reference/overview/blind-indices.mdx @@ -0,0 +1,18 @@ +--- +title: "Blind Indices" +--- + +In April 2023, we added the capability for users to query for secrets by name to improve the user experience of Infisical. Previously, it was only possible to query by id of the secret or fetch all secrets belonging to a project and environment. + +Blind indexing must be enabled for projects created prior to April 2023 to take effect. If your project can be blind indexed, then you'll see a section in your project settings appear as shown below: + +![project enable blind indices](../../images/project-settings-blind-indices.png) + + +It works using virtually irreversible blind indices generated by applying `argon2id` to the name of each secret and a random 128-bit salt assigned to each project on the server. We continue to keep the values of secrets E2EE by default. + +You can read more about it [here](/security/mechanics). + + + As previously mentioned, all projects made after April 2023 are automatically blind indexed. If you created a project before this date, you have to enable it manually in your project settings. + \ No newline at end of file diff --git a/docs/api-reference/overview/examples/create-secrets.mdx b/docs/api-reference/overview/examples/create-secret.mdx similarity index 75% rename from docs/api-reference/overview/examples/create-secrets.mdx rename to docs/api-reference/overview/examples/create-secret.mdx index a20e9f2e3..8979c0c51 100644 --- a/docs/api-reference/overview/examples/create-secrets.mdx +++ b/docs/api-reference/overview/examples/create-secret.mdx @@ -1,21 +1,21 @@ --- -title: "Create secrets" +title: "Create secret" +description: "How to add a secret using an Infisical Token scoped to a project and environment" --- -In this example, we demonstrate how to add secrets to a project and environment using an Infisical Token. - Prerequisites: -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) -- Create an [Infisical Token](../../../getting-started/dashboard/token) for your project and environment. +- Set up and add envars to [Infisical Cloud](https://app.infisical.com). +- Create an [Infisical Token](../../../getting-started/dashboard/token) for your project and environment with write access enabled. - Grasp a basic understanding of the system and its underlying cryptography [here](/api-reference/overview/introduction). +- [Ensure that your project is blind-indexed](../blind-indices). ## Flow 1. [Get your Infisical Token data](/api-reference/endpoints/service-tokens/get) including a (encrypted) project key. 2. Decrypt the (encrypted) project key with the key from your Infisical Token. -3. Encrypt your secret(s) with the project key -4. [Send (encrypted) secret(s) to Infical](/api-reference/endpoints/secrets/create) +3. Encrypt your secret with the project key +4. [Send (encrypted) secret to Infisical](/api-reference/endpoints/secrets/create) ## Example @@ -84,7 +84,7 @@ const createSecrets = async () => { secret: serviceTokenSecret }); - // 3. Encrypt your secret(s) with the project key + // 3. Encrypt your secret with the project key const { ciphertext: secretKeyCiphertext, iv: secretKeyIV, @@ -111,27 +111,23 @@ const createSecrets = async () => { text: secretComment, secret: projectKey }); - - const secret = { - type: secretType, - secretKeyCiphertext, - secretKeyIV, - secretKeyTag, - secretValueCiphertext, - secretValueIV, - secretValueTag, - secretCommentCiphertext, - secretCommentIV, - secretCommentTag - } - // 4. Send (encrypted) secret(s) to Infisical + // 4. Send (encrypted) secret to Infisical await axios.post( - `${BASE_URL}/api/v2/secrets`, + `${BASE_URL}/api/v3/secrets/${secretKey}`, { workspaceId: serviceTokenData.workspace, environment: serviceTokenData.environment, - secrets: [secret] + type: secretType, + secretKeyCiphertext, + secretKeyIV, + secretKeyTag, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag }, { headers: { @@ -204,31 +200,27 @@ def create_secrets(): secret=service_token_secret, ) - # 3. Encrypt your secret(s) with the project key + # 3. Encrypt your secret with the project key encrypted_key_data = encrypt(text=secret_key, secret=project_key) encrypted_value_data = encrypt(text=secret_value, secret=project_key) encrypted_comment_data = encrypt(text=secret_comment, secret=project_key) - secret = { - "type": secret_type, - "secretKeyCiphertext": encrypted_key_data["ciphertext"], - "secretKeyIV": encrypted_key_data["iv"], - "secretKeyTag": encrypted_key_data["tag"], - "secretValueCiphertext": encrypted_value_data["ciphertext"], - "secretValueIV": encrypted_value_data["iv"], - "secretValueTag": encrypted_value_data["tag"], - "secretCommentCiphertext": encrypted_comment_data["ciphertext"], - "secretCommentIV": encrypted_comment_data["iv"], - "secretCommentTag": encrypted_comment_data["tag"], - } - - # 4. Send (encrypted) secret (s) to Infisical + # 4. Send (encrypted) secret to Infisical requests.post( - f"{BASE_URL}/api/v2/secrets", + f"{BASE_URL}/api/v3/secrets/{secret_key}", json={ "workspaceId": service_token_data["workspace"], "environment": service_token_data["environment"], - "secrets": [secret], + "type": secret_type, + "secretKeyCiphertext": encrypted_key_data["ciphertext"], + "secretKeyIV": encrypted_key_data["iv"], + "secretKeyTag": encrypted_key_data["tag"], + "secretValueCiphertext": encrypted_value_data["ciphertext"], + "secretValueIV": encrypted_value_data["iv"], + "secretValueTag": encrypted_value_data["tag"], + "secretCommentCiphertext": encrypted_comment_data["ciphertext"], + "secretCommentIV": encrypted_comment_data["iv"], + "secretCommentTag": encrypted_comment_data["tag"] }, headers={"Authorization": f"Bearer {service_token}"}, ) @@ -238,10 +230,4 @@ create_secrets() ``` - - - - This example uses [TweetNaCl.js](https://tweetnacl.js.org/#/), a port of - TweetNacl/Nacl, to perform asymmeric decryption of the project key but there - are ports of NaCl available in every major language. - \ No newline at end of file + \ No newline at end of file diff --git a/docs/api-reference/overview/examples/delete-secret.mdx b/docs/api-reference/overview/examples/delete-secret.mdx new file mode 100644 index 000000000..f66cd0b69 --- /dev/null +++ b/docs/api-reference/overview/examples/delete-secret.mdx @@ -0,0 +1,94 @@ +--- +title: "Delete secret" +description: "How to delete a secret using an Infisical Token scoped to a project and environment" +--- + +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com). +- Create either an [API Key](/api-reference/overview/authentication) or [Infisical Token](../../../getting-started/dashboard/token) for your project and environment with write access enabled. +- Grasp a basic understanding of the system and its underlying cryptography [here](/api-reference/overview/introduction). +- [Ensure that your project is blind-indexed](../blind-indices). + +## Example + + + +```js +const axios = require('axios'); +const BASE_URL = 'https://app.infisical.com'; + +const deleteSecrets = async () => { + const serviceToken = 'your_service_token'; + const secretType = 'shared' // 'shared' or 'personal' + const secretKey = 'some_key' + + // 1. Get your Infisical Token data + const { data: serviceTokenData } = await axios.get( + `${BASE_URL}/api/v2/service-token`, + { + headers: { + Authorization: `Bearer ${serviceToken}` + } + } + ); + + // 2. Delete secret from Infisical + await axios.delete( + `${BASE_URL}/api/v3/secrets/${secretKey}`, + { + workspaceId: serviceTokenData.workspace, + environment: serviceTokenData.environment, + type: secretType + }, + { + headers: { + Authorization: `Bearer ${serviceToken}` + }, + } + ); +}; + +deleteSecrets(); +``` + + + +```Python +import requests + +BASE_URL = "https://app.infisical.com" + + +def delete_secrets(): + service_token = "" + secret_type = "shared" # "shared" or "personal" + secret_key = "some_key" + + # 1. Get your Infisical Token data + service_token_data = requests.get( + f"{BASE_URL}/api/v2/service-token", + headers={"Authorization": f"Bearer {service_token}"}, + ).json() + + # 2. Delete secret from Infisical + requests.delete( + f"{BASE_URL}/api/v2/secrets/{secret_key}", + json={ + "workspaceId": service_token_data["workspace"], + "environment": service_token_data["environment"], + "type": secret_type + }, + headers={"Authorization": f"Bearer {service_token}"}, + ) + + +delete_secrets() + +``` + + + + If using an `API_KEY` to authenticate with the Infisical API, then you should include it in the `X_API_KEY` header. + + diff --git a/docs/api-reference/overview/examples/delete-secrets.mdx b/docs/api-reference/overview/examples/delete-secrets.mdx deleted file mode 100644 index 766f674ca..000000000 --- a/docs/api-reference/overview/examples/delete-secrets.mdx +++ /dev/null @@ -1,70 +0,0 @@ ---- -title: "Delete secrets" ---- - -In this example, we demonstrate how to delete secrets - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) -- Create either an [API Key](/api-reference/overview/authentication) or [Infisical Token](../../../getting-started/dashboard/token) for your project and environment. -- Grasp a basic understanding of the system and its underlying cryptography [here](/api-reference/overview/introduction). - -## Example - - - -```js -const axios = require('axios'); -const BASE_URL = 'https://app.infisical.com'; - -const deleteSecrets = async () => { - const serviceToken = 'your_service_token'; - const secretId = 'id_of_secret_to_delete'; - - // 6. Send ID(s) of secret(s) to delete to the Infisical API - await axios.delete( - `${BASE_URL}/api/v2/secrets`, - { - secretIds: [secretId], - }, - { - headers: { - Authorization: `Bearer ${serviceToken}` - }, - } - ); -}; - -deleteSecrets(); -``` - - - -```Python -import requests - -BASE_URL = "https://app.infisical.com" - - -def delete_secrets(): - service_token = "" - secret_id = "id_of_secret_to_delete" - - # Send ID(s) of secret(s) to delete to the Infisical API - requests.delete( - f"{BASE_URL}/api/v2/secrets", - json={"secretIds": [secret_id]}, - headers={"Authorization": f"Bearer {service_token}"}, - ) - - -delete_secrets() - -``` - - - - If using an `API_KEY` to authenticate with the Infisical API, then you should include it in the `X_API_KEY` header. - - diff --git a/docs/api-reference/overview/examples/retrieve-secret.mdx b/docs/api-reference/overview/examples/retrieve-secret.mdx new file mode 100644 index 000000000..af20fed1b --- /dev/null +++ b/docs/api-reference/overview/examples/retrieve-secret.mdx @@ -0,0 +1,180 @@ +--- +title: "Retrieve secret" +description: "How to get a secret using an Infisical Token scoped to a project and environment" +--- + +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com). +- Create an [Infisical Token](../../../getting-started/dashboard/token) for your project and environment. +- Grasp a basic understanding of the system and its underlying cryptography [here](/api-reference/overview/introduction). +- [Ensure that your project is blind-indexed](../blind-indices). + +## Flow + +1. [Get your Infisical Token data](/api-reference/endpoints/service-tokens/get) including a (encrypted) project key. +2. [Get the secret from your project and environment](/api-reference/endpoints/secrets/read-one). +3. Decrypt the (encrypted) project key with the key from your Infisical Token. +4. Decrypt the (encrypted) secret + +## Example + + + +```js +const crypto = require('crypto'); +const axios = require('axios'); + +const BASE_URL = 'https://app.infisical.com'; +const ALGORITHM = 'aes-256-gcm'; + +const decrypt = ({ ciphertext, iv, tag, secret}) => { + const decipher = crypto.createDecipheriv( + ALGORITHM, + secret, + Buffer.from(iv, 'base64') + ); + decipher.setAuthTag(Buffer.from(tag, 'base64')); + + let cleartext = decipher.update(ciphertext, 'base64', 'utf8'); + cleartext += decipher.final('utf8'); + + return cleartext; +} + +const getSecret = async () => { + const serviceToken = 'your_service_token'; + const serviceTokenSecret = serviceToken.substring(serviceToken.lastIndexOf('.') + 1); + + const secretType = 'shared' // 'shared' or 'personal' + const secretKey = 'some_key'; + + // 1. Get your Infisical Token data + const { data: serviceTokenData } = await axios.get( + `${BASE_URL}/api/v2/service-token`, + { + headers: { + Authorization: `Bearer ${serviceToken}` + } + } + ); + + // 2. Get the secret from your project and environment + const { data } = await axios.get( + `${BASE_URL}/api/v3/secrets/${secretKey}?${new URLSearchParams({ + environment: serviceTokenData.environment, + workspaceId: serviceTokenData.workspace, + type: secretType // optional, defaults to 'shared' + })}`, + { + headers: { + Authorization: `Bearer ${serviceToken}` + } + } + ); + + const encryptedSecret = data.secret; + + // 3. Decrypt the (encrypted) project key with the key from your Infisical Token + const projectKey = decrypt({ + ciphertext: serviceTokenData.encryptedKey, + iv: serviceTokenData.iv, + tag: serviceTokenData.tag, + secret: serviceTokenSecret + }); + + // 4. Decrypt the (encrypted) secret value + + const secretValue = decrypt({ + ciphertext: encryptedSecret.secretValueCiphertext, + iv: encryptedSecret.secretValueIV, + tag: encryptedSecret.secretValueTag, + secret: projectKey + }); + + console.log('secret: ', ({ + secretKey, + secretValue + })); +} + +getSecret(); + +``` + + + +```Python +import requests +import base64 +from Cryptodome.Cipher import AES + + +BASE_URL = "http://app.infisical.com" + + +def decrypt(ciphertext, iv, tag, secret): + secret = bytes(secret, "utf-8") + iv = base64.standard_b64decode(iv) + tag = base64.standard_b64decode(tag) + ciphertext = base64.standard_b64decode(ciphertext) + + cipher = AES.new(secret, AES.MODE_GCM, iv) + cipher.update(tag) + cleartext = cipher.decrypt(ciphertext).decode("utf-8") + return cleartext + + +def get_secret(): + service_token = "your_service_token" + service_token_secret = service_token[service_token.rindex(".") + 1 :] + + secret_type = "shared" # "shared" or "personal" + secret_key = "some_key" + + # 1. Get your Infisical Token data + service_token_data = requests.get( + f"{BASE_URL}/api/v2/service-token", + headers={"Authorization": f"Bearer {service_token}"}, + ).json() + + # 2. Get secret from your project and environment + data = requests.get( + f"{BASE_URL}/api/v3/secrets/{secret_key}", + params={ + "environment": service_token_data["environment"], + "workspaceId": service_token_data["workspace"], + "type": secret_type # optional, defaults to "shared" + }, + headers={"Authorization": f"Bearer {service_token}"}, + ).json() + + encrypted_secret = data["secret"] + + # 3. Decrypt the (encrypted) project key with the key from your Infisical Token + project_key = decrypt( + ciphertext=service_token_data["encryptedKey"], + iv=service_token_data["iv"], + tag=service_token_data["tag"], + secret=service_token_secret, + ) + + # 4. Decrypt the (encrypted) secret value + secret_value = decrypt( + ciphertext=encrypted_secret["secretValueCiphertext"], + iv=encrypted_secret["secretValueIV"], + tag=encrypted_secret["secretValueTag"], + secret=project_key, + ) + + print("secret: ", { + "secret_key": secret_key, + "secret_value": secret_value + }) + + +get_secret() + +``` + + \ No newline at end of file diff --git a/docs/api-reference/overview/examples/retrieve-secrets.mdx b/docs/api-reference/overview/examples/retrieve-secrets.mdx index 794f76d7a..f561a7579 100644 --- a/docs/api-reference/overview/examples/retrieve-secrets.mdx +++ b/docs/api-reference/overview/examples/retrieve-secrets.mdx @@ -1,14 +1,14 @@ --- title: "Retrieve secrets" +description: "How to get all secrets using an Infisical Token scoped to a project and environment" --- -In this example, we demonstrate how to retrieve secrets from a project and environment using an Infisical Token. - Prerequisites: - Set up and add envars to [Infisical Cloud](https://app.infisical.com). - Create an [Infisical Token](../../../getting-started/dashboard/token) for your project and environment. - Grasp a basic understanding of the system and its underlying cryptography [here](/api-reference/overview/introduction). +- [Ensure that your project is blind-indexed](../blind-indices). ## Flow @@ -58,7 +58,7 @@ const getSecrets = async () => { // 2. Get secrets for your project and environment const { data } = await axios.get( - `${BASE_URL}/api/v2/secrets?${new URLSearchParams({ + `${BASE_URL}/api/v3/secrets?${new URLSearchParams({ environment: serviceTokenData.environment, workspaceId: serviceTokenData.workspace })}`, @@ -143,7 +143,7 @@ def get_secrets(): # 2. Get secrets for your project and environment data = requests.get( - f"{BASE_URL}/api/v2/secrets", + f"{BASE_URL}/api/v3/secrets", params={ "environment": service_token_data["environment"], "workspaceId": service_token_data["workspace"], @@ -192,10 +192,4 @@ get_secrets() ``` - - - - This example uses [TweetNaCl.js](https://tweetnacl.js.org/#/), a port of - TweetNacl/Nacl, to perform asymmeric decryption of the project key but there - are ports of NaCl available in every major language. - \ No newline at end of file + \ No newline at end of file diff --git a/docs/api-reference/overview/examples/update-secrets.mdx b/docs/api-reference/overview/examples/update-secret.mdx similarity index 71% rename from docs/api-reference/overview/examples/update-secrets.mdx rename to docs/api-reference/overview/examples/update-secret.mdx index 13bc4ee3e..73e744331 100644 --- a/docs/api-reference/overview/examples/update-secrets.mdx +++ b/docs/api-reference/overview/examples/update-secret.mdx @@ -1,21 +1,21 @@ --- -title: "Update secrets" +title: "Update secret" +description: "How to update a secret using an Infisical Token scoped to a project and environment" --- -In this example, we demonstrate how to update secrets using an Infisical Token. - Prerequisites: -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) -- Create an [Infisical Token](../../../getting-started/dashboard/token) for your project and environment. +- Set up and add envars to [Infisical Cloud](https://app.infisical.com). +- Create an [Infisical Token](../../../getting-started/dashboard/token) for your project and environment with write access enabled. - Grasp a basic understanding of the system and its underlying cryptography [here](/api-reference/overview/introduction). +- [Ensure that your project is blind-indexed](../blind-indices). ## Flow 1. [Get your Infisical Token data](/api-reference/endpoints/service-tokens/get) including a (encrypted) project key. 2. Decrypt the (encrypted) project key with the key from your Infisical Token. -3. Encrypt your updated secret(s) with the project key -4. [Send (encrypted) updated secret(s) to Infical](/api-reference/endpoints/secrets/update) +3. Encrypt your updated secret with the project key +4. [Send (encrypted) updated secret to Infical](/api-reference/endpoints/secrets/update) ## Example @@ -60,7 +60,7 @@ const updateSecrets = async () => { const serviceToken = 'your_service_token'; const serviceTokenSecret = serviceToken.substring(serviceToken.lastIndexOf('.') + 1); - const secretId = 'id_of_secret_to_update'; + const secretType = 'shared' // 'shared' or 'personal' const secretKey = 'some_key'; const secretValue = 'updated_value'; const secretComment = 'updated_comment'; @@ -83,7 +83,7 @@ const updateSecrets = async () => { secret: serviceTokenSecret }); - // 3. Encrypt your updated secret(s) with the project key + // 3. Encrypt your updated secret with the project key const { ciphertext: secretKeyCiphertext, iv: secretKeyIV, @@ -110,27 +110,20 @@ const updateSecrets = async () => { text: secretComment, secret: projectKey }); - - const secret = { - id: secretId, - workspace: serviceTokenData.workspace, - environment: serviceTokenData.environment, - secretKeyCiphertext, - secretKeyIV, - secretKeyTag, - secretValueCiphertext, - secretValueIV, - secretValueTag, - secretCommentCiphertext, - secretCommentIV, - secretCommentTag - } - // 4. Send (encrypted) updated secret(s) to Infisical + // 4. Send (encrypted) updated secret to Infisical await axios.patch( - `${BASE_URL}/api/v2/secrets`, + `${BASE_URL}/api/v3/secrets/${secretKey}`, { - secrets: [secret] + workspaceId: serviceTokenData.workspace, + environment: serviceTokenData.environment, + type: secretType, + secretValueCiphertext, + secretValueIV, + secretValueTag, + secretCommentCiphertext, + secretCommentIV, + secretCommentTag }, { headers: { @@ -184,7 +177,7 @@ def update_secret(): service_token = "your_service_token" service_token_secret = service_token[service_token.rindex(".") + 1 :] - secret_id = "id_of_secret_to_update" + secret_type = "shared" # "shared" or "personal" secret_key = "some_key" secret_value = "updated_value" secret_comment = "updated_comment" @@ -203,30 +196,28 @@ def update_secret(): secret=service_token_secret, ) - # 3. Encrypt your updated secret(s) with the project key + # 3. Encrypt your updated secret with the project key encrypted_key_data = encrypt(text=secret_key, secret=project_key) encrypted_value_data = encrypt(text=secret_value, secret=project_key) encrypted_comment_data = encrypt(text=secret_comment, secret=project_key) - secret = { - "id": secret_id, - "workspace": service_token_data["workspace"], - "environment": service_token_data["environment"], - "secretKeyCiphertext": encrypted_key_data["ciphertext"], - "secretKeyIV": encrypted_key_data["iv"], - "secretKeyTag": encrypted_key_data["tag"], - "secretValueCiphertext": encrypted_value_data["ciphertext"], - "secretValueIV": encrypted_value_data["iv"], - "secretValueTag": encrypted_value_data["tag"], - "secretCommentCiphertext": encrypted_comment_data["ciphertext"], - "secretCommentIV": encrypted_comment_data["iv"], - "secretCommentTag": encrypted_comment_data["tag"], - } - - # 4. Send (encrypted) updated secret(s) to Infisical + # 4. Send (encrypted) updated secret to Infisical requests.patch( - f"{BASE_URL}/api/v2/secrets", - json={"secrets": [secret]}, + f"{BASE_URL}/api/v3/secrets/{secret_key}", + json={ + "workspaceId": service_token_data["workspace"], + "environment": service_token_data["environment"], + "type": secret_type, + "secretKeyCiphertext": encrypted_key_data["ciphertext"], + "secretKeyIV": encrypted_key_data["iv"], + "secretKeyTag": encrypted_key_data["tag"], + "secretValueCiphertext": encrypted_value_data["ciphertext"], + "secretValueIV": encrypted_value_data["iv"], + "secretValueTag": encrypted_value_data["tag"], + "secretCommentCiphertext": encrypted_comment_data["ciphertext"], + "secretCommentIV": encrypted_comment_data["iv"], + "secretCommentTag": encrypted_comment_data["tag"] + }, headers={"Authorization": f"Bearer {service_token}"}, ) @@ -235,10 +226,4 @@ update_secret() ``` - - - - This example uses [TweetNaCl.js](https://tweetnacl.js.org/#/), a port of - TweetNacl/Nacl, to perform asymmeric decryption of the project key but there - are ports of NaCl available in every major language. - \ No newline at end of file + \ No newline at end of file diff --git a/docs/api-reference/overview/introduction.mdx b/docs/api-reference/overview/introduction.mdx index 585abc8f0..5199256e5 100644 --- a/docs/api-reference/overview/introduction.mdx +++ b/docs/api-reference/overview/introduction.mdx @@ -14,9 +14,13 @@ With the Public API, users can create, read, update, and delete secrets, as well If you decide to make your own requests using the API reference instead, be prepared for a steeper learning curve and more manual work. + + In April 2023, we added the capability for users to query for secrets by name to improve the user experience of Infisical. If your project was created prior to April 2023, please read and follow the section on [blind indices](./blind-indices) and how to enable them for better usage of Infisical. + + ## Concepts -Using Infisical's API to manage secrets requires a basic understanding of the system and its underlying cryptography detailed [here](/security/overview). +Using Infisical's API to manage secrets requires a basic understanding of the system and its underlying cryptography detailed [here](/security/overview). A few key points: - Each user has a public/private key pair that is stored with the platform; private keys are encrypted locally by protected keys that are encrypted by keys derived from Argon2id applied to the user's password before being sent off to the server during the account signup process. - Each (encrypted) secret belongs to a project and environment. diff --git a/docs/images/project-settings-blind-indices.png b/docs/images/project-settings-blind-indices.png new file mode 100644 index 000000000..d52936ffe Binary files /dev/null and b/docs/images/project-settings-blind-indices.png differ diff --git a/docs/mint.json b/docs/mint.json index 9694e046e..606c4dc76 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -207,13 +207,15 @@ "pages": [ "api-reference/overview/introduction", "api-reference/overview/authentication", + "api-reference/overview/blind-indices", { "group": "Examples", "pages": [ - "api-reference/overview/examples/create-secrets", "api-reference/overview/examples/retrieve-secrets", - "api-reference/overview/examples/update-secrets", - "api-reference/overview/examples/delete-secrets" + "api-reference/overview/examples/create-secret", + "api-reference/overview/examples/retrieve-secret", + "api-reference/overview/examples/update-secret", + "api-reference/overview/examples/delete-secret" ] } ] @@ -252,8 +254,9 @@ { "group": "Secrets", "pages": [ - "api-reference/endpoints/secrets/create", "api-reference/endpoints/secrets/read", + "api-reference/endpoints/secrets/create", + "api-reference/endpoints/secrets/read-one", "api-reference/endpoints/secrets/update", "api-reference/endpoints/secrets/delete", "api-reference/endpoints/secrets/versions", diff --git a/docs/security/data-model.mdx b/docs/security/data-model.mdx index 9f79cd254..af4adeb24 100644 --- a/docs/security/data-model.mdx +++ b/docs/security/data-model.mdx @@ -21,21 +21,30 @@ Infisical makes a usability-security tradeoff that is to give users convenient a ## Secrets -The `Secret` model includes the fields `workspace`, `type`, `user`, `environment`, `secretKeyCiphertext`, `secretKeyIV`, `secretKeyTag`, `secretValueCiphertext`, `secretValueIV`, and `secretValueTag`. +The `Secret` model includes the fields `workspace`, `type`, `user`, `environment`, `secretBlindIndex`, `secretKeyCiphertext`, `secretKeyIV`, `secretKeyTag`, `secretValueCiphertext`, `secretValueIV`, and `secretValueTag`. -Each secret is symmetrically encrypted by the key of the project that it belongs to; that key's encrypted copies are stored in a separate `Key` collection. +Each secret consists of a key name and value pair and is symmetrically encrypted by the key of the project that it belongs to; that key's encrypted copies are stored in a separate `Key` collection. + +The `secretBlindIndex` enables users to query secrets by their names; it is a blind index computed by applying `argon2id` with a 128-bit random salt (unique to each project) and the name of the secret. The salt itself is symmetrically encrypted under the server key and stored in the `SecretBlindIndexData` collection. + +## Blind Index Data + +The `SecretBlindIndexData` model includes the fields `workspace`, `encryptedSaltCiphertext`, `saltIV`, and `saltTag`. + +Infisical stores salts (unique to each project) symmetrically encrypted under the server key. The salts are used to compute blind indices for secrets that enable +users to query secrets by name. ## Project Keys The `Key` model includes the fields `encryptedKey`, `nonce`, `sender`, `receiver`, and `workspace`. -Infisical stores copies of project keys, one for each member of a project, encrypted under each member's public key. +Infisical stores copies of project keys, one for each member of a project, asymmetrically encrypted under each member's public key. ## Bots The `Bot` model contains the fields `name`, `workspace`, `isActive`, `publicKey`, `encryptedPrivateKey`, `iv`, and `tag`. -Each project comes with a bot that has its own public-private key pair; its private key is encrypted by the server's symmetric key. If needed, a user can opt-in to share their project key with the bot (i.e. Infisical) to give the platform access to the project's secrets. +Each project comes with a bot that has its own public-private key pair; its private key is symmetrically encrypted by the server's key. If needed, a user can opt-in to share their project key with the bot (i.e. Infisical) to give the platform access to the project's secrets. Sharing secrets with Infisical so they can be synced to integrations like diff --git a/docs/security/mechanics.mdx b/docs/security/mechanics.mdx index 325224f90..97cd570f1 100644 --- a/docs/security/mechanics.mdx +++ b/docs/security/mechanics.mdx @@ -27,3 +27,20 @@ After signing up, a user can invite other users to their organization to partake To push secrets, a sender randomly-generates a symmetric encryption key, uses that key to encrypt their secret keys and values separately, asymmetrically encrypts the key with the receivers’ public keys, and uploads the encrypted secrets and keys to the server. To pull secrets, a receiver obtains encrypted secret keys and values and their encrypted copy of the project key to decrypt the secrets from the server — they asymmetrically decrypt the key using their private key and use the decrypted key to decrypt the secrets. This public-key mechanism prevents the server-side from reading any secrets. + +When dealing with individual secrets (e.g. pulling one secret by name) or creating new secrets, a user passes the name of the secret to the server which is then converted to a blind index by applying `argon2id` with the name of the secret and a 128-bit random salt unique to each project; the salt itself is encrypted by the server key and stored in the database. + + + Infisical ensures that the name of any secret is never stored in plaintext and instead only a blind index generated from the name. It is infeasible to reverse back a blind index to the name of a secret without knowledge of the server key. + + +## Bot + +To use some features like integrations, users must opt out of E2EE (this means sharing access to secrets with Infisical). + +Infisical employs the concept of a bot which is a cryptographic abstraction for how Infisical interacts with secrets when users opt out of E2EE. In this model, each project is assigned a bot with its own public-private key pair where each bot's private key is stored symmetrically encrypted under the server key. When a user opts out of E2EE, they share the project key with the bot by encrypting a copy of it under the public key of the bot. + +When users wish to sync secrets from a project and environment Infisical to other platform integrations like Vercel or GitHub, Infisical decrypts the intended secrets and uses the integration platform's APIs to send secrets over. It should be noted that opting out of E2EE is optional and it is entirely possible to use Infisical to manage secrets across your team and infrastructure without opting out of E2EE. + + + diff --git a/docs/security/overview.mdx b/docs/security/overview.mdx index 643a54b38..bddff608b 100644 --- a/docs/security/overview.mdx +++ b/docs/security/overview.mdx @@ -5,7 +5,7 @@ description: "Infisical's security statement." ## Summary -Infisical uses end-to-end encryption (E2EE) whenever possible to securely store and share secrets. It uses secure remote password (SRP) to handle authentication and public-key cryptography for secret sharing and syncing; secrets are symmetrically encrypted at rest by keys decryptable only by members of the project. +Infisical uses end-to-end encryption (E2EE) whenever possible to securely store and share secret values. It uses secure remote password (SRP) to handle authentication and public-key cryptography for secret sharing and syncing; secrets are symmetrically encrypted by keys decryptable only by members of the project. Infisical uses AES256-GCM for symmetric encryption and x25519-xsalsa20-poly1305 for asymmetric encryption operations mentioned in this brief; key generation and asymmetric algorithms are implemented with the [TweetNaCl.js](https://tweetnacl.js.org/#/) library which has been well-audited and recommended for use by cybersecurity firm Cure53. Lastly, the secure remote password (SRP) implementation uses [jsrp](https://github.com/alax/jsrp) package for user authentication. As part of our commitment to user privacy and security, we aim to conduct formal security and compliance audits in the following year. diff --git a/docs/spec.yaml b/docs/spec.yaml index b57caf89a..89f01e8da 100644 --- a/docs/spec.yaml +++ b/docs/spec.yaml @@ -940,7 +940,11 @@ paths: /api/v1/invite-org/signup: post: description: '' - parameters: [] + parameters: + - name: host + in: header + schema: + type: string responses: '200': description: OK @@ -1277,6 +1281,12 @@ paths: example: any targetEnvironment: example: any + targetEnvironmentId: + example: any + targetService: + example: any + targetServiceId: + example: any owner: example: any path: @@ -1415,11 +1425,75 @@ paths: required: true schema: type: string + - name: teamId + in: query + schema: + type: string responses: '200': description: OK '400': description: Bad Request + /api/v1/integration-auth/{integrationAuthId}/teams: + get: + description: '' + parameters: + - name: integrationAuthId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v1/integration-auth/{integrationAuthId}/vercel/branches: + get: + description: '' + parameters: + - name: integrationAuthId + in: path + required: true + schema: + type: string + - name: appId + in: query + schema: + type: string + responses: + '200': + description: OK + /api/v1/integration-auth/{integrationAuthId}/railway/environments: + get: + description: '' + parameters: + - name: integrationAuthId + in: path + required: true + schema: + type: string + - name: appId + in: query + schema: + type: string + responses: + '200': + description: OK + /api/v1/integration-auth/{integrationAuthId}/railway/services: + get: + description: '' + parameters: + - name: integrationAuthId + in: path + required: true + schema: + type: string + - name: appId + in: query + schema: + type: string + responses: + '200': + description: OK /api/v2/signup/complete-account/signup: post: description: '' @@ -1771,10 +1845,20 @@ paths: items: $ref: '#/components/schemas/Project' description: Projects of organization - '400': - description: Bad Request security: - apiKeyAuth: [] + /api/v2/organizations/{organizationId}/service-accounts: + get: + description: '' + parameters: + - name: organizationId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK /api/v2/workspace/{workspaceId}/environments: post: description: '' @@ -2467,8 +2551,6 @@ paths: responses: '200': description: OK - '400': - description: Bad Request requestBody: content: application/json: @@ -2503,8 +2585,139 @@ paths: responses: '200': description: OK + /api/v2/service-accounts/me: + get: + description: '' + parameters: [] + responses: + '200': + description: OK + /api/v2/service-accounts/{serviceAccountId}: + get: + description: '' + parameters: + - name: serviceAccountId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + delete: + description: '' + parameters: + - name: serviceAccountId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v2/service-accounts/: + post: + description: '' + parameters: [] + responses: + '200': + description: OK + /api/v2/service-accounts/{serviceAccountId}/name: + patch: + description: '' + parameters: + - name: serviceAccountId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + example: any + /api/v2/service-accounts/{serviceAccountId}/permissions/workspace: + get: + description: '' + parameters: + - name: serviceAccountId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + post: + description: '' + parameters: + - name: serviceAccountId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK '400': description: Bad Request + requestBody: + content: + application/json: + schema: + type: object + properties: + environment: + example: any + workspaceId: + example: any + read: + example: any + write: + example: any + encryptedKey: + example: any + nonce: + example: any + /api/v2/service-accounts/{serviceAccountId}/permissions/workspace/{serviceAccountWorkspacePermissionId}: + delete: + description: '' + parameters: + - name: serviceAccountId + in: path + required: true + schema: + type: string + - name: serviceAccountWorkspacePermissionId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v2/service-accounts/{serviceAccountId}/keys: + get: + description: '' + parameters: + - name: serviceAccountId + in: path + required: true + schema: + type: string + - name: workspaceId + in: query + schema: + type: string + responses: + '200': + description: OK /api/v2/api-key/: get: description: '' @@ -2546,6 +2759,182 @@ paths: description: OK '400': description: Bad Request + /api/v3/secrets/: + get: + description: '' + parameters: + - name: workspaceId + in: query + schema: + type: string + - name: environment + in: query + schema: + type: string + responses: + '200': + description: OK + /api/v3/secrets/{secretName}: + post: + description: '' + parameters: + - name: secretName + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + requestBody: + content: + application/json: + schema: + type: object + properties: + workspaceId: + example: any + environment: + example: any + type: + example: any + secretKeyCiphertext: + example: any + secretKeyIV: + example: any + secretKeyTag: + example: any + secretValueCiphertext: + example: any + secretValueIV: + example: any + secretValueTag: + example: any + secretCommentCiphertext: + example: any + secretCommentIV: + example: any + secretCommentTag: + example: any + get: + description: '' + parameters: + - name: secretName + in: path + required: true + schema: + type: string + - name: workspaceId + in: query + schema: + type: string + - name: environment + in: query + schema: + type: string + - name: type + in: query + schema: + type: string + responses: + '200': + description: OK + patch: + description: '' + parameters: + - name: secretName + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + requestBody: + content: + application/json: + schema: + type: object + properties: + workspaceId: + example: any + environment: + example: any + type: + example: any + secretValueCiphertext: + example: any + secretValueIV: + example: any + secretValueTag: + example: any + delete: + description: '' + parameters: + - name: secretName + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + requestBody: + content: + application/json: + schema: + type: object + properties: + workspaceId: + example: any + environment: + example: any + type: + example: any + /api/v3/workspaces/{workspaceId}/secrets/blind-index-status: + get: + description: '' + parameters: + - name: workspaceId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v3/workspaces/{workspaceId}/secrets: + get: + description: '' + parameters: + - name: workspaceId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + /api/v3/workspaces/{workspaceId}/secrets/names: + post: + description: '' + parameters: + - name: workspaceId + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + requestBody: + content: + application/json: + schema: + type: object + properties: + secretsToUpdate: + example: any /api/status: get: description: '' diff --git a/frontend/src/components/utilities/secrets/encryptSecrets.ts b/frontend/src/components/utilities/secrets/encryptSecrets.ts index 7b678a494..afaf27ab5 100644 --- a/frontend/src/components/utilities/secrets/encryptSecrets.ts +++ b/frontend/src/components/utilities/secrets/encryptSecrets.ts @@ -10,6 +10,7 @@ interface EncryptedSecretProps { id: string; createdAt: string; environment: string; + secretName: string; secretCommentCiphertext: string; secretCommentIV: string; secretCommentTag: string; @@ -94,6 +95,7 @@ const encryptSecrets = async ({ id: secret.id, createdAt: '', environment: env, + secretName: secret.key, secretKeyCiphertext, secretKeyIV, secretKeyTag, diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts index 8f6d02102..971aa73ca 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -93,6 +93,7 @@ const initProjectHelper = async ({ }) => { let project; try { + // create new project project = await createWorkspace({ workspaceName: projectName, diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index 622c5196c..3a9d8fd2f 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -7,7 +7,9 @@ export { useGetUserWorkspaces, useGetUserWsEnvironments, useGetWorkspaceById, + useGetWorkspaceIndexStatus, + useGetWorkspaceSecrets, + useNameWorkspaceSecrets, useRenameWorkspace, useToggleAutoCapitalization, - useUpdateWsEnvironment -} from './queries'; + useUpdateWsEnvironment} from './queries'; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 4736da076..d2d7a4cde 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -2,12 +2,16 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { apiRequest } from '@app/config/request'; +import { + EncryptedSecret +} from '../secrets/types'; import { CreateEnvironmentDTO, CreateWorkspaceDTO, DeleteEnvironmentDTO, DeleteWorkspaceDTO, GetWsEnvironmentDTO, + NameWorkspaceSecretsDTO, RenameWorkspaceDTO, ToggleAutoCapitalizationDTO, UpdateEnvironmentDTO, @@ -17,6 +21,8 @@ import { const workspaceKeys = { getWorkspaceById: (workspaceId: string) => [{ workspaceId }, 'workspace'] as const, + getWorkspaceSecrets: (workspaceId: string) => [{ workspaceId }, 'workspace-secrets'] as const, + getWorkspaceIndexStatus: (workspaceId: string) => [{ workspaceId}, 'workspace-index-status'] as const, getWorkspaceMemberships: (orgId: string) => [{ orgId }, 'workspace-memberships'], getAllUserWorkspace: ['workspaces'] as const, getUserWsEnvironments: (workspaceId: string) => ['workspace-env', { workspaceId }] as const @@ -26,14 +32,47 @@ const fetchWorkspaceById = async (workspaceId: string) => { const { data } = await apiRequest.get<{ workspace: Workspace }>( `/api/v1/workspace/${workspaceId}` ); + return data.workspace; }; +const fetchWorkspaceIndexStatus = async (workspaceId: string) => { + const { data } = await apiRequest.get( + `/api/v3/workspaces/${workspaceId}/secrets/blind-index-status` + ); + + return data; +} + +const fetchWorkspaceSecrets = async (workspaceId: string) => { + const { data: { secrets } } = await apiRequest.get<{ secrets: EncryptedSecret[] }>( + `/api/v3/workspaces/${workspaceId}/secrets` + ); + + return secrets; +} + const fetchUserWorkspaces = async () => { const { data } = await apiRequest.get<{ workspaces: Workspace[] }>('/api/v1/workspace'); return data.workspaces; }; +export const useGetWorkspaceIndexStatus = (workspaceId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceIndexStatus(workspaceId), + queryFn: () => fetchWorkspaceIndexStatus(workspaceId), + enabled: true + }); +} + +export const useGetWorkspaceSecrets = (workspaceId: string) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceSecrets(workspaceId), + queryFn: () => fetchWorkspaceSecrets(workspaceId), + enabled: true + }) +} + export const useGetWorkspaceById = (workspaceId: string) => { return useQuery({ queryKey: workspaceKeys.getWorkspaceById(workspaceId), @@ -75,6 +114,20 @@ export const useGetUserWorkspaceMemberships = (orgId: string) => enabled: Boolean(orgId) }); +export const useNameWorkspaceSecrets = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, NameWorkspaceSecretsDTO>({ + mutationFn: async ({ workspaceId, secretsToUpdate }) => + apiRequest.post(`/api/v3/workspaces/${workspaceId}/secrets/names`, { + secretsToUpdate + }), + onSuccess: (_, variables) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceIndexStatus(variables.workspaceId)); + } + }); +} + // mutation export const useCreateWorkspace = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 44d4c4322..3f58f39b9 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -16,6 +16,14 @@ export type WorkspaceEnv = { export type WorkspaceTag = { _id: string; name: string; slug: string }; +export type NameWorkspaceSecretsDTO = { + workspaceId: string; + secretsToUpdate: { + secretName: string; + _id: string; + }[]; +} + // mutation dto export type CreateWorkspaceDTO = { workspaceName: string; diff --git a/frontend/src/pages/dashboard/[id].tsx b/frontend/src/pages/dashboard/[id].tsx index 2f1b8e2e4..418c038f5 100644 --- a/frontend/src/pages/dashboard/[id].tsx +++ b/frontend/src/pages/dashboard/[id].tsx @@ -586,6 +586,7 @@ export default function Dashboard() { method: 'POST', secret: { type: secret.type, + secretName: secret.secretName, secretKeyCiphertext: secret.secretKeyCiphertext, secretKeyIV: secret.secretKeyIV, secretKeyTag: secret.secretKeyTag, @@ -614,6 +615,7 @@ export default function Dashboard() { secret: { _id: secret.id, type: secret.type, + secretName: secret.secretName, secretKeyCiphertext: secret.secretKeyCiphertext, secretKeyIV: secret.secretKeyIV, secretKeyTag: secret.secretKeyTag, diff --git a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx index af812c6aa..3ba5bc956 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/ProjectSettingsPage.tsx @@ -9,8 +9,8 @@ import NavHeader from '@app/components/navigation/NavHeader'; // TODO(akhilmhdh):Refactor this into a better utility module package import { decryptAssymmetric, - encryptSymmetric -} from '@app/components/utilities/cryptography/crypto'; + decryptSymmetric, + encryptSymmetric} from '@app/components/utilities/cryptography/crypto'; import { Button, FormControl, Input } from '@app/components/v2'; import { plans } from '@app/const'; import { useSubscription, useWorkspace } from '@app/context'; @@ -25,11 +25,13 @@ import { useDeleteWsTag, useGetUserWsKey, useGetUserWsServiceTokens, + useGetWorkspaceIndexStatus, + useGetWorkspaceSecrets, useGetWsTags, + useNameWorkspaceSecrets, useRenameWorkspace, useToggleAutoCapitalization, - useUpdateWsEnvironment -} from '@app/hooks/api'; + useUpdateWsEnvironment} from '@app/hooks/api'; import { AutoCapitalizationSection } from './components/AutoCapitalizationSection/AutoCapitalizationSection'; import { SecretTagsSection } from './components/SecretTagsSection'; @@ -39,6 +41,7 @@ import { CreateUpdateEnvFormData, CreateWsTag, EnvironmentSection, + ProjectIndexSecretsSection, ProjectNameChangeSection, ServiceTokenSection } from './components'; @@ -55,6 +58,7 @@ export const ProjectSettingsPage = () => { const [isDeleting, setIsDeleting] = useToggle(); const renameWorkspace = useRenameWorkspace(); + const nameWorkspaceSecrets = useNameWorkspaceSecrets(); const toggleAutoCapitalization = useToggleAutoCapitalization(); const deleteWorkspace = useDeleteWorkspace(); @@ -63,11 +67,16 @@ export const ProjectSettingsPage = () => { const updateWsEnv = useUpdateWsEnvironment(); const deleteWsEnv = useDeleteWsEnvironment(); + const { data: isBlindIndexed, isLoading: isBlindIndexedLoading } = useGetWorkspaceIndexStatus(workspaceID); + // service token const { data: serviceTokens, isLoading: isServiceTokenLoading } = useGetUserWsServiceTokens({ workspaceID: currentWorkspace?._id || '' }); + const { data: latestFileKey } = useGetUserWsKey(workspaceID); + const { data: encryptedSecrets } = useGetWorkspaceSecrets(workspaceID); + const createServiceToken = useCreateServiceToken(); const deleteServiceToken = useDeleteServiceToken(); @@ -207,14 +216,15 @@ export const ProjectSettingsPage = () => { // type guard if (!latestFileKey) return ''; try { - // crypo calculation to generate the key const key = decryptAssymmetric({ ciphertext: latestFileKey.encryptedKey, nonce: latestFileKey.nonce, publicKey: latestFileKey.sender.publicKey, privateKey: localStorage.getItem('PRIVATE_KEY') as string }); + const randomBytes = crypto.randomBytes(16).toString('hex'); + const { ciphertext, iv, tag } = encryptSymmetric({ plaintext: key, key: randomBytes @@ -303,6 +313,38 @@ export const ProjectSettingsPage = () => { } }; + const onEnableBlindIndices = async () => { + if (!currentWorkspace?._id) return; + if (!encryptedSecrets) return; + if (!latestFileKey) return; + + const key = decryptAssymmetric({ + ciphertext: latestFileKey.encryptedKey, + nonce: latestFileKey.nonce, + publicKey: latestFileKey.sender.publicKey, + privateKey: localStorage.getItem('PRIVATE_KEY') as string + }); + + const secretsToUpdate = encryptedSecrets.map((encryptedSecret) => { + const secretName = decryptSymmetric({ + ciphertext: encryptedSecret.secretKeyCiphertext, + iv: encryptedSecret.secretKeyIV, + tag: encryptedSecret.secretKeyTag, + key + }); + + return ({ + secretName, + _id: encryptedSecret._id + }); + }); + + await nameWorkspaceSecrets.mutateAsync({ + workspaceId: currentWorkspace._id, + secretsToUpdate + }); + } + return (
{/* TODO(akhilmhdh): Remove this right when layout is refactored */} @@ -349,6 +391,11 @@ export const ProjectSettingsPage = () => { workspaceAutoCapitalization={currentWorkspace?.autoCapitalization} onAutoCapitalizationChange={onAutoCapitalizationToggle} /> + {!isBlindIndexedLoading && !isBlindIndexed && ( + + )}

{t('settings-project:danger-zone')}

{t('settings-project:danger-zone-note')}

diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectIndexSecretsSection/ProjectIndexSecretsSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectIndexSecretsSection/ProjectIndexSecretsSection.tsx new file mode 100644 index 000000000..d1ca4f265 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectIndexSecretsSection/ProjectIndexSecretsSection.tsx @@ -0,0 +1,32 @@ +import { Button } from '@app/components/v2'; + +// TODO: add check so that this only shows up if user is +// an admin in the workspace + +type Props = { + onEnableBlindIndices: () => Promise; +} + +export const ProjectIndexSecretsSection = ({ + onEnableBlindIndices +}: Props) => { + return ( +
+

Blind Indices

+

+ Your project, created before the introduction of blind indexing, contains unindexed secrets. To access individual secrets by name through the SDK and public API, please enable blind indexing. +

+

+ Learn more about it here. +

+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectIndexSecretsSection/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectIndexSecretsSection/index.tsx new file mode 100644 index 000000000..cd8252f7d --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectIndexSecretsSection/index.tsx @@ -0,0 +1 @@ +export { ProjectIndexSecretsSection } from './ProjectIndexSecretsSection'; \ No newline at end of file diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx index 98160b5b7..a59111447 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/index.tsx @@ -1,6 +1,7 @@ export { CopyProjectIDSection } from './CopyProjectIDSection'; export { EnvironmentSection } from './EnvironmentSection'; export type { CreateUpdateEnvFormData } from './EnvironmentSection/EnvironmentSection'; +export { ProjectIndexSecretsSection } from './ProjectIndexSecretsSection'; export { ProjectNameChangeSection } from './ProjectNameChangeSection'; export type { CreateWsTag } from './SecretTagsSection/SecretTagsSection'; export { ServiceTokenSection } from './ServiceTokenSection';