Compare commits

...

77 Commits

Author SHA1 Message Date
Daniel Hougaard
a91f64f742 fix(k8-operator): missing generation, helm, and error formatting 2024-12-07 01:20:13 +04:00
Daniel Hougaard
1bc508b286 Merge pull request #2771 from akhilmhdh/feat/template-in-operator
Template support in k8s operator
2024-12-07 00:09:26 +04:00
Akhil Mohan
d3d30eba80 Merge pull request #2823 from Infisical/daniel/consolidate-request-ids
fix: consolidate reqId and requestId fields
2024-12-06 10:56:18 +05:30
Daniel Hougaard
623a99be0e fix: consolidate reqId and requestId fields 2024-12-06 01:34:07 +04:00
Akhil Mohan
f80023f8f3 Merge pull request #2838 from akhilmhdh/feat/identity-management-condition
feat: added identity id condition in identity permission of a project
2024-12-06 01:24:24 +05:30
=
98289f56ae feat: changed both IN operator contains name to In itself 2024-12-06 01:16:28 +05:30
Scott Wilson
c40f195c1d Merge pull request #2835 from Infisical/integrations-table
Improvement: Integrations Table and UI Improvements
2024-12-05 09:28:52 -08:00
Scott Wilson
fbfe694fc0 improvement: add overflow handling to integration filter dropdown 2024-12-05 09:13:39 -08:00
Maidul Islam
2098bd3be2 Merge pull request #2842 from Infisical/misc/add-pg-queue-init-flag
misc: added pg queue init flag
2024-12-05 11:12:29 -05:00
Akhil Mohan
ef82c664a6 Merge pull request #2797 from akhilmhdh/feat/oauth2-csrf
feat: resolved csrf for oauth2 using state parameter
2024-12-05 14:37:47 +05:30
=
fcbedfaf1b feat: updated changes by review feedback 2024-12-05 14:20:05 +05:30
=
882f6b22f5 feat: updated frontend for review changes 2024-12-05 14:08:08 +05:30
=
bcd778457d feat: added identity id in privilege section v2 as well 2024-12-05 14:04:59 +05:30
Sheen Capadngan
0a1242db75 misc: added pg queue init flag 2024-12-05 15:52:17 +08:00
Scott Wilson
a078cb6059 improvement: add search to cloud integrations 2024-12-04 21:00:33 -08:00
Maidul Islam
095b26c8c9 Merge pull request #2841 from Infisical/integration-error-improvement
Improvement: Integration Error - Handle Response Data Empty String
2024-12-04 23:39:33 -05:00
Scott Wilson
fcdfcd0219 improvement: check if response data is empty string 2024-12-04 20:17:07 -08:00
Scott Wilson
132de1479d improvement: only sort by status if 1 or more integrations is failing to sync; otherwise sort by integration 2024-12-04 17:25:48 -08:00
Scott Wilson
d4a76b3621 improvement: add support for ordering by destination 2024-12-04 17:11:47 -08:00
Scott Wilson
331dcd4d79 improvement: support search by integration destination 2024-12-04 17:06:43 -08:00
Scott Wilson
025f64f068 improvement: hide secret suffix if not set 2024-12-04 17:02:01 -08:00
Scott Wilson
05d7f94518 improvement: add margin to integrations table view 2024-12-04 17:00:09 -08:00
Scott Wilson
b58e32c754 fix: actually implement env filter for integrations 2024-12-04 16:55:05 -08:00
Daniel Hougaard
4ace30aecd Merge pull request #2839 from Infisical/omar/eng-1966-click-to-copy-req-id-on-toast
Improvement(notifications): Add copyable request IDs to server side errors
2024-12-05 04:04:33 +04:00
McPizza
8b2a866994 fix nits 2024-12-04 23:32:55 +00:00
Daniel Hougaard
b4386af2e0 Merge pull request #2840 from Infisical/daniel/updated-java-sdk-docs
docs(java-sdk): updated for v3.0.0
2024-12-05 01:20:43 +04:00
Maidul Islam
ec5e6eb7b4 Merge pull request #2837 from Infisical/misc/use-pg-queue-for-audit-logs-with-flag
misc: pg-queue for audit logs
2024-12-04 14:25:33 -05:00
McPizza0
48cb5f6e9b feat(notifications): add copyable request IDs 2024-12-04 16:24:48 +00:00
=
3c63312944 feat: added identity id condition in identity permission of a project 2024-12-04 21:26:23 +05:30
Sheen Capadngan
0842901d4f misc: always initialize pg-boss 2024-12-04 23:21:37 +08:00
Sheen Capadngan
32d6826ade fix: resolve e2e 2024-12-04 22:52:30 +08:00
Sheen Capadngan
a750f48922 misc: finalized structure 2024-12-04 22:49:28 +08:00
Maidul Islam
67662686f3 Merge pull request #2836 from akhilmhdh/feat/dynamic-secret-safe-chars
feat: updated random pass generator of dynamic secret to use safe chars
2024-12-04 09:32:59 -05:00
Sheen Capadngan
11c96245a7 misc: added error listener 2024-12-04 22:27:07 +08:00
Sheen Capadngan
a63191e11d misc: use pg queue for audit logs when enabled 2024-12-04 22:22:34 +08:00
=
7a13c155f5 feat: updated random pass generator of dynamic secret to use safe characters 2024-12-04 15:15:53 +05:30
Scott Wilson
fb6a085bf9 chore: remove comment and unused component 2024-12-03 15:01:35 -08:00
Scott Wilson
6c533f89d3 feature: high-level integrations refactor 2024-12-03 14:53:33 -08:00
McPizza
5ceb30f43f feat(KMS): New external KMS support for Google GCP KMS (#2825)
* feat(KMS): New external KMS support for Google GCP KMS
2024-12-03 18:14:42 +01:00
McPizza
7728a4793b fix: Schema validation errors correctly returned as 422 (#2828)
* fix: Schema validation errors correctly returned as 422
2024-12-03 18:12:29 +01:00
Maidul Islam
d3523ed1d6 Merge pull request #2833 from akhilmhdh/fix/create-project
fix: resolved reduntant min membership check over project creation
2024-12-03 11:11:08 -05:00
=
35a9b2a38d fix: resolved reduntant min membership check over project create for identity 2024-12-03 21:13:16 +05:30
Scott Wilson
16a9f8c194 Merge pull request #2829 from Infisical/minor-ui-fixes
Improvements: Truncate Filterable Select List Options and Fix Null Display of User Last Name
2024-12-02 16:18:29 -08:00
Scott Wilson
9557639bfe truncate filter select list options and fix display of null last name for users 2024-12-02 16:06:15 -08:00
Scott Wilson
1049f95952 Merge pull request #2816 from Infisical/create-secret-form-env-multi-select
Improvement: Multi-select for Environment Selection on Create Secret
2024-12-02 11:02:51 -08:00
Scott Wilson
e618d5ca5f Merge pull request #2821 from Infisical/secret-approval-filterable-selects
Improvement: Secret Approval Form Filterable Selects
2024-12-02 10:37:16 -08:00
Scott Wilson
d659250ce8 improvement: change selected project icon from eye to chevron 2024-12-02 10:20:57 -08:00
Scott Wilson
87363eabfe chore: remove comments 2024-12-02 09:46:53 -08:00
Scott Wilson
d1b9c316d8 improvement: use multi-select for environment selection on create secret 2024-12-02 09:45:43 -08:00
Scott Wilson
b9867c0d06 Merge branch 'main' into secret-approval-filterable-selects 2024-12-02 09:44:04 -08:00
Scott Wilson
afa2f383c5 improvement: address feedback 2024-12-02 09:35:03 -08:00
Scott Wilson
39f7354fec Merge pull request #2814 from Infisical/add-group-to-project-filterable-selects
Improvement: User/Group/Identity Modals Dropdown to Filterable Select Refactor + User Groups and Secret Tags Table Pagination
2024-12-02 08:20:01 -08:00
Scott Wilson
c46c0cb1e8 Merge pull request #2824 from Infisical/environment-select-refactor
Improvement: Copy Secrets Modal & Environment Selects Improvements
2024-12-02 08:05:12 -08:00
Scott Wilson
6905ffba4e improvement: handle overflow and improve ui 2024-11-29 13:43:06 -08:00
Scott Wilson
64fd423c61 improvement: update import secret env select 2024-11-29 13:34:36 -08:00
Scott Wilson
da1a7466d1 improvement: change label 2024-11-29 13:28:53 -08:00
Scott Wilson
d3f3f34129 improvement: update copy secrets from env select and secret selection 2024-11-29 13:27:24 -08:00
Scott Wilson
c8fba7ce4c improvement: align pagination left on grid view project overview 2024-11-29 11:17:54 -08:00
Scott Wilson
ae51fbb8f2 chore: revert license 2024-11-29 10:53:22 -08:00
Scott Wilson
62910e93ca fix: remove labels for options(outdated) 2024-11-29 10:52:49 -08:00
Scott Wilson
9e3c632a1f chore: revert license 2024-11-29 10:44:26 -08:00
Scott Wilson
bb094f60c1 improvement: update secret approval policy form to use filterable selects w/ UI revisions 2024-11-29 10:44:05 -08:00
Scott Wilson
a18f3c2919 progress 2024-11-29 08:19:02 -08:00
Scott Wilson
a852b15a1e improvement: move environment filters beneath static filters 2024-11-29 08:11:04 -08:00
Scott Wilson
dab8f0b261 improvement: secret tags table pagination 2024-11-28 14:29:41 -08:00
Scott Wilson
4293665130 improvement: user groups table pagination 2024-11-28 14:10:45 -08:00
Scott Wilson
8afa65c272 improvements: minor refactoring 2024-11-28 13:47:09 -08:00
Scott Wilson
4c739fd57f chore: revert license 2024-11-28 13:36:42 -08:00
Scott Wilson
bcc2840020 improvement: filterable role selection on create/edit group 2024-11-28 13:13:23 -08:00
Scott Wilson
8b3af92d23 improvement: edit user role filterable select 2024-11-28 12:58:03 -08:00
Scott Wilson
9ca58894f0 improvement: filter select for create identity role 2024-11-28 12:58:03 -08:00
Scott Wilson
d131314de0 improvement: filter select for invite users to org 2024-11-28 12:58:03 -08:00
Scott Wilson
9c03144f19 improvement: use filterable multi-select for add users to project role select 2024-11-28 12:58:03 -08:00
Scott Wilson
5495ffd78e improvement: update add group to project modal to use filterable selects 2024-11-28 12:58:03 -08:00
=
e4b149a849 feat: resolved csrf for oauth2 using state parameter 2024-11-26 21:19:32 +05:30
=
269f851cbf docs: added docs for template support in k8s operator 2024-11-22 00:08:30 +05:30
=
7a61995dd4 feat: added template support in operator 2024-11-22 00:04:41 +05:30
138 changed files with 4543 additions and 2237 deletions

View File

@@ -10,12 +10,15 @@ export const mockQueue = (): TQueueServiceFactory => {
queue: async (name, jobData) => {
job[name] = jobData;
},
queuePg: async () => {},
initialize: async () => {},
shutdown: async () => undefined,
stopRepeatableJob: async () => true,
start: (name, jobFn) => {
queues[name] = jobFn;
workers[name] = jobFn;
},
startPg: async () => {},
listen: (name, event) => {
events[name] = event;
},

View File

@@ -53,13 +53,13 @@ export default {
extension: "ts"
});
const smtp = mockSmtpServer();
const queue = queueServiceFactory(cfg.REDIS_URL);
const queue = queueServiceFactory(cfg.REDIS_URL, cfg.DB_CONNECTION_URI);
const keyStore = keyStoreFactory(cfg.REDIS_URL);
const hsmModule = initializeHsmModule();
hsmModule.initialize();
const server = await main({ db, smtp, logger, queue, keyStore, hsmModule: hsmModule.getModule() });
const server = await main({ db, smtp, logger, queue, keyStore, hsmModule: hsmModule.getModule(), redis });
// @ts-expect-error type
globalThis.testServer = server;

View File

@@ -28,6 +28,7 @@
"@fastify/session": "^10.7.0",
"@fastify/swagger": "^8.14.0",
"@fastify/swagger-ui": "^2.1.0",
"@google-cloud/kms": "^4.5.0",
"@node-saml/passport-saml": "^4.0.4",
"@octokit/auth-app": "^7.1.1",
"@octokit/plugin-retry": "^5.0.5",
@@ -92,6 +93,7 @@
"passport-google-oauth20": "^2.0.0",
"passport-ldapauth": "^3.0.1",
"pg": "^8.11.3",
"pg-boss": "^10.1.5",
"pg-query-stream": "^4.5.3",
"picomatch": "^3.0.1",
"pino": "^8.16.2",
@@ -5598,6 +5600,18 @@
"yaml": "^2.2.2"
}
},
"node_modules/@google-cloud/kms": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@google-cloud/kms/-/kms-4.5.0.tgz",
"integrity": "sha512-i2vC0DI7bdfEhQszqASTw0KVvbB7HsO2CwTBod423NawAu7FWi+gVVa7NLfXVNGJaZZayFfci2Hu+om/HmyEjQ==",
"license": "Apache-2.0",
"dependencies": {
"google-gax": "^4.0.3"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@google-cloud/paginator": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz",
@@ -12259,14 +12273,6 @@
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"node_modules/buffer-writer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz",
"integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==",
"engines": {
"node": ">=4"
}
},
"node_modules/bullmq": {
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.4.2.tgz",
@@ -15086,6 +15092,44 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/google-gax": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.4.1.tgz",
"integrity": "sha512-Phyp9fMfA00J3sZbJxbbB4jC55b7DBjE3F6poyL3wKMEBVKA79q6BGuHcTiM28yOzVql0NDbRL8MLLh8Iwk9Dg==",
"license": "Apache-2.0",
"dependencies": {
"@grpc/grpc-js": "^1.10.9",
"@grpc/proto-loader": "^0.7.13",
"@types/long": "^4.0.0",
"abort-controller": "^3.0.0",
"duplexify": "^4.0.0",
"google-auth-library": "^9.3.0",
"node-fetch": "^2.7.0",
"object-hash": "^3.0.0",
"proto3-json-serializer": "^2.0.2",
"protobufjs": "^7.3.2",
"retry-request": "^7.0.0",
"uuid": "^9.0.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/google-gax/node_modules/@types/long": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
"license": "MIT"
},
"node_modules/google-gax/node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/googleapis": {
"version": "137.1.0",
"resolved": "https://registry.npmjs.org/googleapis/-/googleapis-137.1.0.tgz",
@@ -18185,11 +18229,6 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/packet-reader": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-1.0.0.tgz",
"integrity": "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -18408,15 +18447,13 @@
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
"node_modules/pg": {
"version": "8.11.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.11.3.tgz",
"integrity": "sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==",
"version": "8.13.1",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.13.1.tgz",
"integrity": "sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==",
"dependencies": {
"buffer-writer": "2.0.0",
"packet-reader": "1.0.0",
"pg-connection-string": "^2.6.2",
"pg-pool": "^3.6.1",
"pg-protocol": "^1.6.0",
"pg-connection-string": "^2.7.0",
"pg-pool": "^3.7.0",
"pg-protocol": "^1.7.0",
"pg-types": "^2.1.0",
"pgpass": "1.x"
},
@@ -18435,6 +18472,19 @@
}
}
},
"node_modules/pg-boss": {
"version": "10.1.5",
"resolved": "https://registry.npmjs.org/pg-boss/-/pg-boss-10.1.5.tgz",
"integrity": "sha512-H87NL6c7N6nTCSCePh16EaSQVSFevNXWdJuzY6PZz4rw+W/nuMKPfI/vYyXS0AdT1g1Q3S3EgeOYOHcB7ZVToQ==",
"dependencies": {
"cron-parser": "^4.9.0",
"pg": "^8.13.0",
"serialize-error": "^8.1.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/pg-cloudflare": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz",
@@ -18471,17 +18521,17 @@
}
},
"node_modules/pg-pool": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.6.1.tgz",
"integrity": "sha512-jizsIzhkIitxCGfPRzJn1ZdcosIt3pz9Sh3V01fm1vZnbnCMgmGl5wvGGdNN2EL9Rmb0EcFoCkixH4Pu+sP9Og==",
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.7.0.tgz",
"integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.0.tgz",
"integrity": "sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q=="
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.7.0.tgz",
"integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ=="
},
"node_modules/pg-query-stream": {
"version": "4.5.3",
@@ -18510,9 +18560,9 @@
}
},
"node_modules/pg/node_modules/pg-connection-string": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz",
"integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA=="
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.7.0.tgz",
"integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA=="
},
"node_modules/pgpass": {
"version": "1.0.5",
@@ -19223,6 +19273,18 @@
"node": ">=6"
}
},
"node_modules/proto3-json-serializer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz",
"integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==",
"license": "Apache-2.0",
"dependencies": {
"protobufjs": "^7.2.5"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/protobufjs": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz",
@@ -20111,6 +20173,20 @@
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
},
"node_modules/serialize-error": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",
"integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
"dependencies": {
"type-fest": "^0.20.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/serve-static": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
@@ -22130,7 +22206,6 @@
"version": "0.20.2",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
"dev": true,
"engines": {
"node": ">=10"
},

View File

@@ -136,6 +136,7 @@
"@fastify/session": "^10.7.0",
"@fastify/swagger": "^8.14.0",
"@fastify/swagger-ui": "^2.1.0",
"@google-cloud/kms": "^4.5.0",
"@node-saml/passport-saml": "^4.0.4",
"@octokit/auth-app": "^7.1.1",
"@octokit/plugin-retry": "^5.0.5",
@@ -200,6 +201,7 @@
"passport-google-oauth20": "^2.0.0",
"passport-ldapauth": "^3.0.1",
"pg": "^8.11.3",
"pg-boss": "^10.1.5",
"pg-query-stream": "^4.5.3",
"picomatch": "^3.0.1",
"pino": "^8.16.2",

View File

@@ -2,6 +2,6 @@ import "@fastify/request-context";
declare module "@fastify/request-context" {
interface RequestContextData {
requestId: string;
reqId: string;
}
}

View File

@@ -1,5 +1,7 @@
import "fastify";
import { Redis } from "ioredis";
import { TUsers } from "@app/db/schemas";
import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service";
import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service";
@@ -87,6 +89,10 @@ import { TWebhookServiceFactory } from "@app/services/webhook/webhook-service";
import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service";
declare module "fastify" {
interface Session {
callbackPort: string;
}
interface FastifyRequest {
realIp: string;
// used for mfa session authentication
@@ -115,6 +121,7 @@ declare module "fastify" {
}
interface FastifyInstance {
redis: Redis;
services: {
login: TAuthLoginFactory;
password: TAuthPasswordFactory;

View File

@@ -4,9 +4,15 @@ import { ExternalKmsSchema, KmsKeysSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import {
ExternalKmsAwsSchema,
ExternalKmsGcpCredentialSchema,
ExternalKmsGcpSchema,
ExternalKmsInputSchema,
ExternalKmsInputUpdateSchema
ExternalKmsInputUpdateSchema,
KmsGcpKeyFetchAuthType,
KmsProviders,
TExternalKmsGcpCredentialSchema
} from "@app/ee/services/external-kms/providers/model";
import { NotFoundError } from "@app/lib/errors";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
@@ -44,7 +50,8 @@ const sanitizedExternalSchemaForGetById = KmsKeysSchema.extend({
statusDetails: true,
provider: true
}).extend({
providerInput: ExternalKmsAwsSchema
// for GCP, we don't return the credential object as it is sensitive data that should not be exposed
providerInput: z.union([ExternalKmsAwsSchema, ExternalKmsGcpSchema.pick({ gcpRegion: true, keyName: true })])
})
});
@@ -286,4 +293,67 @@ export const registerExternalKmsRouter = async (server: FastifyZodProvider) => {
return { externalKms };
}
});
server.route({
method: "POST",
url: "/gcp/keys",
config: {
rateLimit: writeLimit
},
schema: {
body: z.discriminatedUnion("authMethod", [
z.object({
authMethod: z.literal(KmsGcpKeyFetchAuthType.Credential),
region: z.string().trim().min(1),
credential: ExternalKmsGcpCredentialSchema
}),
z.object({
authMethod: z.literal(KmsGcpKeyFetchAuthType.Kms),
region: z.string().trim().min(1),
kmsId: z.string().trim().min(1)
})
]),
response: {
200: z.object({
keys: z.string().array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { region, authMethod } = req.body;
let credentialJson: TExternalKmsGcpCredentialSchema | undefined;
if (authMethod === KmsGcpKeyFetchAuthType.Credential) {
credentialJson = req.body.credential;
} else if (authMethod === KmsGcpKeyFetchAuthType.Kms) {
const externalKms = await server.services.externalKms.findById({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
id: req.body.kmsId
});
if (!externalKms || externalKms.external.provider !== KmsProviders.Gcp) {
throw new NotFoundError({ message: "KMS not found or not of type GCP" });
}
credentialJson = externalKms.external.providerInput.credential as TExternalKmsGcpCredentialSchema;
}
if (!credentialJson) {
throw new NotFoundError({
message: "Something went wrong while fetching the GCP credential, please check inputs and try again"
});
}
const results = await server.services.externalKms.fetchGcpKeys({
credential: credentialJson,
gcpRegion: region
});
return results;
}
});
};

View File

@@ -9,7 +9,6 @@
import { Authenticator, Strategy } from "@fastify/passport";
import fastifySession from "@fastify/session";
import RedisStore from "connect-redis";
import { Redis } from "ioredis";
import { z } from "zod";
import { OidcConfigsSchema } from "@app/db/schemas/oidc-configs";
@@ -21,7 +20,6 @@ import { AuthMode } from "@app/services/auth/auth-type";
export const registerOidcRouter = async (server: FastifyZodProvider) => {
const appCfg = getConfig();
const redis = new Redis(appCfg.REDIS_URL);
const passport = new Authenticator({ key: "oidc", userProperty: "passportUser" });
/*
@@ -30,7 +28,7 @@ export const registerOidcRouter = async (server: FastifyZodProvider) => {
- Fastify session <> Redis structure is based on the ff: https://github.com/fastify/session/blob/master/examples/redis.js
*/
const redisStore = new RedisStore({
client: redis,
client: server.redis,
prefix: "oidc-session:",
ttl: 600 // 10 minutes
});

View File

@@ -1,6 +1,7 @@
import { RawAxiosRequestHeaders } from "axios";
import { SecretKeyEncoding } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
@@ -20,27 +21,130 @@ type TAuditLogQueueServiceFactoryDep = {
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
};
export type TAuditLogQueueServiceFactory = ReturnType<typeof auditLogQueueServiceFactory>;
export type TAuditLogQueueServiceFactory = Awaited<ReturnType<typeof auditLogQueueServiceFactory>>;
// keep this timeout 5s it must be fast because else the queue will take time to finish
// audit log is a crowded queue thus needs to be fast
export const AUDIT_LOG_STREAM_TIMEOUT = 5 * 1000;
export const auditLogQueueServiceFactory = ({
export const auditLogQueueServiceFactory = async ({
auditLogDAL,
queueService,
projectDAL,
licenseService,
auditLogStreamDAL
}: TAuditLogQueueServiceFactoryDep) => {
const appCfg = getConfig();
const pushToLog = async (data: TCreateAuditLogDTO) => {
await queueService.queue(QueueName.AuditLog, QueueJobs.AuditLog, data, {
removeOnFail: {
count: 3
},
removeOnComplete: true
});
if (appCfg.USE_PG_QUEUE && appCfg.SHOULD_INIT_PG_QUEUE) {
await queueService.queuePg<QueueName.AuditLog>(QueueJobs.AuditLog, data, {
retryLimit: 10,
retryBackoff: true
});
} else {
await queueService.queue<QueueName.AuditLog>(QueueName.AuditLog, QueueJobs.AuditLog, data, {
removeOnFail: {
count: 3
},
removeOnComplete: true
});
}
};
if (appCfg.SHOULD_INIT_PG_QUEUE) {
await queueService.startPg<QueueName.AuditLog>(
QueueJobs.AuditLog,
async ([job]) => {
const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data;
let { orgId } = job.data;
const MS_IN_DAY = 24 * 60 * 60 * 1000;
let project;
if (!orgId) {
// it will never be undefined for both org and project id
// TODO(akhilmhdh): use caching here in dal to avoid db calls
project = await projectDAL.findById(projectId as string);
orgId = project.orgId;
}
const plan = await licenseService.getPlan(orgId);
if (plan.auditLogsRetentionDays === 0) {
// skip inserting if audit log retention is 0 meaning its not supported
return;
}
// For project actions, set TTL to project-level audit log retention config
// This condition ensures that the plan's audit log retention days cannot be bypassed
const ttlInDays =
project?.auditLogsRetentionDays && project.auditLogsRetentionDays < plan.auditLogsRetentionDays
? project.auditLogsRetentionDays
: plan.auditLogsRetentionDays;
const ttl = ttlInDays * MS_IN_DAY;
const auditLog = await auditLogDAL.create({
actor: actor.type,
actorMetadata: actor.metadata,
userAgent,
projectId,
projectName: project?.name,
ipAddress,
orgId,
eventType: event.type,
expiresAt: new Date(Date.now() + ttl),
eventMetadata: event.metadata,
userAgentType
});
const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : [];
await Promise.allSettled(
logStreams.map(
async ({
url,
encryptedHeadersTag,
encryptedHeadersIV,
encryptedHeadersKeyEncoding,
encryptedHeadersCiphertext
}) => {
const streamHeaders =
encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag
? (JSON.parse(
infisicalSymmetricDecrypt({
keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding,
iv: encryptedHeadersIV,
tag: encryptedHeadersTag,
ciphertext: encryptedHeadersCiphertext
})
) as LogStreamHeaders[])
: [];
const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" };
if (streamHeaders.length)
streamHeaders.forEach(({ key, value }) => {
headers[key] = value;
});
return request.post(url, auditLog, {
headers,
// request timeout
timeout: AUDIT_LOG_STREAM_TIMEOUT,
// connection timeout
signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT)
});
}
)
);
},
{
batchSize: 1,
workerCount: 30,
pollingIntervalSeconds: 0.5
}
);
}
queueService.start(QueueName.AuditLog, async (job) => {
const { actor, event, ipAddress, projectId, userAgent, userAgentType } = job.data;
let { orgId } = job.data;

View File

@@ -127,7 +127,7 @@ const ElastiCacheUserManager = (credentials: TBasicAWSCredentials, region: strin
};
const generatePassword = () => {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
return customAlphabet(charset, 64)();
};
@@ -211,7 +211,7 @@ export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => {
return { entityId };
};
const renew = async (inputs: unknown, entityId: string) => {
const renew = async (_inputs: unknown, entityId: string) => {
// No renewal necessary
return { entityId };
};

View File

@@ -9,7 +9,7 @@ const MSFT_GRAPH_API_URL = "https://graph.microsoft.com/v1.0/";
const MSFT_LOGIN_URL = "https://login.microsoftonline.com";
const generatePassword = () => {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
return customAlphabet(charset, 64)();
};
@@ -122,7 +122,7 @@ export const AzureEntraIDProvider = (): TDynamicProviderFns & {
return users;
};
const renew = async (inputs: unknown, entityId: string) => {
const renew = async (_inputs: unknown, entityId: string) => {
// No renewal necessary
return { entityId };
};

View File

@@ -9,7 +9,7 @@ import { alphaNumericNanoId } from "@app/lib/nanoid";
import { DynamicSecretCassandraSchema, TDynamicProviderFns } from "./models";
const generatePassword = (size = 48) => {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
return customAlphabet(charset, 48)(size);
};

View File

@@ -8,7 +8,7 @@ import { verifyHostInputValidity } from "../dynamic-secret-fns";
import { DynamicSecretElasticSearchSchema, ElasticSearchAuthTypes, TDynamicProviderFns } from "./models";
const generatePassword = () => {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
return customAlphabet(charset, 64)();
};
@@ -95,7 +95,7 @@ export const ElasticSearchProvider = (): TDynamicProviderFns => {
return { entityId };
};
const renew = async (inputs: unknown, entityId: string) => {
const renew = async (_inputs: unknown, entityId: string) => {
// No renewal necessary
return { entityId };
};

View File

@@ -8,7 +8,7 @@ import { alphaNumericNanoId } from "@app/lib/nanoid";
import { DynamicSecretMongoAtlasSchema, TDynamicProviderFns } from "./models";
const generatePassword = (size = 48) => {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
return customAlphabet(charset, 48)(size);
};

View File

@@ -8,7 +8,7 @@ import { verifyHostInputValidity } from "../dynamic-secret-fns";
import { DynamicSecretMongoDBSchema, TDynamicProviderFns } from "./models";
const generatePassword = (size = 48) => {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
return customAlphabet(charset, 48)(size);
};

View File

@@ -11,7 +11,7 @@ import { verifyHostInputValidity } from "../dynamic-secret-fns";
import { DynamicSecretRabbitMqSchema, TDynamicProviderFns } from "./models";
const generatePassword = () => {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
return customAlphabet(charset, 64)();
};
@@ -141,7 +141,7 @@ export const RabbitMqProvider = (): TDynamicProviderFns => {
return { entityId };
};
const renew = async (inputs: unknown, entityId: string) => {
const renew = async (_inputs: unknown, entityId: string) => {
// No renewal necessary
return { entityId };
};

View File

@@ -10,7 +10,7 @@ import { verifyHostInputValidity } from "../dynamic-secret-fns";
import { DynamicSecretRedisDBSchema, TDynamicProviderFns } from "./models";
const generatePassword = () => {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
return customAlphabet(charset, 64)();
};

View File

@@ -12,7 +12,7 @@ import { DynamicSecretSnowflakeSchema, TDynamicProviderFns } from "./models";
const noop = () => {};
const generatePassword = (size = 48) => {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
return customAlphabet(charset, 48)(size);
};

View File

@@ -14,7 +14,7 @@ const generatePassword = (provider: SqlProviders) => {
// oracle has limit of 48 password length
const size = provider === SqlProviders.Oracle ? 30 : 48;
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*";
return customAlphabet(charset, 48)(size);
};

View File

@@ -20,7 +20,8 @@ import {
TUpdateExternalKmsDTO
} from "./external-kms-types";
import { AwsKmsProviderFactory } from "./providers/aws-kms";
import { ExternalKmsAwsSchema, KmsProviders } from "./providers/model";
import { GcpKmsProviderFactory } from "./providers/gcp-kms";
import { ExternalKmsAwsSchema, ExternalKmsGcpSchema, KmsProviders, TExternalKmsGcpSchema } from "./providers/model";
type TExternalKmsServiceFactoryDep = {
externalKmsDAL: TExternalKmsDALFactory;
@@ -78,6 +79,13 @@ export const externalKmsServiceFactory = ({
await externalKms.validateConnection();
}
break;
case KmsProviders.Gcp:
{
const externalKms = await GcpKmsProviderFactory({ inputs: provider.inputs });
await externalKms.validateConnection();
sanitizedProviderInput = JSON.stringify(provider.inputs);
}
break;
default:
throw new BadRequestError({ message: "external kms provided is invalid" });
}
@@ -88,7 +96,7 @@ export const externalKmsServiceFactory = ({
});
const { cipherTextBlob: encryptedProviderInputs } = orgDataKeyEncryptor({
plainText: Buffer.from(sanitizedProviderInput, "utf8")
plainText: Buffer.from(sanitizedProviderInput)
});
const externalKms = await externalKmsDAL.transaction(async (tx) => {
@@ -162,7 +170,7 @@ export const externalKmsServiceFactory = ({
case KmsProviders.Aws:
{
const decryptedProviderInput = await ExternalKmsAwsSchema.parseAsync(
JSON.parse(decryptedProviderInputBlob.toString("utf8"))
JSON.parse(decryptedProviderInputBlob.toString())
);
const updatedProviderInput = { ...decryptedProviderInput, ...provider.inputs };
const externalKms = await AwsKmsProviderFactory({ inputs: updatedProviderInput });
@@ -170,6 +178,17 @@ export const externalKmsServiceFactory = ({
sanitizedProviderInput = JSON.stringify(updatedProviderInput);
}
break;
case KmsProviders.Gcp:
{
const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync(
JSON.parse(decryptedProviderInputBlob.toString())
);
const updatedProviderInput = { ...decryptedProviderInput, ...provider.inputs };
const externalKms = await GcpKmsProviderFactory({ inputs: updatedProviderInput });
await externalKms.validateConnection();
sanitizedProviderInput = JSON.stringify(updatedProviderInput);
}
break;
default:
throw new BadRequestError({ message: "external kms provided is invalid" });
}
@@ -178,7 +197,7 @@ export const externalKmsServiceFactory = ({
let encryptedProviderInputs: Buffer | undefined;
if (sanitizedProviderInput) {
const { cipherTextBlob } = orgDataKeyEncryptor({
plainText: Buffer.from(sanitizedProviderInput, "utf8")
plainText: Buffer.from(sanitizedProviderInput)
});
encryptedProviderInputs = cipherTextBlob;
}
@@ -271,10 +290,17 @@ export const externalKmsServiceFactory = ({
switch (externalKmsDoc.provider) {
case KmsProviders.Aws: {
const decryptedProviderInput = await ExternalKmsAwsSchema.parseAsync(
JSON.parse(decryptedProviderInputBlob.toString("utf8"))
JSON.parse(decryptedProviderInputBlob.toString())
);
return { ...kmsDoc, external: { ...externalKmsDoc, providerInput: decryptedProviderInput } };
}
case KmsProviders.Gcp: {
const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync(
JSON.parse(decryptedProviderInputBlob.toString())
);
return { ...kmsDoc, external: { ...externalKmsDoc, providerInput: decryptedProviderInput } };
}
default:
throw new BadRequestError({ message: "external kms provided is invalid" });
}
@@ -312,21 +338,34 @@ export const externalKmsServiceFactory = ({
switch (externalKmsDoc.provider) {
case KmsProviders.Aws: {
const decryptedProviderInput = await ExternalKmsAwsSchema.parseAsync(
JSON.parse(decryptedProviderInputBlob.toString("utf8"))
JSON.parse(decryptedProviderInputBlob.toString())
);
return { ...kmsDoc, external: { ...externalKmsDoc, providerInput: decryptedProviderInput } };
}
case KmsProviders.Gcp: {
const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync(
JSON.parse(decryptedProviderInputBlob.toString())
);
return { ...kmsDoc, external: { ...externalKmsDoc, providerInput: decryptedProviderInput } };
}
default:
throw new BadRequestError({ message: "external kms provided is invalid" });
}
};
const fetchGcpKeys = async ({ credential, gcpRegion }: Pick<TExternalKmsGcpSchema, "credential" | "gcpRegion">) => {
const externalKms = await GcpKmsProviderFactory({ inputs: { credential, gcpRegion, keyName: "" } });
return externalKms.getKeysList();
};
return {
create,
updateById,
deleteById,
list,
findById,
findByName
findByName,
fetchGcpKeys
};
};

View File

@@ -0,0 +1,113 @@
import { KeyManagementServiceClient } from "@google-cloud/kms";
import { BadRequestError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { ExternalKmsGcpSchema, TExternalKmsGcpClientSchema, TExternalKmsProviderFns } from "./model";
const getGcpKmsClient = async ({ credential, gcpRegion }: TExternalKmsGcpClientSchema) => {
const gcpKmsClient = new KeyManagementServiceClient({
credentials: credential
});
const projectId = credential.project_id;
const locationName = gcpKmsClient.locationPath(projectId, gcpRegion);
return {
gcpKmsClient,
locationName
};
};
type GcpKmsProviderArgs = {
inputs: unknown;
};
type TGcpKmsProviderFactoryReturn = TExternalKmsProviderFns & {
getKeysList: () => Promise<{ keys: string[] }>;
};
export const GcpKmsProviderFactory = async ({ inputs }: GcpKmsProviderArgs): Promise<TGcpKmsProviderFactoryReturn> => {
const { credential, gcpRegion, keyName } = await ExternalKmsGcpSchema.parseAsync(inputs);
const { gcpKmsClient, locationName } = await getGcpKmsClient({
credential,
gcpRegion
});
const validateConnection = async () => {
try {
await gcpKmsClient.listKeyRings({
parent: locationName
});
return true;
} catch (error) {
throw new BadRequestError({
message: "Cannot connect to GCP KMS"
});
}
};
// Used when adding the KMS to fetch the list of keys in specified region
const getKeysList = async () => {
try {
const [keyRings] = await gcpKmsClient.listKeyRings({
parent: locationName
});
const validKeyRings = keyRings
.filter(
(keyRing): keyRing is { name: string } =>
keyRing !== null && typeof keyRing === "object" && "name" in keyRing && typeof keyRing.name === "string"
)
.map((keyRing) => keyRing.name);
const keyList: string[] = [];
const keyListPromises = validKeyRings.map((keyRingName) =>
gcpKmsClient
.listCryptoKeys({
parent: keyRingName
})
.then(([cryptoKeys]) =>
cryptoKeys
.filter(
(key): key is { name: string } =>
key !== null && typeof key === "object" && "name" in key && typeof key.name === "string"
)
.map((key) => key.name)
)
);
const cryptoKeyLists = await Promise.all(keyListPromises);
keyList.push(...cryptoKeyLists.flat());
return { keys: keyList };
} catch (error) {
logger.error(error, "Could not validate GCP KMS connection and credentials");
throw new BadRequestError({
message: "Could not validate GCP KMS connection and credentials",
error
});
}
};
const encrypt = async (data: Buffer) => {
const encryptedText = await gcpKmsClient.encrypt({
name: keyName,
plaintext: data
});
if (!encryptedText[0].ciphertext) throw new Error("encryption failed");
return { encryptedBlob: Buffer.from(encryptedText[0].ciphertext) };
};
const decrypt = async (encryptedBlob: Buffer) => {
const decryptedText = await gcpKmsClient.decrypt({
name: keyName,
ciphertext: encryptedBlob
});
if (!decryptedText[0].plaintext) throw new Error("decryption failed");
return { data: Buffer.from(decryptedText[0].plaintext) };
};
return {
validateConnection,
getKeysList,
encrypt,
decrypt
};
};

View File

@@ -1,13 +1,23 @@
import { z } from "zod";
export enum KmsProviders {
Aws = "aws"
Aws = "aws",
Gcp = "gcp"
}
export enum KmsAwsCredentialType {
AssumeRole = "assume-role",
AccessKey = "access-key"
}
// Google uses snake_case for their enum values and we need to match that
export enum KmsGcpCredentialType {
ServiceAccount = "service_account"
}
export enum KmsGcpKeyFetchAuthType {
Credential = "credential",
Kms = "kmsId"
}
export const ExternalKmsAwsSchema = z.object({
credential: z
@@ -42,14 +52,44 @@ export const ExternalKmsAwsSchema = z.object({
});
export type TExternalKmsAwsSchema = z.infer<typeof ExternalKmsAwsSchema>;
export const ExternalKmsGcpCredentialSchema = z.object({
type: z.literal(KmsGcpCredentialType.ServiceAccount),
project_id: z.string().min(1),
private_key_id: z.string().min(1),
private_key: z.string().min(1),
client_email: z.string().min(1),
client_id: z.string().min(1),
auth_uri: z.string().min(1),
token_uri: z.string().min(1),
auth_provider_x509_cert_url: z.string().min(1),
client_x509_cert_url: z.string().min(1),
universe_domain: z.string().min(1)
});
export type TExternalKmsGcpCredentialSchema = z.infer<typeof ExternalKmsGcpCredentialSchema>;
export const ExternalKmsGcpSchema = z.object({
credential: ExternalKmsGcpCredentialSchema.describe("GCP Service Account JSON credential to connect"),
gcpRegion: z.string().trim().describe("GCP region where the KMS key is located"),
keyName: z.string().trim().describe("GCP key name")
});
export type TExternalKmsGcpSchema = z.infer<typeof ExternalKmsGcpSchema>;
const ExternalKmsGcpClientSchema = ExternalKmsGcpSchema.pick({ gcpRegion: true }).extend({
credential: ExternalKmsGcpCredentialSchema
});
export type TExternalKmsGcpClientSchema = z.infer<typeof ExternalKmsGcpClientSchema>;
// The root schema of the JSON
export const ExternalKmsInputSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal(KmsProviders.Aws), inputs: ExternalKmsAwsSchema })
z.object({ type: z.literal(KmsProviders.Aws), inputs: ExternalKmsAwsSchema }),
z.object({ type: z.literal(KmsProviders.Gcp), inputs: ExternalKmsGcpSchema })
]);
export type TExternalKmsInputSchema = z.infer<typeof ExternalKmsInputSchema>;
export const ExternalKmsInputUpdateSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal(KmsProviders.Aws), inputs: ExternalKmsAwsSchema.partial() })
z.object({ type: z.literal(KmsProviders.Aws), inputs: ExternalKmsAwsSchema.partial() }),
z.object({ type: z.literal(KmsProviders.Gcp), inputs: ExternalKmsGcpSchema.partial() })
]);
export type TExternalKmsInputUpdateSchema = z.infer<typeof ExternalKmsInputUpdateSchema>;

View File

@@ -1,4 +1,4 @@
import { ForbiddenError } from "@casl/ability";
import { ForbiddenError, subject } from "@casl/ability";
import { packRules } from "@casl/ability/extra";
import ms from "ms";
@@ -62,7 +62,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Identity, { identityId })
);
const { permission: targetIdentityPermission } = await permissionService.getProjectPermission(
ActorType.IDENTITY,
identityId,
@@ -139,7 +142,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId })
);
const { permission: targetIdentityPermission } = await permissionService.getProjectPermission(
ActorType.IDENTITY,
identityProjectMembership.identityId,
@@ -216,7 +222,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Identity);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId })
);
const { permission: identityRolePermission } = await permissionService.getProjectPermission(
ActorType.IDENTITY,
identityProjectMembership.identityId,
@@ -258,7 +267,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId })
);
return {
...identityPrivilege,
@@ -289,7 +301,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId })
);
const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({
slug,
@@ -321,7 +336,10 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Identity, { identityId: identityProjectMembership.identityId })
);
const identityPrivileges = await identityProjectAdditionalPrivilegeDAL.find(
{

View File

@@ -1,4 +1,4 @@
import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability";
import { ForbiddenError, MongoAbility, RawRuleOf, subject } from "@casl/ability";
import { PackRule, packRules, unpackRules } from "@casl/ability/extra";
import ms from "ms";
@@ -69,7 +69,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Identity, { identityId })
);
const { permission: targetIdentityPermission } = await permissionService.getProjectPermission(
ActorType.IDENTITY,
identityId,
@@ -146,7 +150,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Identity, { identityId })
);
const { permission: targetIdentityPermission } = await permissionService.getProjectPermission(
ActorType.IDENTITY,
@@ -241,7 +249,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Identity, { identityId })
);
const { permission: identityRolePermission } = await permissionService.getProjectPermission(
ActorType.IDENTITY,
identityProjectMembership.identityId,
@@ -294,7 +306,10 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Identity, { identityId })
);
const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({
slug,
@@ -333,7 +348,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Identity, { identityId })
);
const identityPrivileges = await identityProjectAdditionalPrivilegeDAL.find({
projectMembershipId: identityProjectMembership.id

View File

@@ -82,6 +82,10 @@ export type SecretImportSubjectFields = {
secretPath: string;
};
export type IdentityManagementSubjectFields = {
identityId: string;
};
export type ProjectPermissionSet =
| [
ProjectPermissionActions,
@@ -121,7 +125,10 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
| [ProjectPermissionActions, ProjectPermissionSub.SecretRotation]
| [ProjectPermissionActions, ProjectPermissionSub.Identity]
| [
ProjectPermissionActions,
ProjectPermissionSub.Identity | (ForcedSubject<ProjectPermissionSub.Identity> & IdentityManagementSubjectFields)
]
| [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities]
| [ProjectPermissionActions, ProjectPermissionSub.Certificates]
| [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates]
@@ -213,6 +220,21 @@ const SecretConditionV2Schema = z
})
.partial();
const IdentityManagementConditionSchema = z
.object({
identityId: z.union([
z.string(),
z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN]
})
.partial()
])
})
.partial();
const GeneralPermissionSchema = [
z.object({
subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."),
@@ -262,12 +284,6 @@ const GeneralPermissionSchema = [
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.Identity).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.ServiceTokens).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
@@ -373,6 +389,12 @@ export const ProjectPermissionV1Schema = z.discriminatedUnion("subject", [
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.Identity).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
"Describe what action an entity can take."
)
}),
...GeneralPermissionSchema
]);
@@ -417,6 +439,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
z.object({
subject: z.literal(ProjectPermissionSub.Identity).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
"Describe what action an entity can take."
),
conditions: IdentityManagementConditionSchema.describe(
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
...GeneralPermissionSchema
]);
@@ -697,26 +729,26 @@ export const buildServiceTokenProjectPermission = (
[ProjectPermissionSub.Secrets, ProjectPermissionSub.SecretImports, ProjectPermissionSub.SecretFolders].forEach(
(subject) => {
if (canWrite) {
// TODO: @Akhi
// @ts-expect-error type
can(ProjectPermissionActions.Edit, subject, {
// TODO: @Akhi
// @ts-expect-error type
secretPath: { $glob: secretPath },
environment
});
// @ts-expect-error type
can(ProjectPermissionActions.Create, subject, {
// @ts-expect-error type
secretPath: { $glob: secretPath },
environment
});
// @ts-expect-error type
can(ProjectPermissionActions.Delete, subject, {
// @ts-expect-error type
secretPath: { $glob: secretPath },
environment
});
}
if (canRead) {
// @ts-expect-error type
can(ProjectPermissionActions.Read, subject, {
// @ts-expect-error type
secretPath: { $glob: secretPath },
environment
});

View File

@@ -178,7 +178,10 @@ const envSchema = z
HSM_LIB_PATH: zpStr(z.string().optional()),
HSM_PIN: zpStr(z.string().optional()),
HSM_KEY_LABEL: zpStr(z.string().optional()),
HSM_SLOT: z.coerce.number().optional().default(0)
HSM_SLOT: z.coerce.number().optional().default(0),
USE_PG_QUEUE: zodStrBool.default("false"),
SHOULD_INIT_PG_QUEUE: zodStrBool.default("false")
})
// To ensure that basic encryption is always possible.
.refine(

View File

@@ -89,9 +89,9 @@ const redactedKeys = [
const UNKNOWN_REQUEST_ID = "UNKNOWN_REQUEST_ID";
const extractRequestId = () => {
const extractReqId = () => {
try {
return requestContext.get("requestId") || UNKNOWN_REQUEST_ID;
return requestContext.get("reqId") || UNKNOWN_REQUEST_ID;
} catch (err) {
console.log("failed to get request context", err);
return UNKNOWN_REQUEST_ID;
@@ -133,22 +133,22 @@ export const initLogger = async () => {
const wrapLogger = (originalLogger: Logger): CustomLogger => {
// eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any
originalLogger.info = (obj: unknown, msg?: string, ...args: any[]) => {
return originalLogger.child({ requestId: extractRequestId() }).info(obj, msg, ...args);
return originalLogger.child({ reqId: extractReqId() }).info(obj, msg, ...args);
};
// eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any
originalLogger.error = (obj: unknown, msg?: string, ...args: any[]) => {
return originalLogger.child({ requestId: extractRequestId() }).error(obj, msg, ...args);
return originalLogger.child({ reqId: extractReqId() }).error(obj, msg, ...args);
};
// eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any
originalLogger.warn = (obj: unknown, msg?: string, ...args: any[]) => {
return originalLogger.child({ requestId: extractRequestId() }).warn(obj, msg, ...args);
return originalLogger.child({ reqId: extractReqId() }).warn(obj, msg, ...args);
};
// eslint-disable-next-line no-param-reassign, @typescript-eslint/no-explicit-any
originalLogger.debug = (obj: unknown, msg?: string, ...args: any[]) => {
return originalLogger.child({ requestId: extractRequestId() }).debug(obj, msg, ...args);
return originalLogger.child({ reqId: extractReqId() }).debug(obj, msg, ...args);
};
return originalLogger;

View File

@@ -1,6 +1,7 @@
import "./lib/telemetry/instrumentation";
import dotenv from "dotenv";
import { Redis } from "ioredis";
import path from "path";
import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns";
@@ -55,13 +56,17 @@ const run = async () => {
}
const smtp = smtpServiceFactory(formatSmtpConfig());
const queue = queueServiceFactory(appCfg.REDIS_URL);
const queue = queueServiceFactory(appCfg.REDIS_URL, appCfg.DB_CONNECTION_URI);
await queue.initialize();
const keyStore = keyStoreFactory(appCfg.REDIS_URL);
const redis = new Redis(appCfg.REDIS_URL);
const hsmModule = initializeHsmModule();
hsmModule.initialize();
const server = await main({ db, auditLogDb, hsmModule: hsmModule.getModule(), smtp, logger, queue, keyStore });
const server = await main({ db, auditLogDb, hsmModule: hsmModule.getModule(), smtp, logger, queue, keyStore, redis });
const bootstrap = await bootstrapCheck({ db });
// eslint-disable-next-line

View File

@@ -1,5 +1,6 @@
import { Job, JobsOptions, Queue, QueueOptions, RepeatOptions, Worker, WorkerListener } from "bullmq";
import Redis from "ioredis";
import PgBoss, { WorkOptions } from "pg-boss";
import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas";
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
@@ -7,6 +8,8 @@ import {
TScanFullRepoEventPayload,
TScanPushEventPayload
} from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types";
import { getConfig } from "@app/lib/config/env";
import { logger } from "@app/lib/logger";
import {
TFailedIntegrationSyncEmailsPayload,
TIntegrationSyncPayload,
@@ -184,17 +187,39 @@ export type TQueueJobTypes = {
};
export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>;
export const queueServiceFactory = (redisUrl: string) => {
export const queueServiceFactory = (redisUrl: string, dbConnectionUrl: string) => {
const connection = new Redis(redisUrl, { maxRetriesPerRequest: null });
const queueContainer = {} as Record<
QueueName,
Queue<TQueueJobTypes[QueueName]["payload"], void, TQueueJobTypes[QueueName]["name"]>
>;
const pgBoss = new PgBoss({
connectionString: dbConnectionUrl,
archiveCompletedAfterSeconds: 60,
archiveFailedAfterSeconds: 1000, // we want to keep failed jobs for a longer time so that it can be retried
deleteAfterSeconds: 30
});
const queueContainerPg = {} as Record<QueueJobs, boolean>;
const workerContainer = {} as Record<
QueueName,
Worker<TQueueJobTypes[QueueName]["payload"], void, TQueueJobTypes[QueueName]["name"]>
>;
const initialize = async () => {
const appCfg = getConfig();
if (appCfg.SHOULD_INIT_PG_QUEUE) {
logger.info("Initializing pg-queue...");
await pgBoss.start();
pgBoss.on("error", (error) => {
logger.error(error, "pg-queue error");
});
}
};
const start = <T extends QueueName>(
name: T,
jobFn: (job: Job<TQueueJobTypes[T]["payload"], void, TQueueJobTypes[T]["name"]>, token?: string) => Promise<void>,
@@ -215,6 +240,27 @@ export const queueServiceFactory = (redisUrl: string) => {
});
};
const startPg = async <T extends QueueName>(
jobName: QueueJobs,
jobsFn: (jobs: PgBoss.Job<TQueueJobTypes[T]["payload"]>[]) => Promise<void>,
options: WorkOptions & {
workerCount: number;
}
) => {
if (queueContainerPg[jobName]) {
throw new Error(`${jobName} queue is already initialized`);
}
await pgBoss.createQueue(jobName);
queueContainerPg[jobName] = true;
await Promise.all(
Array.from({ length: options.workerCount }).map(() =>
pgBoss.work<TQueueJobTypes[T]["payload"]>(jobName, options, jobsFn)
)
);
};
const listen = <
T extends QueueName,
U extends keyof WorkerListener<TQueueJobTypes[T]["payload"], void, TQueueJobTypes[T]["name"]>
@@ -238,6 +284,18 @@ export const queueServiceFactory = (redisUrl: string) => {
await q.add(job, data, opts);
};
const queuePg = async <T extends QueueName>(
job: TQueueJobTypes[T]["name"],
data: TQueueJobTypes[T]["payload"],
opts?: PgBoss.SendOptions & { jobId?: string }
) => {
await pgBoss.send({
name: job,
data,
options: opts
});
};
const stopRepeatableJob = async <T extends QueueName>(
name: T,
job: TQueueJobTypes[T]["name"],
@@ -274,5 +332,17 @@ export const queueServiceFactory = (redisUrl: string) => {
await Promise.all(Object.values(workerContainer).map((worker) => worker.close()));
};
return { start, listen, queue, shutdown, stopRepeatableJob, stopRepeatableJobByJobId, clearQueue, stopJobById };
return {
initialize,
start,
listen,
queue,
shutdown,
stopRepeatableJob,
stopRepeatableJobByJobId,
clearQueue,
stopJobById,
startPg,
queuePg
};
};

View File

@@ -12,6 +12,7 @@ import type { FastifyRateLimitOptions } from "@fastify/rate-limit";
import ratelimiter from "@fastify/rate-limit";
import { fastifyRequestContext } from "@fastify/request-context";
import fastify from "fastify";
import { Redis } from "ioredis";
import { Knex } from "knex";
import { HsmModule } from "@app/ee/services/hsm/hsm-types";
@@ -41,10 +42,11 @@ type TMain = {
queue: TQueueServiceFactory;
keyStore: TKeyStoreFactory;
hsmModule: HsmModule;
redis: Redis;
};
// Run the server!
export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, keyStore }: TMain) => {
export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, keyStore, redis }: TMain) => {
const appCfg = getConfig();
const server = fastify({
@@ -60,6 +62,7 @@ export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, key
server.setValidatorCompiler(validatorCompiler);
server.setSerializerCompiler(serializerCompiler);
server.decorate("redis", redis);
server.addContentTypeParser("application/scim+json", { parseAs: "string" }, (_, body, done) => {
try {
const strBody = body instanceof Buffer ? body.toString() : body;
@@ -109,9 +112,9 @@ export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, key
await server.register(maintenanceMode);
await server.register(fastifyRequestContext, {
defaultStoreValues: (request) => ({
requestId: request.id,
log: request.log.child({ requestId: request.id })
defaultStoreValues: (req) => ({
reqId: req.id,
log: req.log.child({ reqId: req.id })
})
});

View File

@@ -27,6 +27,7 @@ enum HttpStatusCodes {
NotFound = 404,
Unauthorized = 401,
Forbidden = 403,
UnprocessableContent = 422,
// eslint-disable-next-line @typescript-eslint/no-shadow
InternalServerError = 500,
GatewayTimeout = 504,
@@ -39,42 +40,42 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
if (error instanceof BadRequestError) {
void res
.status(HttpStatusCodes.BadRequest)
.send({ requestId: req.id, statusCode: HttpStatusCodes.BadRequest, message: error.message, error: error.name });
.send({ reqId: req.id, statusCode: HttpStatusCodes.BadRequest, message: error.message, error: error.name });
} else if (error instanceof NotFoundError) {
void res
.status(HttpStatusCodes.NotFound)
.send({ requestId: req.id, statusCode: HttpStatusCodes.NotFound, message: error.message, error: error.name });
.send({ reqId: req.id, statusCode: HttpStatusCodes.NotFound, message: error.message, error: error.name });
} else if (error instanceof UnauthorizedError) {
void res.status(HttpStatusCodes.Unauthorized).send({
requestId: req.id,
reqId: req.id,
statusCode: HttpStatusCodes.Unauthorized,
message: error.message,
error: error.name
});
} else if (error instanceof DatabaseError || error instanceof InternalServerError) {
void res.status(HttpStatusCodes.InternalServerError).send({
requestId: req.id,
reqId: req.id,
statusCode: HttpStatusCodes.InternalServerError,
message: "Something went wrong",
error: error.name
});
} else if (error instanceof GatewayTimeoutError) {
void res.status(HttpStatusCodes.GatewayTimeout).send({
requestId: req.id,
reqId: req.id,
statusCode: HttpStatusCodes.GatewayTimeout,
message: error.message,
error: error.name
});
} else if (error instanceof ZodError) {
void res.status(HttpStatusCodes.Unauthorized).send({
requestId: req.id,
statusCode: HttpStatusCodes.Unauthorized,
void res.status(HttpStatusCodes.UnprocessableContent).send({
reqId: req.id,
statusCode: HttpStatusCodes.UnprocessableContent,
error: "ValidationFailure",
message: error.issues
});
} else if (error instanceof ForbiddenError) {
void res.status(HttpStatusCodes.Forbidden).send({
requestId: req.id,
reqId: req.id,
statusCode: HttpStatusCodes.Forbidden,
error: "PermissionDenied",
message: `You are not allowed to ${error.action} on ${error.subjectType}`,
@@ -87,28 +88,28 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
});
} else if (error instanceof ForbiddenRequestError) {
void res.status(HttpStatusCodes.Forbidden).send({
requestId: req.id,
reqId: req.id,
statusCode: HttpStatusCodes.Forbidden,
message: error.message,
error: error.name
});
} else if (error instanceof RateLimitError) {
void res.status(HttpStatusCodes.TooManyRequests).send({
requestId: req.id,
reqId: req.id,
statusCode: HttpStatusCodes.TooManyRequests,
message: error.message,
error: error.name
});
} else if (error instanceof ScimRequestError) {
void res.status(error.status).send({
requestId: req.id,
reqId: req.id,
schemas: error.schemas,
status: error.status,
detail: error.detail
});
} else if (error instanceof OidcAuthError) {
void res.status(HttpStatusCodes.InternalServerError).send({
requestId: req.id,
reqId: req.id,
statusCode: HttpStatusCodes.InternalServerError,
message: error.message,
error: error.name
@@ -127,14 +128,14 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
}
void res.status(HttpStatusCodes.Forbidden).send({
requestId: req.id,
reqId: req.id,
statusCode: HttpStatusCodes.Forbidden,
error: "TokenError",
message: errorMessage
});
} else {
void res.status(HttpStatusCodes.InternalServerError).send({
requestId: req.id,
reqId: req.id,
statusCode: HttpStatusCodes.InternalServerError,
error: "InternalServerError",
message: "Something went wrong"

View File

@@ -394,13 +394,14 @@ export const registerRoutes = async (
permissionService
});
const auditLogQueue = auditLogQueueServiceFactory({
const auditLogQueue = await auditLogQueueServiceFactory({
auditLogDAL,
queueService,
projectDAL,
licenseService,
auditLogStreamDAL
});
const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue });
const auditLogStreamService = auditLogStreamServiceFactory({
licenseService,

View File

@@ -30,32 +30,39 @@ export const integrationAuthPubSchema = IntegrationAuthsSchema.pick({
export const DefaultResponseErrorsSchema = {
400: z.object({
requestId: z.string(),
reqId: z.string(),
statusCode: z.literal(400),
message: z.string(),
error: z.string()
}),
404: z.object({
requestId: z.string(),
reqId: z.string(),
statusCode: z.literal(404),
message: z.string(),
error: z.string()
}),
401: z.object({
requestId: z.string(),
reqId: z.string(),
statusCode: z.literal(401),
message: z.any(),
message: z.string(),
error: z.string()
}),
403: z.object({
requestId: z.string(),
reqId: z.string(),
statusCode: z.literal(403),
message: z.string(),
details: z.any().optional(),
error: z.string()
}),
// Zod errors return a message of varying shapes and sizes, so z.any() is used here
422: z.object({
reqId: z.string(),
statusCode: z.literal(422),
message: z.any(),
error: z.string()
}),
500: z.object({
requestId: z.string(),
reqId: z.string(),
statusCode: z.literal(500),
message: z.string(),
error: z.string()

View File

@@ -8,6 +8,7 @@
import { Authenticator } from "@fastify/passport";
import fastifySession from "@fastify/session";
import RedisStore from "connect-redis";
import { Strategy as GitHubStrategy } from "passport-github";
import { Strategy as GitLabStrategy } from "passport-gitlab2";
import { Strategy as GoogleStrategy } from "passport-google-oauth20";
@@ -23,8 +24,22 @@ import { OrgAuthMethod } from "@app/services/org/org-types";
export const registerSsoRouter = async (server: FastifyZodProvider) => {
const appCfg = getConfig();
const passport = new Authenticator({ key: "sso", userProperty: "passportUser" });
await server.register(fastifySession, { secret: appCfg.COOKIE_SECRET_SIGN_KEY });
const redisStore = new RedisStore({
client: server.redis,
prefix: "oauth-session:",
ttl: 600 // 10 minutes
});
await server.register(fastifySession, {
secret: appCfg.COOKIE_SECRET_SIGN_KEY,
store: redisStore,
cookie: {
secure: appCfg.HTTPS_ENABLED,
sameSite: "lax" // we want cookies to be sent to Infisical in redirects originating from IDP server
}
});
await server.register(passport.initialize());
await server.register(passport.secureSession());
// passport oauth strategy for Google
@@ -37,11 +52,15 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
clientID: appCfg.CLIENT_ID_GOOGLE_LOGIN as string,
clientSecret: appCfg.CLIENT_SECRET_GOOGLE_LOGIN as string,
callbackURL: `${appCfg.SITE_URL}/api/v1/sso/google`,
scope: ["profile", " email"]
scope: ["profile", " email"],
state: true
},
// eslint-disable-next-line
async (req, _accessToken, _refreshToken, profile, cb) => {
try {
// @ts-expect-error this is because this is express type and not fastify
const callbackPort = req.session.get("callbackPort");
const email = profile?.emails?.[0]?.value;
if (!email)
throw new NotFoundError({
@@ -54,7 +73,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
firstName: profile?.name?.givenName || "",
lastName: profile?.name?.familyName || "",
authMethod: AuthMethod.GOOGLE,
callbackPort: req.query.state as string
callbackPort
});
cb(null, { isUserCompleted, providerAuthToken });
} catch (error) {
@@ -76,10 +95,14 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
clientID: appCfg.CLIENT_ID_GITHUB_LOGIN as string,
clientSecret: appCfg.CLIENT_SECRET_GITHUB_LOGIN as string,
callbackURL: `${appCfg.SITE_URL}/api/v1/sso/github`,
scope: ["user:email"]
scope: ["user:email"],
// akhilmhdh: because the ts type for this is outdated by the maintainer
state: true as unknown as string
},
// eslint-disable-next-line
async (req, accessToken, _refreshToken, profile, cb) => {
// @ts-expect-error this is because this is express type and not fastify
const callbackPort = req.session.get("callbackPort");
try {
const ghEmails = await fetchGithubEmails(accessToken);
const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0];
@@ -88,7 +111,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
firstName: profile.displayName,
lastName: "",
authMethod: AuthMethod.GITHUB,
callbackPort: req.query.state as string
callbackPort
});
return cb(null, { isUserCompleted, providerAuthToken });
} catch (error) {
@@ -112,17 +135,20 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
clientID: appCfg.CLIENT_ID_GITLAB_LOGIN,
clientSecret: appCfg.CLIENT_SECRET_GITLAB_LOGIN,
callbackURL: `${appCfg.SITE_URL}/api/v1/sso/gitlab`,
baseURL: appCfg.CLIENT_GITLAB_LOGIN_URL
baseURL: appCfg.CLIENT_GITLAB_LOGIN_URL,
state: true
},
async (req: any, _accessToken: string, _refreshToken: string, profile: any, cb: any) => {
try {
const callbackPort = req.session.get("callbackPort");
const email = profile.emails[0].value;
const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({
email,
firstName: profile.displayName,
lastName: "",
authMethod: AuthMethod.GITLAB,
callbackPort: req.query.state as string
callbackPort
});
return cb(null, { isUserCompleted, providerAuthToken });
@@ -143,17 +169,24 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
callback_port: z.string().optional()
})
},
preValidation: (req, res) =>
(
passport.authenticate("google", {
scope: ["profile", "email"],
session: false,
state: req.query.callback_port,
authInfo: false
// this is due to zod type difference
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any
)(req, res),
preValidation: [
async (req, res) => {
const { callback_port: callbackPort } = req.query;
// ensure fresh session state per login attempt
await req.session.regenerate();
if (callbackPort) {
req.session.set("callbackPort", callbackPort);
}
return (
passport.authenticate("google", {
scope: ["profile", "email"],
authInfo: false
// this is due to zod type difference
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any
)(req, res);
}
],
handler: () => {}
});
@@ -166,7 +199,8 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
authInfo: false
// this is due to zod type difference
}) as never,
handler: (req, res) => {
handler: async (req, res) => {
await req.session.destroy();
if (req.passportUser.isUserCompleted) {
return res.redirect(
`${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}`
@@ -186,15 +220,24 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
callback_port: z.string().optional()
})
},
preValidation: (req, res) =>
(
passport.authenticate("github", {
session: false,
state: req.query.callback_port,
authInfo: false
// this is due to zod type difference
}) as any
)(req, res),
preValidation: [
async (req, res) => {
const { callback_port: callbackPort } = req.query;
// ensure fresh session state per login attempt
await req.session.regenerate();
if (callbackPort) {
req.session.set("callbackPort", callbackPort);
}
return (
passport.authenticate("github", {
session: false,
authInfo: false
// this is due to zod type difference
}) as any
)(req, res);
}
],
handler: () => {}
});
@@ -245,7 +288,8 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
authInfo: false
// this is due to zod type difference
}) as any,
handler: (req, res) => {
handler: async (req, res) => {
await req.session.destroy();
if (req.passportUser.isUserCompleted) {
return res.redirect(
`${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}`
@@ -265,16 +309,25 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
callback_port: z.string().optional()
})
},
preValidation: (req, res) =>
(
passport.authenticate("gitlab", {
session: false,
state: req.query.callback_port,
authInfo: false
// this is due to zod type difference
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any
)(req, res),
preValidation: [
async (req, res) => {
const { callback_port: callbackPort } = req.query;
// ensure fresh session state per login attempt
await req.session.regenerate();
if (callbackPort) {
req.session.set("callbackPort", callbackPort);
}
return (
passport.authenticate("gitlab", {
session: false,
authInfo: false
// this is due to zod type difference
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any
)(req, res);
}
],
handler: () => {}
});
@@ -288,7 +341,8 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
// this is due to zod type difference
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
handler: (req, res) => {
handler: async (req, res) => {
await req.session.destroy();
if (req.passportUser.isUserCompleted) {
return res.redirect(
`${appCfg.SITE_URL}/login/sso?token=${encodeURIComponent(req.passportUser.providerAuthToken)}`

View File

@@ -1,4 +1,4 @@
import { ForbiddenError } from "@casl/ability";
import { ForbiddenError, subject } from "@casl/ability";
import ms from "ms";
import { ProjectMembershipRole } from "@app/db/schemas";
@@ -61,7 +61,12 @@ export const identityProjectServiceFactory = ({
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Identity);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Identity, {
identityId
})
);
const existingIdentity = await identityProjectDAL.findOne({ identityId, projectId });
if (existingIdentity)
@@ -161,7 +166,10 @@ export const identityProjectServiceFactory = ({
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Identity, { identityId })
);
const projectIdentity = await identityProjectDAL.findOne({ identityId, projectId });
if (!projectIdentity)
@@ -253,7 +261,11 @@ export const identityProjectServiceFactory = ({
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Identity);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.Identity, { identityId })
);
const { permission: identityRolePermission } = await permissionService.getProjectPermission(
ActorType.IDENTITY,
identityId,
@@ -317,7 +329,11 @@ export const identityProjectServiceFactory = ({
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Identity, { identityId })
);
const [identityMembership] = await identityProjectDAL.findByProjectId(projectId, { identityId });
if (!identityMembership)

View File

@@ -4,8 +4,10 @@ import { z } from "zod";
import { KmsKeysSchema, TKmsRootConfig } from "@app/db/schemas";
import { AwsKmsProviderFactory } from "@app/ee/services/external-kms/providers/aws-kms";
import { GcpKmsProviderFactory } from "@app/ee/services/external-kms/providers/gcp-kms";
import {
ExternalKmsAwsSchema,
ExternalKmsGcpSchema,
KmsProviders,
TExternalKmsProviderFns
} from "@app/ee/services/external-kms/providers/model";
@@ -291,6 +293,16 @@ export const kmsServiceFactory = ({
});
break;
}
case KmsProviders.Gcp: {
const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync(
JSON.parse(decryptedProviderInputBlob.toString("utf8"))
);
externalKms = await GcpKmsProviderFactory({
inputs: decryptedProviderInput
});
break;
}
default:
throw new Error("Invalid KMS provider.");
}
@@ -353,6 +365,16 @@ export const kmsServiceFactory = ({
});
break;
}
case KmsProviders.Gcp: {
const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync(
JSON.parse(decryptedProviderInputBlob.toString("utf8"))
);
externalKms = await GcpKmsProviderFactory({
inputs: decryptedProviderInput
});
break;
}
default:
throw new Error("Invalid KMS provider.");
}

View File

@@ -1,7 +1,7 @@
import { ForbiddenError } from "@casl/ability";
import slugify from "@sindresorhus/slugify";
import { OrgMembershipRole, ProjectMembershipRole, ProjectVersion, TProjectEnvironments } from "@app/db/schemas";
import { ProjectMembershipRole, ProjectVersion, TProjectEnvironments } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
@@ -9,7 +9,6 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services
import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service";
import { InfisicalProjectTemplate } from "@app/ee/services/project-template/project-template-types";
import { TKeyStoreFactory } from "@app/keystore/keystore";
import { isAtLeastAsPrivileged } from "@app/lib/casl";
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn";
@@ -370,20 +369,6 @@ export const projectServiceFactory = ({
});
}
// Get the role permission for the identity
const { permission: rolePermission, role: customRole } = await permissionService.getOrgPermissionByRole(
OrgMembershipRole.Member,
organization.id
);
// Identity has to be at least a member in order to create projects
const hasPrivilege = isAtLeastAsPrivileged(permission, rolePermission);
if (!hasPrivilege)
throw new ForbiddenRequestError({
message: "Failed to add identity to project with more privileged role"
});
const isCustomRole = Boolean(customRole);
const identityProjectMembership = await identityProjectDAL.create(
{
identityId: actorId,
@@ -395,8 +380,7 @@ export const projectServiceFactory = ({
await identityProjectMembershipRoleDAL.create(
{
projectMembershipId: identityProjectMembership.id,
role: isCustomRole ? ProjectMembershipRole.Custom : ProjectMembershipRole.Admin,
customRoleId: customRole?.id
role: ProjectMembershipRole.Admin
},
tx
);

View File

@@ -932,8 +932,12 @@ export const secretQueueFactory = ({
);
const message =
(err instanceof AxiosError ? JSON.stringify(err?.response?.data) : (err as Error)?.message) ||
"Unknown error occurred.";
// eslint-disable-next-line no-nested-ternary
(err instanceof AxiosError
? err?.response?.data
? JSON.stringify(err?.response?.data)
: err?.message
: (err as Error)?.message) || "Unknown error occurred.";
await auditLogService.createAuditLog({
projectId,

View File

@@ -8,9 +8,9 @@
</head>
<body>
<h2>Join your organization on Infisical</h2>
<p>{{inviterFirstName}} ({{inviterUsername}}) has invited you to their Infisical organization {{organizationName}}</p>
<a href="{{callback_url}}?token={{token}}{{#if metadata}}&metadata={{metadata}}{{/if}}&to={{email}}&organization_id={{organizationId}}">Join now</a>
<p>{{inviterFirstName}} ({{inviterUsername}}) has invited you to their Infisical organization named {{organizationName}}</p>
<a href="{{callback_url}}?token={{token}}{{#if metadata}}&metadata={{metadata}}{{/if}}&to={{email}}&organization_id={{organizationId}}">Click to join</a>
<h3>What is Infisical?</h3>
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.</p>
</body>
</html>
</html>

View File

@@ -6,10 +6,10 @@
</head>
<body>
<h2>Join your team on Infisical</h2>
<p>You have been invited to a new Infisical project {{workspaceName}}</p>
<a href="{{callback_url}}">Join now</a>
<p>You have been invited to a new Infisical project named {{workspaceName}}</p>
<a href="{{callback_url}}">Click to join</a>
<h3>What is Infisical?</h3>
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets
and configs.</p>
</body>
</html>
</html>

View File

@@ -74,22 +74,22 @@ Next, you will need to follow the steps listed below to add AWS KMS for your org
<Steps>
<Step title="Navigate to the organization settings and select the 'Encryption' tab.">
![Open encryption org settings](../../../images/platform/kms/aws/encryption-org-settings.png)
![Open encryption org settings](../../../images/platform/kms/encryption-org-settings.png)
</Step>
<Step title="Click on the 'Add' button">
![Add encryption org settings](../../../images/platform/kms/aws/encryption-org-settings-add.png)
![Add encryption org settings](../../../images/platform/kms/encryption-org-settings-add.png)
Click the 'Add' button to begin adding a new external KMS.
</Step>
<Step title="Select 'AWS KMS'">
![Select Encryption Provider](../../../images/platform/kms/aws/encryption-modal-provider-select.png)
![Select Encryption Provider](../../../images/platform/kms/encryption-modal-provider-select.png)
Choose 'AWS KMS' from the list of encryption providers.
</Step>
<Step title="Provide the inputs for AWS KMS">
Selecting AWS as the provider will require you input the following fields.
Selecting AWS as the provider will require you input the following fields.
<ParamField path="Alias" type="string" required>
Name for referencing the AWS KMS key within the organization.
</ParamField>
<ParamField path="Alias" type="string" required>
Name for referencing the AWS KMS key within the organization.
</ParamField>
<ParamField path="Description" type="string">
Short description of the AWS KMS key.

View File

@@ -0,0 +1,132 @@
---
title: "GCP Key Management Service"
description: "Learn how to manage encryption using GCP KMS"
---
To enhance the security of your Infisical projects, you can now encrypt your secrets using an external Key Management Service (KMS).
When external KMS is configured for your project, all encryption and decryption operations will be handled by the chosen KMS.
This guide will walk you through the steps needed to configure external KMS support with Google Cloud KMS.
## Prerequisites
Before you begin, you'll first need to set up a GCP Service Account, add a KMS key and set the required permissions.
<Steps>
<Step title="Create a GCP Service Account">
1. Navigate to the [Create Service Account](https://console.cloud.google.com/iam-admin/serviceaccounts/create) page in your GCP Console.
![GCP Service Account Creation](/images/platform/kms/gcp/service-account-form.png)
2. Give the service account a suitable **name** and **description**. Then click **Create and Continue**.
3. Under **Grant this service account access to project**, click **Select a role** and select the
**Cloud KMS Viewer** and **Cloud KMS CryptoKey Encrypter/Decrypter*** roles, then click **Continue**.
![GCP Service Account Permissions](/images/platform/kms/gcp/service-account-permissions.png)
3. You can skip the **Grant users access to this service account** options.
4. Click Done.
5. You should see the service account in the list of service accounts. Click it to view the service account details.
6. Select the **Keys** tab, click **Add Key**, select **Create new key**, select **JSON** as the key type, then click **Create**.
7. You will be prompted to download a JSON file that we will need later on.
<Info>
Remember to keep the JSON file in a secure location. It will be used to authenticate your GCP service account.
Once you have successfully set up GCP KMS with Infisical, you should permanently delete the JSON file.
</Info>
</Step>
<Step title="Add a GCP KMS Key">
1. Navigate to the [KMS](https://console.cloud.google.com/security/kms) page in your GCP Console.
<Info>
If you have not used GCP KMS before, you will be redirected to the **Cloud Key Management Service (KMS) API** page.
Click **Enable** to enable the KMS API, then continue the steps below.
It may take a few minutes for the API to be enabled and KMS section of the Cloud Console to become viewable.
</Info>
2. In the KMS section, click **Create Key Ring**.
![GCP Create Key Ring](/images/platform/kms/gcp/keyring-create.png)
3. Give the key ring a **Name** and select a **Region**, then click **Create**.
<Info>
We don't currently support multi-region key rings.
</Info>
4. On the "Create Key" page, give the key a **Name** and set the **Protection Level** based on your requirements (or use default *Software*), then click **Continue**.
5. Under **Key Material**, select **Generated Key**, then click **Continue**.
6. Under **Purpose**, select **Symmetric encrypt/decrypt**, then click **Continue**.
7. For **Key Rotation Period**, select **Never (manual rotation)**, then click **Continue** followed by **Create**.
8. You should see the key in the list of keys. We're now ready to set it up in Infisical.
</Step>
</Steps>
## Setup GCP KMS in the Organization Settings
Next, you will need to follow the steps listed below to add GCP KMS for your organization.
<Steps>
<Step title="Navigate to the organization settings and select the 'Encryption' tab.">
![Open encryption org settings](../../../images/platform/kms/encryption-org-settings.png)
</Step>
<Step title="Click on the 'Add' button">
![Add encryption org settings](../../../images/platform/kms/encryption-org-settings-add.png)
Click the 'Add' button to begin adding a new external KMS.
</Step>
<Step title="Select 'GCP KMS'">
![Select Encryption Provider](../../../images/platform/kms/encryption-modal-provider-select.png)
Choose 'GCP KMS' from the list of encryption providers.
</Step>
<Step title="Provide the inputs for GCP KMS">
![GCP Create KMS Modal](/images/platform/kms/gcp/gcp-add-modal-filled.png)
Selecting GCP as the provider will require you input the following fields.
<ParamField path="Alias" type="string" required>
Name for referencing the GCP KMS key within the organization.
</ParamField>
<ParamField path="Description" type="string">
Short description of the GCP KMS key.
</ParamField>
<ParamField path="GCP Region" type="dropdown" required>
The GCP region where the GCP KMS key ring is located.
</ParamField>
<ParamField path="Service Account Credential JSON" type="file" required>
Upload the JSON file you downloaded earlier when creating the GCP service account.
</ParamField>
<ParamField path="GCP Key Name" type="dropdown" required>
This field will be populated with the list of GCP KMS keys in the selected region. Select the key you created earlier.
</ParamField>
</Step>
<Step title="Click Save">
Save your configuration to apply the settings.
</Step>
</Steps>
You now have a GCP KMS Key configured at the organization level. You can assign these GCP KMS keys to existing Infisical projects by visiting the 'Project Settings' page.
## Assign GCP KMS Key to an Existing Project
To assign the GCP KMS key you added to your organization, follow the steps below.
<Steps>
<Step title="Open Project Settings and select to the Encryption Tab">
![Open encryption project
settings](../../../images/platform/kms/gcp/project-settings.png)
</Step>
<Step title="Under the Key Management section, select your newly added GCP KMS key from the dropdown">
![Select encryption project
settings](../../../images/platform/kms/gcp/select-gcp-kms-in-project.png)
Choose the GCP KMS key you configured earlier.
</Step>
<Step title="Click Save">
Once you have selected the KMS of choice, click save.
</Step>
</Steps>

View File

@@ -25,4 +25,4 @@ For existing projects, you can configure the KMS from the Project Settings page.
## External KMS
Infisical supports the use of external KMS solutions to enhance security and compliance. You can configure your project to use services like [AWS Key Management Service](./aws-kms) for managing encryption.
Infisical supports the use of external KMS solutions to enhance security and compliance. You can configure your project to use services like [AWS Key Management Service](./aws-kms) or [GCP Key Management Service](./gcp-kms) for managing encryption.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 KiB

View File

Before

Width:  |  Height:  |  Size: 694 KiB

After

Width:  |  Height:  |  Size: 694 KiB

View File

Before

Width:  |  Height:  |  Size: 482 KiB

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -162,6 +162,10 @@ spec:
secretName: managed-secret
secretNamespace: default
creationPolicy: "Orphan" ## Owner | Orphan
# template:
# includeAllSecrets: true
# data:
# CUSTOM_KEY: "{{ .KEY.SecretPath }} {{ .KEY.Value }}"
# secretType: kubernetes.io/dockerconfigjson
```
@@ -674,6 +678,51 @@ The namespace of the managed Kubernetes secret to be created.
<Accordion title="managedSecretReference.secretType">
Override the default Opaque type for managed secrets with this field. Useful for creating kubernetes.io/dockerconfigjson secrets.
</Accordion>
<Accordion title="managedSecretReference.template">
Templates enable you to transform data from Infisical before storing it as a Kubernetes Secret.
</Accordion>
<Accordion title="managedSecretReference.template.includeAllSecrets">
When set to true, this option injects all secrets retrieved from Infisical into your configuration.
Secrets defined in the template will override the automatically injected secrets.
</Accordion>
<Accordion title="managedSecretReference.template.data">
Define secret keys and their corresponding templates.
Each data value uses a Golang template with access to all secrets retrieved from the specified scope.
Secrets are structured as follows:
```golang
type TemplateSecret struct {
Value string `json:"value"`
SecretPath string `json:"secretPath"`
}
```
#### Example template configuration:
```golang
managedSecretReference:
secretName: managed-secret
secretNamespace: default
template:
includeAllSecrets: true
data:
NEW_KEY: "{{ .KEY1.SecretPath }} {{ .KEY1.Value }}"
```
When you run the following command:
```bash
kubectl get secret managed-secret -o jsonpath='{.data}'
```
You'll receive Kubernetes secrets output that includes the NEW_KEY:
```bash
{... "KEY":"d29ybGQ=","NEW_KEY":"LyBoZWxsbw=="}
```
When you set `includeAllSecrets` as `false` the Kubernetes secrets outputs will be:
```bash
{"NEW_KEY":"LyBoZWxsbw=="}
```
</Accordion>
<Accordion title="managedSecretReference.creationPolicy">
Creation polices allow you to control whether or not owner references should be added to the managed Kubernetes secret that is generated by the Infisical operator.
This is useful for tools such as ArgoCD, where every resource requires an owner reference; otherwise, it will be pruned automatically.

View File

@@ -32,7 +32,10 @@
"thumbsRating": true
},
"api": {
"baseUrl": ["https://app.infisical.com", "http://localhost:8080"]
"baseUrl": [
"https://app.infisical.com",
"http://localhost:8080"
]
},
"topbarLinks": [
{
@@ -73,7 +76,9 @@
"documentation/getting-started/introduction",
{
"group": "Quickstart",
"pages": ["documentation/guides/local-development"]
"pages": [
"documentation/guides/local-development"
]
},
{
"group": "Guides",
@@ -127,7 +132,8 @@
"pages": [
"documentation/platform/kms-configuration/overview",
"documentation/platform/kms-configuration/aws-kms",
"documentation/platform/kms-configuration/aws-hsm"
"documentation/platform/kms-configuration/aws-hsm",
"documentation/platform/kms-configuration/gcp-kms"
]
},
{
@@ -461,11 +467,15 @@
},
{
"group": "Build Tool Integrations",
"pages": ["integrations/build-tools/gradle"]
"pages": [
"integrations/build-tools/gradle"
]
},
{
"group": "",
"pages": ["sdks/overview"]
"pages": [
"sdks/overview"
]
},
{
"group": "SDK's",
@@ -485,7 +495,9 @@
"api-reference/overview/authentication",
{
"group": "Examples",
"pages": ["api-reference/overview/examples/integration"]
"pages": [
"api-reference/overview/examples/integration"
]
}
]
},
@@ -760,11 +772,15 @@
},
{
"group": "Service Tokens",
"pages": ["api-reference/endpoints/service-tokens/get"]
"pages": [
"api-reference/endpoints/service-tokens/get"
]
},
{
"group": "Audit Logs",
"pages": ["api-reference/endpoints/audit-logs/export-audit-log"]
"pages": [
"api-reference/endpoints/audit-logs/export-audit-log"
]
}
]
},
@@ -863,7 +879,9 @@
},
{
"group": "",
"pages": ["changelog/overview"]
"pages": [
"changelog/overview"
]
},
{
"group": "Contributing",
@@ -887,7 +905,9 @@
},
{
"group": "Contributing to SDK",
"pages": ["contributing/sdk/developing"]
"pages": [
"contributing/sdk/developing"
]
}
]
}
@@ -911,13 +931,22 @@
{
"title": "PRODUCT",
"links": [
{ "label": "Secret Management", "url": "https://infisical.com/" },
{ "label": "Secret Scanning", "url": "https://infisical.com/radar" },
{
"label": "Secret Management",
"url": "https://infisical.com/"
},
{
"label": "Secret Scanning",
"url": "https://infisical.com/radar"
},
{
"label": "Share Secrets",
"url": "https://app.infisical.com/share-secret"
},
{ "label": "Pricing", "url": "https://infisical.com/pricing" },
{
"label": "Pricing",
"url": "https://infisical.com/pricing"
},
{
"label": "Security",
"url": "https://infisical.com/docs/internals/security"
@@ -1061,4 +1090,4 @@
}
]
}
}
}

View File

@@ -89,7 +89,7 @@
"react-mailchimp-subscribe": "^2.1.3",
"react-markdown": "^8.0.3",
"react-redux": "^8.0.2",
"react-select": "^5.8.3",
"react-select": "^5.8.1",
"react-table": "^7.8.0",
"react-toastify": "^9.1.3",
"sanitize-html": "^2.12.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,18 +1,60 @@
import { ReactNode } from "react";
import { Id, toast, ToastContainer, ToastOptions, TypeOptions } from "react-toastify";
import { faCopy, IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { CopyButton } from "../v2/CopyButton";
export type TNotification = {
title?: string;
text: ReactNode;
children?: ReactNode;
callToAction?: ReactNode;
copyActions?: { icon?: IconDefinition; value: string; name: string; label?: string }[];
};
export const NotificationContent = ({ title, text, children }: TNotification) => {
export const NotificationContent = ({
title,
text,
children,
callToAction,
copyActions
}: TNotification) => {
return (
<div className="msg-container">
{title && <div className="text-md mb-1 font-medium">{title}</div>}
<div className={title ? "text-sm text-neutral-400" : "text-md"}>{text}</div>
{children && <div className="mt-2">{children}</div>}
{(callToAction || copyActions) && (
<div
className={twMerge(
"mt-2 flex h-7 w-full flex-row items-end gap-2",
callToAction ? "justify-between" : "justify-end"
)}
>
{callToAction}
{copyActions && (
<div className="flex h-7 flex-row items-center gap-2">
{copyActions.map((action) => (
<div className="flex flex-row items-center gap-2" key={`copy-${action.name}`}>
{action.label && (
<span className="ml-2 text-xs text-mineshaft-400">{action.label}</span>
)}
<CopyButton
value={action.value}
name={action.name}
size="xs"
variant="plain"
color="text-mineshaft-400"
icon={action.icon ?? faCopy}
/>
</div>
))}
</div>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,55 @@
import { faCheck, faCopy, IconDefinition } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { useTimedReset } from "@app/hooks";
import { IconButton } from "../IconButton";
import { Tooltip } from "../Tooltip";
export type CopyButtonProps = {
value: string;
size?: "xs" | "sm" | "md" | "lg";
variant?: "solid" | "outline" | "plain" | "star" | "outline_bg";
color?: string;
name?: string;
icon?: IconDefinition;
};
export const CopyButton = ({
value,
size = "sm",
variant = "solid",
color,
name,
icon = faCopy
}: CopyButtonProps) => {
const [copyText, isCopying, setCopyText] = useTimedReset<string>({
initialState: name ? `Copy ${name}` : "Copy to clipboard"
});
async function handleCopyText() {
setCopyText("Copied");
navigator.clipboard.writeText(value);
}
return (
<div>
<Tooltip content={copyText} size={size === "xs" || size === "sm" ? "sm" : "md"}>
<IconButton
ariaLabel={copyText}
variant={variant}
className={twMerge("group relative", color)}
size={size}
onClick={() => {
handleCopyText();
}}
>
<FontAwesomeIcon icon={isCopying ? faCheck : icon} />
</IconButton>
</Tooltip>
</div>
);
};
CopyButton.displayName = "CopyButton";

View File

@@ -0,0 +1,2 @@
export type { CopyButtonProps } from "./CopyButton";
export { CopyButton } from "./CopyButton";

View File

@@ -40,32 +40,39 @@ export const FilterableSelect = <T,>({
...props.components
}}
classNames={{
container: () => "w-full font-inter",
control: ({ isFocused }) =>
container: ({ isDisabled }) =>
twMerge("w-full text-sm font-inter", isDisabled && "!pointer-events-auto opacity-50"),
control: ({ isFocused, isDisabled }) =>
twMerge(
isFocused ? "border-primary-400/50" : "border-mineshaft-600 hover:border-gray-400",
"border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 hover:cursor-pointer"
isFocused ? "border-primary-400/50" : "border-mineshaft-600 ",
`border w-full p-0.5 rounded-md text-mineshaft-200 font-inter bg-mineshaft-900 ${
isDisabled ? "!cursor-not-allowed" : "hover:border-gray-400 hover:cursor-pointer"
} `
),
placeholder: () => "text-mineshaft-400 text-sm pl-1 py-0.5",
input: () => "pl-1 py-0.5",
placeholder: () =>
`${isMulti ? "py-[0.22rem]" : "leading-7"} text-mineshaft-400 text-sm pl-1`,
input: () => "pl-1",
valueContainer: () =>
`p-1 max-h-[14rem] ${isMulti ? "!overflow-y-auto thin-scrollbar" : ""} gap-1`,
`px-1 max-h-[8.2rem] ${
isMulti ? "!overflow-y-auto thin-scrollbar py-1" : "py-[0.1rem]"
} gap-1`,
singleValue: () => "leading-7 ml-1",
multiValue: () => "bg-mineshaft-600 rounded items-center py-0.5 px-2 gap-1.5",
multiValue: () => "bg-mineshaft-600 text-sm rounded items-center py-0.5 px-2 gap-1.5",
multiValueLabel: () => "leading-6 text-sm",
multiValueRemove: () => "hover:text-red text-bunker-400",
indicatorsContainer: () => "p-1 gap-1",
clearIndicator: () => "p-1 hover:text-red text-bunker-400",
indicatorSeparator: () => "bg-bunker-400",
dropdownIndicator: () => "text-bunker-200 p-1",
menuList: () => "flex flex-col gap-1",
menu: () =>
"mt-2 p-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md",
"my-2 p-2 border text-sm text-mineshaft-200 thin-scrollbar bg-mineshaft-900 border-mineshaft-600 rounded-md",
groupHeading: () => "ml-3 mt-2 mb-1 text-mineshaft-400 text-sm",
option: ({ isFocused, isSelected }) =>
twMerge(
isFocused && "bg-mineshaft-700 active:bg-mineshaft-600",
isSelected && "text-mineshaft-200",
"hover:cursor-pointer mb-1 rounded text-xs px-3 py-2"
"hover:cursor-pointer rounded text-xs px-3 py-2"
),
noOptionsMessage: () => "text-mineshaft-400 p-2 rounded-md"
}}

View File

@@ -54,7 +54,7 @@ export const Pagination = ({
)}
>
{startAdornment}
<div className="ml-auto mr-6 flex items-center space-x-2">
<div className={twMerge("mr-4 flex items-center space-x-2", startAdornment && "ml-auto")}>
<div className="text-xs">
{(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
</div>

View File

@@ -36,10 +36,12 @@ export const MultiValueRemove = (props: MultiValueRemoveProps) => {
export const Option = <T,>({ isSelected, children, ...props }: OptionProps<T>) => {
return (
<components.Option isSelected={isSelected} {...props}>
{children}
{isSelected && (
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
)}
<div className="flex flex-row items-center justify-between">
<p className="truncate">{children}</p>
{isSelected && (
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
)}
</div>
</components.Option>
);
};

View File

@@ -13,6 +13,7 @@ export type TooltipProps = Omit<TooltipPrimitive.TooltipContentProps, "open" | "
position?: "top" | "bottom" | "left" | "right";
isDisabled?: boolean;
center?: boolean;
size?: "sm" | "md";
};
export const Tooltip = ({
@@ -26,6 +27,7 @@ export const Tooltip = ({
asChild = true,
isDisabled,
position = "top",
size = "md",
...props
}: TooltipProps) =>
// just render children if tooltip content is empty
@@ -43,7 +45,7 @@ export const Tooltip = ({
sideOffset={5}
{...props}
className={twMerge(
`z-50 max-w-[15rem] select-none rounded-md border border-mineshaft-600 bg-mineshaft-800 py-2 px-4 text-sm font-light text-bunker-200 shadow-md
`z-50 max-w-[15rem] select-none border border-mineshaft-600 bg-mineshaft-800 font-light text-bunker-200 shadow-md
data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade
data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade
data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade
@@ -51,6 +53,8 @@ export const Tooltip = ({
`,
isDisabled && "!hidden",
center && "text-center",
size === "sm" && "rounded-sm py-1 px-2 text-xs",
size === "md" && "rounded-md py-2 px-4 text-sm",
className
)}
>

View File

@@ -33,6 +33,10 @@ export enum PermissionConditionOperators {
$GLOB = "$glob"
}
export type IdentityManagementSubjectFields = {
identityId: string;
};
export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = {
[PermissionConditionOperators.$EQ]: "equal to",
[PermissionConditionOperators.$IN]: "contains",
@@ -151,7 +155,13 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
| [ProjectPermissionActions, ProjectPermissionSub.SecretRotation]
| [ProjectPermissionActions, ProjectPermissionSub.Identity]
| [
ProjectPermissionActions,
(
| ProjectPermissionSub.Identity
| (ForcedSubject<ProjectPermissionSub.Identity> & IdentityManagementSubjectFields)
)
]
| [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities]
| [ProjectPermissionActions, ProjectPermissionSub.Certificates]
| [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates]

View File

@@ -0,0 +1,12 @@
import { TWorkspaceUser } from "@app/hooks/api/users/types";
export const getMemberLabel = (member: TWorkspaceUser) => {
const {
inviteEmail,
user: { firstName, lastName, username, email }
} = member;
return firstName || lastName
? `${firstName ?? ""} ${lastName ?? ""}`.trim()
: username || email || inviteEmail;
};

View File

@@ -1,4 +1,4 @@
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { ProjectMembershipRole, TOrgRole } from "@app/hooks/api/roles/types";
enum OrgMembershipRole {
Admin = "admin",
@@ -23,3 +23,8 @@ export const formatProjectRoleName = (name: string) => {
export const isCustomProjectRole = (slug: string) =>
!Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole);
export const findOrgMembershipRole = (roles: TOrgRole[], roleIdOrSlug: string) =>
isCustomOrgRole(roleIdOrSlug)
? roles.find((r) => r.id === roleIdOrSlug)
: roles.find((r) => r.slug === roleIdOrSlug);

View File

@@ -177,11 +177,21 @@ export const useGetProjectSecretsOverview = (
}),
onError: (error) => {
if (axios.isAxiosError(error)) {
const serverResponse = error.response?.data as { message: string };
const { message, requestId } = error.response?.data as {
message: string;
requestId: string;
};
createNotification({
title: "Error fetching secret details",
type: "error",
text: serverResponse.message
text: message,
copyActions: [
{
value: requestId,
name: "Request ID",
label: `Request ID: ${requestId}`
}
]
});
}
},
@@ -270,11 +280,21 @@ export const useGetProjectSecretsDetails = (
}),
onError: (error) => {
if (axios.isAxiosError(error)) {
const serverResponse = error.response?.data as { message: string };
const { message, requestId } = error.response?.data as {
message: string;
requestId: string;
};
createNotification({
title: "Error fetching secret details",
type: "error",
text: serverResponse.message
text: message,
copyActions: [
{
value: requestId,
name: "Request ID",
label: `Request ID: ${requestId}`
}
]
});
}
},
@@ -355,11 +375,21 @@ export const useGetProjectSecretsQuickSearch = (
}),
onError: (error) => {
if (axios.isAxiosError(error)) {
const serverResponse = error.response?.data as { message: string };
const { message, requestId } = error.response?.data as {
message: string;
requestId: string;
};
createNotification({
title: "Error fetching secrets deep search",
type: "error",
text: serverResponse.message
text: message,
copyActions: [
{
value: requestId,
name: "Request ID",
label: `Request ID: ${requestId}`
}
]
});
}
},

View File

@@ -1,5 +1,6 @@
export {
useAddExternalKms,
useExternalKmsFetchGcpKeys,
useLoadProjectKmsBackup,
useRemoveExternalKms,
useUpdateExternalKms,

View File

@@ -3,7 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { kmsKeys } from "./queries";
import { AddExternalKmsType, KmsType } from "./types";
import {
AddExternalKmsType,
ExternalKmsGcpSchemaType,
KmsGcpKeyFetchAuthType,
KmsType,
UpdateExternalKmsType
} from "./types";
export const useAddExternalKms = (orgId: string) => {
const queryClient = useQueryClient();
@@ -33,7 +39,7 @@ export const useUpdateExternalKms = (orgId: string) => {
provider
}: {
kmsId: string;
} & AddExternalKmsType) => {
} & UpdateExternalKmsType) => {
const { data } = await apiRequest.patch(`/api/v1/external-kms/${kmsId}`, {
name,
description,
@@ -96,3 +102,44 @@ export const useLoadProjectKmsBackup = (projectId: string) => {
}
});
};
export const useExternalKmsFetchGcpKeys = (orgId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
gcpRegion,
...rest
}: Pick<ExternalKmsGcpSchemaType, "gcpRegion"> &
(
| (Pick<ExternalKmsGcpSchemaType, KmsGcpKeyFetchAuthType.Credential> & {
[KmsGcpKeyFetchAuthType.Kms]?: never;
})
| {
[KmsGcpKeyFetchAuthType.Kms]: string;
[KmsGcpKeyFetchAuthType.Credential]?: never;
}
)): Promise<{ keys: string[] }> => {
const {
[KmsGcpKeyFetchAuthType.Credential]: credential,
[KmsGcpKeyFetchAuthType.Kms]: kmsId
} = rest;
if ((credential && kmsId) || (!credential && !kmsId)) {
throw new Error(
`Either '${KmsGcpKeyFetchAuthType.Credential}' or '${KmsGcpKeyFetchAuthType.Kms}' must be provided, but not both.`
);
}
const { data } = await apiRequest.post("/api/v1/external-kms/gcp/keys", {
authMethod: credential ? KmsGcpKeyFetchAuthType.Credential : KmsGcpKeyFetchAuthType.Kms,
region: gcpRegion,
...rest
});
return data;
},
onSuccess: () => {
queryClient.invalidateQueries(kmsKeys.getExternalKmsList(orgId));
}
});
};

View File

@@ -35,7 +35,8 @@ export enum KmsType {
}
export enum ExternalKmsProvider {
AWS = "aws"
Aws = "aws",
Gcp = "gcp"
}
export const INTERNAL_KMS_KEY_ID = "internal";
@@ -44,6 +45,10 @@ export enum KmsAwsCredentialType {
AssumeRole = "assume-role",
AccessKey = "access-key"
}
// Google uses snake_case for their enum values and we need to match that
export enum KmsGcpCredentialType {
ServiceAccount = "service_account"
}
export const ExternalKmsAwsSchema = z.object({
credential: z
@@ -83,8 +88,34 @@ export const ExternalKmsAwsSchema = z.object({
)
});
export const ExternalKmsGcpCredentialSchema = z.object({
type: z.literal(KmsGcpCredentialType.ServiceAccount),
project_id: z.string().min(1),
private_key_id: z.string().min(1),
private_key: z.string().min(1),
client_email: z.string().min(1),
client_id: z.string().min(1),
auth_uri: z.string().min(1),
token_uri: z.string().min(1),
auth_provider_x509_cert_url: z.string().min(1),
client_x509_cert_url: z.string().min(1),
universe_domain: z.string().min(1)
});
export type ExternalKmsGcpCredentialSchemaType = z.infer<typeof ExternalKmsGcpCredentialSchema>;
export const ExternalKmsGcpSchema = z.object({
credential: ExternalKmsGcpCredentialSchema.describe(
"GCP Service Account JSON credential to connect"
),
gcpRegion: z.string().min(1).trim().describe("GCP region where the KMS key is located"),
keyName: z.string().min(1).trim().describe("GCP key name")
});
export type ExternalKmsGcpSchemaType = z.infer<typeof ExternalKmsGcpSchema>;
export const ExternalKmsInputSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal(ExternalKmsProvider.AWS), inputs: ExternalKmsAwsSchema })
z.object({ type: z.literal(ExternalKmsProvider.Aws), inputs: ExternalKmsAwsSchema }),
z.object({ type: z.literal(ExternalKmsProvider.Gcp), inputs: ExternalKmsGcpSchema })
]);
export const AddExternalKmsSchema = z.object({
@@ -100,3 +131,71 @@ export const AddExternalKmsSchema = z.object({
});
export type AddExternalKmsType = z.infer<typeof AddExternalKmsSchema>;
// we need separate schema for update because the credential field is not required on GCP
export const ExternalKmsUpdateInputSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal(ExternalKmsProvider.Aws), inputs: ExternalKmsAwsSchema }),
z.object({
type: z.literal(ExternalKmsProvider.Gcp),
inputs: ExternalKmsGcpSchema.pick({ gcpRegion: true, keyName: true })
})
]);
export const UpdateExternalKmsSchema = z.object({
name: z
.string()
.trim()
.min(1)
.refine((v) => slugify(v) === v, {
message: "Alias must be a valid slug"
}),
description: z.string().trim().optional(),
provider: ExternalKmsUpdateInputSchema
});
export type UpdateExternalKmsType = z.infer<typeof UpdateExternalKmsSchema>;
const GCP_CREDENTIAL_MAX_FILE_SIZE = 8 * 1024; // 8KB
const GCP_CREDENTIAL_ACCEPTED_FILE_TYPES = ["application/json"];
const AddExternalKmsGcpFormSchemaStandardInputs = z.object({
keyObject: z
.object({ label: z.string().trim(), value: z.string().trim() })
.describe("GCP key name"),
gcpRegion: z.object({ label: z.string().trim(), value: z.string().trim() }).describe("GCP Region")
});
export const AddExternalKmsGcpFormSchema = z.discriminatedUnion("formType", [
z
.object({
formType: z.literal("newGcpKms"),
// `FileList` is a browser-only (window-specific) type, so we need to handle it differently on the server to avoid SSR errors
credentialFile:
typeof window === "undefined"
? z.any()
: z
.instanceof(FileList)
.refine((files) => files?.length === 1, "Image is required.")
.refine(
(files) => files?.[0]?.size <= GCP_CREDENTIAL_MAX_FILE_SIZE,
"Max file size is 8KB."
)
.refine(
(files) => GCP_CREDENTIAL_ACCEPTED_FILE_TYPES.includes(files?.[0]?.type),
"Only .json files are accepted."
)
})
.merge(AddExternalKmsGcpFormSchemaStandardInputs)
.merge(AddExternalKmsSchema.pick({ name: true, description: true })),
z
.object({ formType: z.literal("updateGcpKms") })
.merge(AddExternalKmsGcpFormSchemaStandardInputs)
.merge(AddExternalKmsSchema.pick({ name: true, description: true }))
]);
export type AddExternalKmsGcpFormSchemaType = z.infer<typeof AddExternalKmsGcpFormSchema>;
export enum KmsGcpKeyFetchAuthType {
Credential = "credential",
Kms = "kmsId"
}

View File

@@ -117,11 +117,21 @@ export const useGetProjectSecrets = ({
queryFn: () => fetchProjectSecrets({ workspaceId, environment, secretPath }),
onError: (error) => {
if (axios.isAxiosError(error)) {
const serverResponse = error.response?.data as { message: string };
const { message, requestId } = error.response?.data as {
message: string;
requestId: string;
};
createNotification({
title: "Error fetching secrets",
type: "error",
text: serverResponse.message
text: message,
copyActions: [
{
value: requestId,
name: "Request ID",
label: `Request ID: ${requestId}`
}
]
});
}
},
@@ -148,15 +158,24 @@ export const useGetProjectSecretsAllEnv = ({
enabled: Boolean(workspaceId && environment),
onError: (error: unknown) => {
if (axios.isAxiosError(error) && !isErrorHandled) {
const serverResponse = error.response?.data as { message: string };
if (serverResponse.message !== ERROR_NOT_ALLOWED_READ_SECRETS) {
const { message, requestId } = error.response?.data as {
message: string;
requestId: string;
};
if (message !== ERROR_NOT_ALLOWED_READ_SECRETS) {
createNotification({
title: "Error fetching secrets",
type: "error",
text: serverResponse.message
text: message,
copyActions: [
{
value: requestId,
name: "Request ID",
label: `Request ID: ${requestId}`
}
]
});
}
setIsErrorHandled.on();
}
},

View File

@@ -51,26 +51,26 @@ export enum ApiErrorTypes {
export type TApiErrors =
| {
requestId: string;
reqId: string;
error: ApiErrorTypes.ValidationError;
message: ZodIssue[];
statusCode: 401;
statusCode: 422;
}
| {
requestId: string;
reqId: string;
error: ApiErrorTypes.UnauthorizedError;
message: string;
statusCode: 401;
}
| {
requestId: string;
reqId: string;
error: ApiErrorTypes.ForbiddenError;
message: string;
details: PureAbility["rules"];
statusCode: 403;
}
| {
requestId: string;
reqId: string;
statusCode: 400;
message: string;
error: ApiErrorTypes.BadRequestError;

View File

@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { components, MenuProps, OptionProps } from "react-select";
import { faStar } from "@fortawesome/free-regular-svg-icons";
import { faEye, faPlus, faStar as faSolidStar } from "@fortawesome/free-solid-svg-icons";
import { faChevronRight, faPlus, faStar as faSolidStar } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
@@ -93,7 +93,7 @@ const ProjectOption = ({
>
<div className="flex w-full items-center">
{isSelected && (
<FontAwesomeIcon className="mr-2 text-mineshaft-300" icon={faEye} size="sm" />
<FontAwesomeIcon className="mr-2 text-primary" icon={faChevronRight} size="xs" />
)}
<p className="truncate">{children}</p>
{data.isFavorite ? (

View File

@@ -13,6 +13,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useGetCloudIntegrations, useSaveIntegrationAccessToken } from "@app/hooks/api";
import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils";
import { Button, Card, CardTitle, FormControl, TextArea } from "../../../components/v2";
@@ -46,6 +47,11 @@ export default function GCPSecretManagerAuthorizeIntegrationPage() {
const state = crypto.randomBytes(16).toString("hex");
localStorage.setItem("latestCSRFToken", state);
if (!integrationOption.clientId) {
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
return;
}
const link = `https://accounts.google.com/o/oauth2/auth?scope=https://www.googleapis.com/auth/cloud-platform&response_type=code&access_type=offline&state=${state}&redirect_uri=${window.location.origin}/integrations/gcp-secret-manager/oauth2/callback&client_id=${integrationOption.clientId}`;
window.location.assign(link);
};

View File

@@ -18,6 +18,7 @@ import {
SelectItem
} from "@app/components/v2";
import { useGetCloudIntegrations } from "@app/hooks/api";
import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils";
enum AuthMethod {
APP = "APP",
@@ -84,6 +85,15 @@ export default function GithubIntegrationAuthModeSelectionPage() {
if (selectedAuthMethod === AuthMethod.APP) {
router.push("/integrations/select-integration-auth?integrationSlug=github");
} else {
if (!githubIntegration?.clientId) {
createIntegrationMissingEnvVarsNotification(
"githubactions",
"cicd",
"connecting-with-github-oauth"
);
return;
}
const state = crypto.randomBytes(16).toString("hex");
localStorage.setItem("latestCSRFToken", state);

View File

@@ -10,6 +10,7 @@ import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { useGetCloudIntegrations } from "@app/hooks/api";
import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils";
import { Button, Card, CardTitle, FormControl, Input } from "../../../components/v2";
@@ -37,6 +38,11 @@ export default function GitLabAuthorizeIntegrationPage() {
if (!integrationOption) return;
if (!integrationOption.clientId) {
createIntegrationMissingEnvVarsNotification(integrationOption.slug, "cicd");
return;
}
const baseURL =
(gitLabURL as string).trim() === "" ? "https://gitlab.com" : (gitLabURL as string).trim();

View File

@@ -13,6 +13,7 @@ import {
useGetOrgIntegrationAuths
} from "@app/hooks/api";
import { IntegrationAuth } from "@app/hooks/api/types";
import { createIntegrationMissingEnvVarsNotification } from "@app/views/IntegrationsPage/IntegrationPage.utils";
export default function SelectIntegrationAuthPage() {
const router = useRouter();
@@ -86,6 +87,11 @@ export default function SelectIntegrationAuthPage() {
localStorage.setItem("latestCSRFToken", state);
if (integrationSlug === "github") {
if (!currentIntegration?.clientSlug) {
createIntegrationMissingEnvVarsNotification("githubactions", "cicd");
return;
}
// for now we only handle Github apps
window.location.assign(
`https://github.com/apps/${currentIntegration?.clientSlug}/installations/new?state=${state}`

View File

@@ -876,7 +876,7 @@ const OrganizationPage = () => {
<Pagination
className={
projectsViewMode === ProjectsViewMode.GRID
? "col-span-full border-transparent bg-transparent"
? "col-span-full !justify-start border-transparent bg-transparent pl-2"
: "rounded-b-md border border-mineshaft-600"
}
perPage={perPage}

View File

@@ -32,13 +32,8 @@ export const queryClient = new QueryClient({
{
title: "Validation Error",
type: "error",
text: (
<div>
<p>Please check the input and try again.</p>
<p className="mt-2 text-xs">Request ID: {serverResponse.requestId}</p>
</div>
),
children: (
text: "Please check the input and try again.",
callToAction: (
<Modal>
<ModalTrigger>
<Button variant="outline_bg" size="xs">
@@ -66,7 +61,14 @@ export const queryClient = new QueryClient({
</TableContainer>
</ModalContent>
</Modal>
)
),
copyActions: [
{
value: serverResponse.reqId,
name: "Request ID",
label: `Request ID: ${serverResponse.reqId}`
}
]
},
{ closeOnClick: false }
);
@@ -77,9 +79,8 @@ export const queryClient = new QueryClient({
{
title: "Forbidden Access",
type: "error",
text: `${serverResponse.message} [requestId=${serverResponse.requestId}]`,
children: serverResponse?.details?.length ? (
text: `${serverResponse.message}.`,
callToAction: serverResponse?.details?.length ? (
<Modal>
<ModalTrigger>
<Button variant="outline_bg" size="xs">
@@ -92,7 +93,7 @@ export const queryClient = new QueryClient({
>
<div className="flex flex-col gap-2">
{serverResponse.details?.map((el, index) => {
const hasConditions = Object.keys(el.conditions || {}).length;
const hasConditions = Boolean(Object.keys(el.conditions || {}).length);
return (
<div
key={`Forbidden-error-details-${index + 1}`}
@@ -165,7 +166,14 @@ export const queryClient = new QueryClient({
</div>
</ModalContent>
</Modal>
) : undefined
) : undefined,
copyActions: [
{
value: serverResponse.reqId,
name: "Request ID",
label: `Request ID: ${serverResponse.reqId}`
}
]
},
{ closeOnClick: false }
);
@@ -174,7 +182,14 @@ export const queryClient = new QueryClient({
createNotification({
title: "Bad Request",
type: "error",
text: `${serverResponse.message} [requestId=${serverResponse.requestId}]`
text: `${serverResponse.message}.`,
copyActions: [
{
value: serverResponse.reqId,
name: "Request ID",
label: `Request ID: ${serverResponse.reqId}`
}
]
});
}
}

View File

@@ -1,5 +1,6 @@
import crypto from "crypto";
import { createNotification } from "@app/components/notifications";
import { TCloudIntegration, UserWsKeyPair } from "@app/hooks/api/types";
import {
@@ -30,6 +31,28 @@ export const generateBotKey = (botPublicKey: string, latestKey: UserWsKeyPair) =
return { encryptedKey: ciphertext, nonce };
};
export const createIntegrationMissingEnvVarsNotification = (
slug: string,
type: "cloud" | "cicd" = "cloud",
hashtag?: string
) =>
createNotification({
type: "error",
text: (
<a
href={`https://infisical.com/docs/integrations/${type}/${slug}${
hashtag ? `#${hashtag}` : ""
}`}
target="_blank"
rel="noreferrer"
className="underline"
>
Click here to view docs
</a>
),
title: "Missing Environment Variables"
});
export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => {
try {
// generate CSRF token for OAuth2 code-token exchange integrations
@@ -42,9 +65,17 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) =>
link = `${window.location.origin}/integrations/gcp-secret-manager/authorize`;
break;
case "azure-key-vault":
if (!integrationOption.clientId) {
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
return;
}
link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-key-vault/oauth2/callback&response_mode=query&scope=https://vault.azure.net/.default openid offline_access&state=${state}`;
break;
case "azure-app-configuration":
if (!integrationOption.clientId) {
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
return;
}
link = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/azure-app-configuration/oauth2/callback&response_mode=query&scope=https://azconfig.io/.default openid offline_access&state=${state}`;
break;
case "aws-parameter-store":
@@ -54,12 +85,24 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) =>
link = `${window.location.origin}/integrations/aws-secret-manager/authorize`;
break;
case "heroku":
if (!integrationOption.clientId) {
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
return;
}
link = `https://id.heroku.com/oauth/authorize?client_id=${integrationOption.clientId}&response_type=code&scope=write-protected&state=${state}`;
break;
case "vercel":
if (!integrationOption.clientSlug) {
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
return;
}
link = `https://vercel.com/integrations/${integrationOption.clientSlug}/new?state=${state}`;
break;
case "netlify":
if (!integrationOption.clientId) {
createIntegrationMissingEnvVarsNotification(integrationOption.slug);
return;
}
link = `https://app.netlify.com/authorize?client_id=${integrationOption.clientId}&response_type=code&state=${state}&redirect_uri=${window.location.origin}/integrations/netlify/oauth2/callback`;
break;
case "github":
@@ -111,6 +154,10 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) =>
link = `${window.location.origin}/integrations/cloudflare-workers/authorize`;
break;
case "bitbucket":
if (!integrationOption.clientId) {
createIntegrationMissingEnvVarsNotification(integrationOption.slug, "cicd");
return;
}
link = `https://bitbucket.org/site/oauth2/authorize?client_id=${integrationOption.clientId}&response_type=code&redirect_uri=${window.location.origin}/integrations/bitbucket/oauth2/callback&state=${state}`;
break;
case "codefresh":

View File

@@ -1,6 +1,8 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { motion } from "framer-motion";
import { createNotification } from "@app/components/notifications";
import { ContentLoader } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import {
@@ -28,11 +30,17 @@ type Props = {
}>;
};
enum IntegrationView {
List = "list",
New = "new"
}
export const IntegrationsPage = withProjectPermission(
({ frameworkIntegrations, infrastructureIntegrations }: Props) => {
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const environments = currentWorkspace?.environments || [];
const [view, setView] = useState<IntegrationView>(IntegrationView.New);
const { data: cloudIntegrations, isLoading: isCloudIntegrationsLoading } =
useGetCloudIntegrations();
@@ -56,7 +64,8 @@ export const IntegrationsPage = withProjectPermission(
const {
data: integrations,
isLoading: isIntegrationLoading,
isFetching: isIntegrationFetching
isFetching: isIntegrationFetching,
isFetched: isIntegrationsFetched
} = useGetWorkspaceIntegrations(workspaceId);
const { mutateAsync: deleteIntegration } = useDeleteIntegration();
@@ -89,6 +98,10 @@ export const IntegrationsPage = withProjectPermission(
isIntegrationsEmpty
]);
useEffect(() => {
setView(integrations?.length ? IntegrationView.List : IntegrationView.New);
}, [isIntegrationsFetched]);
const handleProviderIntegration = async (provider: string) => {
const selectedCloudIntegration = cloudIntegrations?.find(({ slug }) => provider === slug);
if (!selectedCloudIntegration) return;
@@ -150,26 +163,64 @@ export const IntegrationsPage = withProjectPermission(
}
};
if (isIntegrationLoading || isCloudIntegrationsLoading)
return (
<div className="flex flex-col items-center gap-2">
<ContentLoader text={["Loading integrations..."]} />
</div>
);
return (
<div className="container mx-auto max-w-7xl pb-12 text-white">
<IntegrationsSection
isLoading={isIntegrationLoading}
integrations={integrations}
environments={environments}
onIntegrationDelete={handleIntegrationDelete}
workspaceId={workspaceId}
/>
<CloudIntegrationSection
isLoading={isCloudIntegrationsLoading || isIntegrationAuthLoading}
cloudIntegrations={cloudIntegrations}
integrationAuths={integrationAuths}
onIntegrationStart={handleProviderIntegrationStart}
onIntegrationRevoke={handleIntegrationAuthRevoke}
/>
<FrameworkIntegrationSection frameworks={frameworkIntegrations} />
<InfrastructureIntegrationSection integrations={infrastructureIntegrations} />
<div className="container relative mx-auto max-w-7xl pb-12 text-white">
<div className="relative">
{view === IntegrationView.List ? (
<motion.div
key="view-integrations"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="w-full"
>
<IntegrationsSection
cloudIntegrations={cloudIntegrations}
onAddIntegration={() => setView(IntegrationView.New)}
isLoading={isIntegrationLoading}
integrations={integrations}
environments={environments}
onIntegrationDelete={handleIntegrationDelete}
workspaceId={workspaceId}
/>
</motion.div>
) : (
<motion.div
key="add-integration"
transition={{ duration: 0.3 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
className="w-full"
>
<CloudIntegrationSection
onViewActiveIntegrations={
integrations?.length ? () => setView(IntegrationView.List) : undefined
}
isLoading={isCloudIntegrationsLoading || isIntegrationAuthLoading}
cloudIntegrations={cloudIntegrations}
integrationAuths={integrationAuths}
onIntegrationStart={handleProviderIntegrationStart}
onIntegrationRevoke={handleIntegrationAuthRevoke}
/>
<FrameworkIntegrationSection frameworks={frameworkIntegrations} />
<InfrastructureIntegrationSection integrations={infrastructureIntegrations} />
</motion.div>
)}
</div>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Integrations }
{
action: ProjectPermissionActions.Read,
subject: ProjectPermissionSub.Integrations
}
);

View File

@@ -1,11 +1,24 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { faCheck, faXmark } from "@fortawesome/free-solid-svg-icons";
import {
faCheck,
faChevronLeft,
faMagnifyingGlass,
faSearch,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { NoEnvironmentsBanner } from "@app/components/integrations/NoEnvironmentsBanner";
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal, Skeleton, Tooltip } from "@app/components/v2";
import {
Button,
DeleteActionModal,
EmptyState,
Input,
Skeleton,
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
@@ -22,6 +35,7 @@ type Props = {
onIntegrationStart: (slug: string) => void;
// cb: handle popUpClose child->parent communication pattern
onIntegrationRevoke: (slug: string, cb: () => void) => void;
onViewActiveIntegrations?: () => void;
};
type TRevokeIntegrationPopUp = { provider: string };
@@ -31,7 +45,8 @@ export const CloudIntegrationSection = ({
cloudIntegrations = [],
integrationAuths = {},
onIntegrationStart,
onIntegrationRevoke
onIntegrationRevoke,
onViewActiveIntegrations
}: Props) => {
const { t } = useTranslation();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
@@ -52,6 +67,12 @@ export const CloudIntegrationSection = ({
return sortedIntegrations;
}, [cloudIntegrations, currentWorkspace?.environments]);
const [search, setSearch] = useState("");
const filteredIntegrations = sortedCloudIntegrations?.filter((cloudIntegration) =>
cloudIntegration.name.toLowerCase().includes(search.toLowerCase().trim())
);
return (
<div>
<div className="px-5">
@@ -59,18 +80,38 @@ export const CloudIntegrationSection = ({
<NoEnvironmentsBanner projectId={currentWorkspace.id} />
)}
</div>
<div className="m-4 mt-7 flex max-w-5xl flex-col items-start justify-between px-2 text-xl">
<h1 className="text-3xl font-semibold">{t("integrations.cloud-integrations")}</h1>
<p className="text-base text-gray-400">{t("integrations.click-to-start")}</p>
<div className="m-4 mt-7 flex flex-col items-start justify-between px-2 text-xl">
{onViewActiveIntegrations && (
<Button
variant="link"
onClick={onViewActiveIntegrations}
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
>
Back to Integrations
</Button>
)}
<div className="flex w-full flex-col justify-between gap-4 whitespace-nowrap lg:flex-row lg:items-end lg:gap-8">
<div className="flex-1">
<h1 className="text-3xl font-semibold">{t("integrations.cloud-integrations")}</h1>
<p className="text-base text-gray-400">{t("integrations.click-to-start")}</p>
</div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search cloud integrations..."
containerClassName="flex-1 h-min text-base"
/>
</div>
</div>
<div className="mx-6 grid grid-cols-2 gap-4 lg:grid-cols-3 2xl:grid-cols-4">
<div className="mx-6 grid grid-cols-3 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7">
{isLoading &&
Array.from({ length: 12 }).map((_, index) => (
<Skeleton className="h-32" key={`cloud-integration-skeleton-${index + 1}`} />
))}
{!isLoading &&
sortedCloudIntegrations?.map((cloudIntegration) => (
{!isLoading && filteredIntegrations.length ? (
filteredIntegrations.map((cloudIntegration) => (
<div
onKeyDown={() => null}
role="button"
@@ -79,7 +120,7 @@ export const CloudIntegrationSection = ({
cloudIntegration.isAvailable
? "cursor-pointer duration-200 hover:bg-mineshaft-700"
: "opacity-50"
} flex h-32 flex-row items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4`}
} flex h-32 flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4`}
onClick={() => {
if (!cloudIntegration.isAvailable) return;
if (
@@ -100,11 +141,12 @@ export const CloudIntegrationSection = ({
>
<img
src={`/images/integrations/${cloudIntegration.image}`}
height={70}
width={70}
height={60}
width={60}
className="mt-auto"
alt="integration logo"
/>
<div className="ml-4 max-w-xs text-xl font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
{cloudIntegration.name}
</div>
{cloudIntegration.isAvailable &&
@@ -135,7 +177,14 @@ export const CloudIntegrationSection = ({
</div>
)}
</div>
))}
))
) : (
<EmptyState
className="col-span-full h-32 w-full rounded-md bg-transparent pt-14"
title="No cloud integrations match search..."
icon={faSearch}
/>
)}
</div>
{isEmpty && (
<div className="mx-6 grid max-w-5xl grid-cols-4 grid-rows-2 gap-4">

View File

@@ -23,34 +23,29 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => {
<h1 className="text-3xl font-semibold">{t("integrations.framework-integrations")}</h1>
<p className="text-base text-gray-400">{t("integrations.click-to-setup")}</p>
</div>
<div
className="mx-6 mt-4 grid grid-flow-dense gap-3"
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))" }}
>
<div className="mx-6 mt-4 grid grid-cols-3 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7">
{sortedFrameworks.map((framework) => (
<a
key={`framework-integration-${framework.slug}`}
href={framework.docsLink}
rel="noopener noreferrer"
target="_blank"
className="relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200"
className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700"
>
<div
className={`flex h-full w-full cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 font-semibold text-gray-300 duration-200 hover:bg-mineshaft-700 group-hover:text-gray-200 ${
framework?.name?.split(" ").length > 1 ? "px-1 text-sm" : "px-2 text-xl"
} w-full max-w-xs text-center`}
>
{framework?.image && (
<img
src={`/images/integrations/${framework.image}.png`}
height={framework?.name ? 60 : 90}
width={framework?.name ? 60 : 90}
alt="integration logo"
/>
)}
{framework?.name && framework?.image && <div className="h-2" />}
{framework?.name && framework.name}
</div>
{framework?.image && (
<img
src={`/images/integrations/${framework.image}.png`}
height={60}
width={60}
className="mt-auto"
alt="integration logo"
/>
)}
{framework?.name && (
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
{framework.name}
</div>
)}
</a>
))}
<a
@@ -58,13 +53,10 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => {
href="https://infisical.com/docs/cli/commands/run"
rel="noopener noreferrer"
target="_blank"
className="relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200"
className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700"
>
<div
className="flex h-full w-full cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 font-semibold text-gray-300 duration-200 hover:bg-mineshaft-700 group-hover:text-gray-200 px-1 text-xl w-full max-w-xs text-center"
>
<FontAwesomeIcon className="text-5xl mb-2 text-white/90" icon={faKeyboard} />
<div className="h-2" />
<FontAwesomeIcon className="mt-auto text-5xl text-white/90" icon={faKeyboard} />
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
CLI
</div>
</a>
@@ -73,13 +65,10 @@ export const FrameworkIntegrationSection = ({ frameworks }: Props) => {
href="https://infisical.com/docs/sdks/overview"
rel="noopener noreferrer"
target="_blank"
className="relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md p-0.5 duration-200"
className="relative flex h-32 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 duration-200 hover:bg-mineshaft-700"
>
<div
className="flex h-full w-full cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 font-semibold text-gray-300 duration-200 hover:bg-mineshaft-700 group-hover:text-gray-200 px-1 text-xl w-full max-w-xs text-center"
>
<FontAwesomeIcon className="text-5xl mb-1 text-white/90" icon={faComputer} />
<div className="h-2" />
<FontAwesomeIcon className="mt-auto text-5xl text-white/90" icon={faComputer} />
<div className="mt-auto max-w-xs text-center text-sm font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
SDKs
</div>
</a>

View File

@@ -1,291 +0,0 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
import { useRouter } from "next/router";
import {
faArrowRight,
faCalendarCheck,
faEllipsis,
faRefresh,
faWarning,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { integrationSlugNameMapping } from "public/data/frequentConstants";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Badge, FormLabel, IconButton, Tooltip } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types";
import { TIntegration } from "@app/hooks/api/types";
type IProps = {
integration: TIntegration;
environments: Array<{ name: string; slug: string; id: string }>;
onRemoveIntegration: VoidFunction;
onManualSyncIntegration: VoidFunction;
};
export const ConfiguredIntegrationItem = ({
integration,
environments,
onRemoveIntegration,
onManualSyncIntegration
}: IProps) => {
const router = useRouter();
return (
<div
className="max-w-8xl flex cursor-pointer justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-3 transition-all hover:bg-mineshaft-700"
onClick={() => router.push(`/integrations/details/${integration.id}`)}
key={`integration-${integration?.id.toString()}`}
>
<div className="flex">
<div className="ml-2 flex flex-col">
<FormLabel label="Environment" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{environments.find((e) => e.id === integration.envId)?.name || "-"}
</div>
</div>
<div className="ml-2 flex flex-col">
<FormLabel label="Secret Path" />
<div className="min-w-[8rem] rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.secretPath}
</div>
</div>
<div className="mt-3 flex h-full items-center">
<FontAwesomeIcon icon={faArrowRight} className="mx-4 text-gray-400" />
</div>
<div className="ml-4 flex flex-col">
<FormLabel
tooltipText={
integration.integration === "github" ? (
<div className="text-xs">
{/* eslint-disable-next-line no-nested-ternary */}
{integration.metadata?.githubVisibility === "selected"
? "Syncing to selected repositories in the organization. "
: integration.metadata?.githubVisibility === "private"
? "Syncing to all private repositories in the organization"
: "Syncing to all public and private repositories in the organization"}
</div>
) : undefined
}
label="Integration"
/>
<div className="min-w-[8rem] rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integrationSlugNameMapping[integration.integration]}
</div>
</div>
{integration.integration === "octopus-deploy" && (
<div className="ml-2 flex flex-col">
<FormLabel label="Space" />
<div className="overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
)}
{integration.integration === "qovery" && (
<div className="flex flex-row">
<div className="ml-2 flex flex-col">
<FormLabel label="Org" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration?.owner || "-"}
</div>
</div>
<div className="ml-2 flex flex-col">
<FormLabel label="Project" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration?.targetService || "-"}
</div>
</div>
<div className="ml-2 flex flex-col">
<FormLabel label="Env" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration?.targetEnvironment || "-"}
</div>
</div>
</div>
)}
{!(
integration.integration === "aws-secret-manager" &&
integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE
) && (
<div className="ml-2 flex flex-col">
<FormLabel
label={
(integration.integration === "qovery" && integration?.scope) ||
(integration.integration === "circleci" && "Project") ||
(integration.integration === "bitbucket" && "Repository") ||
(integration.integration === "octopus-deploy" && "Project") ||
(integration.integration === "aws-secret-manager" && "Secret") ||
(["aws-parameter-store", "rundeck"].includes(integration.integration) && "Path") ||
(integration?.integration === "terraform-cloud" && "Project") ||
(integration?.scope === "github-org" && "Organization") ||
(["github-repo", "github-env"].includes(integration?.scope as string) &&
"Repository") ||
"App"
}
/>
<div className="no-scrollbar::-webkit-scrollbar min-w-[8rem] max-w-[12rem] overflow-scroll whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200 no-scrollbar">
{(integration.integration === "hashicorp-vault" &&
`${integration.app} - path: ${integration.path}`) ||
(integration.scope === "github-org" && `${integration.owner}`) ||
(["aws-parameter-store", "rundeck"].includes(integration.integration) &&
`${integration.path}`) ||
(integration.scope?.startsWith("github-") &&
`${integration.owner}/${integration.app}`) ||
integration.app}
</div>
</div>
)}
{(integration.integration === "vercel" ||
integration.integration === "netlify" ||
integration.integration === "railway" ||
integration.integration === "gitlab" ||
integration.integration === "teamcity" ||
(integration.integration === "github" && integration.scope === "github-env")) && (
<div className="ml-4 flex flex-col">
<FormLabel label="Target Environment" />
<div className="overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
)}
{integration.integration === "bitbucket" && (
<>
{integration.targetServiceId && (
<div className="ml-2 flex flex-col">
<FormLabel label="Environment" />
<div className="min-w-[8rem] overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetService || integration.targetServiceId}
</div>
</div>
)}
<div className="ml-2 flex flex-col">
<FormLabel label="Workspace" />
<div className="overflow-clip text-ellipsis whitespace-nowrap rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
</>
)}
{integration.integration === "checkly" && integration.targetService && (
<div className="ml-2">
<FormLabel label="Group" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetService}
</div>
</div>
)}
{integration.integration === "circleci" && integration.owner && (
<div className="ml-2">
<FormLabel label="Organization" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.owner}
</div>
</div>
)}
{integration.integration === "terraform-cloud" && integration.targetService && (
<div className="ml-2">
<FormLabel label="Category" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration.targetService}
</div>
</div>
)}
{(integration.integration === "checkly" || integration.integration === "github") && (
<div className="ml-2">
<FormLabel label="Secret Suffix" />
<div className="rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200">
{integration?.metadata?.secretSuffix || "-"}
</div>
</div>
)}
</div>
<div className="mt-[1.5rem] flex cursor-default space-x-3">
{integration.isSynced != null && integration.lastUsed != null && (
<Badge variant={integration.isSynced ? "success" : "danger"} key={integration.id}>
<Tooltip
center
className="max-w-xs whitespace-normal break-words"
content={
<div className="flex max-h-[10rem] flex-col overflow-auto ">
<div className="flex self-start">
<FontAwesomeIcon icon={faCalendarCheck} className="pt-0.5 pr-2 text-sm" />
<div className="text-sm">Last successful sync</div>
</div>
<div className="pl-5 text-left text-xs">
{format(new Date(integration.lastUsed), "yyyy-MM-dd, hh:mm aaa")}
</div>
{!integration.isSynced && (
<>
<div className="mt-2 flex self-start">
<FontAwesomeIcon icon={faXmark} className="pt-1 pr-2 text-sm" />
<div className="text-sm">Fail reason</div>
</div>
<div className="pl-5 text-left text-xs">{integration.syncMessage}</div>
</>
)}
</div>
}
>
<div className="flex h-full items-center space-x-2">
<div>{integration.isSynced ? "Synced" : "Not synced"}</div>
{!integration.isSynced && <FontAwesomeIcon icon={faWarning} />}
</div>
</Tooltip>
</Badge>
)}
<div className="space-x-1.5">
<Tooltip className="text-center" content="Manually sync integration secrets">
<IconButton
onClick={(e) => {
e.stopPropagation();
onManualSyncIntegration();
}}
ariaLabel="sync"
colorSchema="primary"
variant="star"
className="max-w-[2.5rem] border-none bg-mineshaft-500"
>
<FontAwesomeIcon icon={faRefresh} className="px-1" />
</IconButton>
</Tooltip>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Integrations}
>
{(isAllowed: boolean) => (
<Tooltip content="Remove Integration">
<IconButton
onClick={(e) => {
e.stopPropagation();
onRemoveIntegration();
}}
ariaLabel="delete"
isDisabled={!isAllowed}
colorSchema="danger"
variant="star"
className="max-w-[2.5rem] border-none bg-mineshaft-500"
>
<FontAwesomeIcon icon={faXmark} className="px-1" />
</IconButton>
</Tooltip>
)}
</ProjectPermissionCan>
<Tooltip content="View details">
<IconButton
ariaLabel="delete"
colorSchema="primary"
variant="star"
className="max-w-[2.5rem] border-none bg-mineshaft-500"
>
<FontAwesomeIcon icon={faEllipsis} className="px-1" />
</IconButton>
</Tooltip>
</div>
</div>
</div>
);
};

View File

@@ -1,13 +1,16 @@
import { Checkbox, DeleteActionModal, EmptyState, Skeleton } from "@app/components/v2";
import { usePopUp, useToggle } from "@app/hooks";
import { useSyncIntegration } from "@app/hooks/api/integrations/queries";
import { TIntegration } from "@app/hooks/api/types";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ConfiguredIntegrationItem } from "./ConfiguredIntegrationItem";
import { Button, Checkbox, DeleteActionModal } from "@app/components/v2";
import { usePopUp, useToggle } from "@app/hooks";
import { TCloudIntegration, TIntegration } from "@app/hooks/api/types";
import { IntegrationsTable } from "./components";
type Props = {
environments: Array<{ name: string; slug: string; id: string }>;
integrations?: TIntegration[];
cloudIntegrations?: TCloudIntegration[];
isLoading?: boolean;
onIntegrationDelete: (
integrationId: string,
@@ -15,6 +18,7 @@ type Props = {
cb: () => void
) => Promise<void>;
workspaceId: string;
onAddIntegration: () => void;
};
export const IntegrationsSection = ({
@@ -22,58 +26,47 @@ export const IntegrationsSection = ({
environments = [],
isLoading,
onIntegrationDelete,
workspaceId
workspaceId,
onAddIntegration,
cloudIntegrations = []
}: Props) => {
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"deleteConfirmation",
"deleteSecretsConfirmation"
] as const);
const { mutate: syncIntegration } = useSyncIntegration();
const [shouldDeleteSecrets, setShouldDeleteSecrets] = useToggle(false);
return (
<div className="mb-8">
<div className="mx-4 mb-4 mt-6 flex flex-col items-start justify-between px-2 text-xl">
<h1 className="text-3xl font-semibold">Current Integrations</h1>
<div className="mx-6 mb-8">
<div className="mb-4 mt-6 flex flex-col items-start justify-between px-2 text-xl">
<h1 className="text-3xl font-semibold">Integrations</h1>
<p className="text-base text-bunker-300">Manage integrations with third-party services.</p>
</div>
{isLoading && (
<div className="p-6 pt-0">
<Skeleton className="h-28" />
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Active Integrations</p>
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={onAddIntegration}
>
Add Integration
</Button>
</div>
)}
{!isLoading && !integrations.length && (
<div className="mx-6">
<EmptyState
className="rounded-md border border-mineshaft-700 pt-8 pb-4"
title="No integrations found. Click on one of the below providers to sync secrets."
/>
</div>
)}
{!isLoading && (
<div className="flex min-w-max flex-col space-y-4 p-6 pt-0">
{integrations?.map((integration) => (
<ConfiguredIntegrationItem
key={`integration-${integration.id}`}
onManualSyncIntegration={() => {
syncIntegration({
workspaceId,
id: integration.id,
lastUsed: integration.lastUsed as string
});
}}
onRemoveIntegration={() => {
setShouldDeleteSecrets.off();
handlePopUpOpen("deleteConfirmation", integration);
}}
integration={integration}
environments={environments}
/>
))}
</div>
)}
<IntegrationsTable
cloudIntegrations={cloudIntegrations}
integrations={integrations}
isLoading={isLoading}
workspaceId={workspaceId}
environments={environments}
onDeleteIntegration={(integration) => {
setShouldDeleteSecrets.off();
handlePopUpOpen("deleteConfirmation", integration);
}}
/>
</div>
<DeleteActionModal
isOpen={popUp.deleteConfirmation.isOpen}
title={`Are you sure want to remove ${

View File

@@ -0,0 +1,138 @@
import { FormLabel } from "@app/components/v2";
import { IntegrationMappingBehavior, TIntegration } from "@app/hooks/api/integrations/types";
type Props = {
integration: TIntegration;
};
const FIELD_CLASSNAME =
"truncate rounded-md border border-mineshaft-700 bg-mineshaft-900 px-3 py-2 font-inter text-sm text-bunker-200";
export const getIntegrationDestination = (integration: TIntegration) =>
(integration.integration === "hashicorp-vault" &&
`${integration.app} - path: ${integration.path}`) ||
(integration.scope === "github-org" && `${integration.owner}`) ||
(["aws-parameter-store", "rundeck"].includes(integration.integration) && `${integration.path}`) ||
(integration.scope?.startsWith("github-") && `${integration.owner}/${integration.app}`) ||
integration.app ||
"-";
export const IntegrationDetails = ({ integration }: Props) => {
return (
<div className="flex flex-col gap-2 p-2">
{integration.integration === "octopus-deploy" && (
<div>
<FormLabel label="Space" />
<div className={FIELD_CLASSNAME}>
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
)}
{integration.integration === "qovery" && (
<>
<div>
<FormLabel label="Org" />
<div className={FIELD_CLASSNAME}>{integration?.owner || "-"}</div>
</div>
<div>
<FormLabel label="Project" />
<div className={FIELD_CLASSNAME}>{integration?.targetService || "-"}</div>
</div>
<div>
<FormLabel label="Env" />
<div className={FIELD_CLASSNAME}>{integration?.targetEnvironment || "-"}</div>
</div>
</>
)}
{!(
integration.integration === "aws-secret-manager" &&
integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE
) && (
<div>
<FormLabel
label={
(integration.integration === "qovery" && integration?.scope) ||
(integration.integration === "circleci" && "Project") ||
(integration.integration === "bitbucket" && "Repository") ||
(integration.integration === "octopus-deploy" && "Project") ||
(integration.integration === "aws-secret-manager" && "Secret") ||
(["aws-parameter-store", "rundeck"].includes(integration.integration) && "Path") ||
(integration?.integration === "terraform-cloud" && "Project") ||
(integration?.scope === "github-org" && "Organization") ||
(["github-repo", "github-env"].includes(integration?.scope as string) &&
"Repository") ||
"App"
}
/>
<div className={FIELD_CLASSNAME}>{getIntegrationDestination(integration)}</div>
</div>
)}
{(integration.integration === "vercel" ||
integration.integration === "netlify" ||
integration.integration === "railway" ||
integration.integration === "gitlab" ||
integration.integration === "teamcity" ||
(integration.integration === "github" && integration.scope === "github-env")) && (
<div>
<FormLabel label="Target Environment" />
<div className={FIELD_CLASSNAME}>
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
)}
{integration.integration === "bitbucket" && (
<>
{integration.targetServiceId && (
<div>
<FormLabel label="Environment" />
<div className={FIELD_CLASSNAME}>
{integration.targetService || integration.targetServiceId}
</div>
</div>
)}
<div>
<FormLabel label="Workspace" />
<div className={FIELD_CLASSNAME}>
{integration.targetEnvironment || integration.targetEnvironmentId}
</div>
</div>
</>
)}
{integration.integration === "checkly" && integration.targetService && (
<div>
<FormLabel label="Group" />
<div className={FIELD_CLASSNAME}>{integration.targetService}</div>
</div>
)}
{integration.integration === "circleci" && integration.owner && (
<div>
<FormLabel label="Organization" />
<div className={FIELD_CLASSNAME}>{integration.owner}</div>
</div>
)}
{integration.integration === "terraform-cloud" && integration.targetService && (
<div>
<FormLabel label="Category" />
<div className={FIELD_CLASSNAME}>{integration.targetService}</div>
</div>
)}
{(integration.integration === "checkly" || integration.integration === "github") &&
integration?.metadata?.secretSuffix && (
<div>
<FormLabel label="Secret Suffix" />
<div className={FIELD_CLASSNAME}>{integration.metadata.secretSuffix}</div>
</div>
)}
{integration.integration === "github" && integration.metadata?.githubVisibility ? (
<div className="mt-2 text-xs text-mineshaft-200">
{/* eslint-disable-next-line no-nested-ternary */}
{integration.metadata?.githubVisibility === "selected"
? "* Syncing to selected repositories in the organization. "
: integration.metadata?.githubVisibility === "private"
? "* Syncing to all private repositories in the organization"
: "* Syncing to all public and private repositories in the organization"}
</div>
) : undefined}
</div>
);
};

View File

@@ -0,0 +1,185 @@
import { useMemo } from "react";
import { useRouter } from "next/router";
import {
faCalendarCheck,
faCheck,
faInfoCircle,
faRefresh,
faTrash,
faWarning,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Badge, IconButton, Td, Tooltip, Tr } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { TCloudIntegration } from "@app/hooks/api/integrations/types";
import { TIntegration } from "@app/hooks/api/types";
import { getIntegrationDestination, IntegrationDetails } from "./IntegrationDetails";
type IProps = {
integration: TIntegration;
environment?: { name: string; slug: string; id: string };
onRemoveIntegration: VoidFunction;
onManualSyncIntegration: VoidFunction;
cloudIntegration: TCloudIntegration;
};
export const IntegrationRow = ({
integration,
environment,
onRemoveIntegration,
onManualSyncIntegration,
cloudIntegration
}: IProps) => {
const router = useRouter();
const { id, secretPath, syncMessage, isSynced } = integration;
const failureMessage = useMemo(() => {
if (isSynced === false) {
if (syncMessage)
try {
return JSON.stringify(JSON.parse(syncMessage), null, 2);
} catch (e) {
return syncMessage;
}
return "An Unknown Error Occurred.";
}
return null;
}, [isSynced, syncMessage]);
return (
<Tr
onClick={() => router.push(`/integrations/details/${integration.id}`)}
className={twMerge(
"group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700",
isSynced === false && "bg-red/5 hover:bg-red/10"
)}
key={`integration-${id}`}
>
<Td>
<div className="flex items-center gap-2">
<img
alt={`${cloudIntegration?.name} integration`}
src={`/images/integrations/${cloudIntegration?.image}`}
className="h-5 w-5"
/>
<span className="hidden lg:inline">{cloudIntegration?.name}</span>
</div>
</Td>
<Td className="!min-w-[8rem] max-w-0">
<Tooltip side="top" className="max-w-2xl break-words" content={secretPath}>
<p className="truncate">{secretPath}</p>
</Tooltip>{" "}
</Td>
<Td>{environment?.name ?? "-"}</Td>
<Td className="!min-w-[5rem] max-w-0">
<div className="flex items-center gap-2">
<p className="truncate">{getIntegrationDestination(integration)}</p>
<Tooltip
position="left"
className="min-w-[20rem] max-w-lg"
content={<IntegrationDetails integration={integration} />}
>
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
</Tooltip>
</div>
</Td>
<Td>
{" "}
{typeof integration.isSynced !== "boolean" ? (
<Badge variant="primary" key={integration.id}>
Pending Sync
</Badge>
) : (
<Tooltip
position="left"
className="max-w-sm"
content={
<div className="flex flex-col gap-2 py-1">
{integration.lastUsed && (
<div>
<div
className={`mb-2 flex self-start ${!isSynced ? "text-yellow" : "text-green"}`}
>
<FontAwesomeIcon
icon={faCalendarCheck}
className="ml-1 pt-0.5 pr-1.5 text-sm"
/>
<div className="text-xs">Last Synced</div>
</div>
<div className="rounded bg-mineshaft-600 p-2 text-xs">
{format(new Date(integration.lastUsed!), "yyyy-MM-dd, hh:mm aaa")}
</div>
</div>
)}
{failureMessage && (
<div>
<div className="mb-2 flex self-start text-red">
<FontAwesomeIcon icon={faXmark} className="ml-1 pt-0.5 pr-1.5 text-sm" />
<div className="text-xs">Failure Reason</div>
</div>
<div className="rounded bg-mineshaft-600 p-2 text-xs">{failureMessage}</div>
</div>
)}
</div>
}
>
<div className="w-min whitespace-nowrap">
<Badge variant={integration.isSynced ? "success" : "danger"} key={integration.id}>
<div className="flex items-center space-x-1">
<FontAwesomeIcon icon={integration.isSynced ? faCheck : faWarning} />
<div>{integration.isSynced ? "Synced" : "Not Synced"}</div>
</div>
</Badge>
</div>
</Tooltip>
)}
</Td>
<Td>
<div className="flex gap-2 whitespace-nowrap">
<Tooltip className="max-w-sm text-center" content="Manually Sync">
<IconButton
onClick={(e) => {
e.stopPropagation();
onManualSyncIntegration();
}}
ariaLabel="sync"
colorSchema="secondary"
variant="plain"
>
<FontAwesomeIcon icon={faRefresh} />
</IconButton>
</Tooltip>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Integrations}
>
{(isAllowed: boolean) => (
<Tooltip content="Remove Integration">
<IconButton
onClick={(e) => {
e.stopPropagation();
onRemoveIntegration();
}}
ariaLabel="delete"
isDisabled={!isAllowed}
colorSchema="danger"
variant="plain"
>
<FontAwesomeIcon icon={faTrash} className="px-1" />
</IconButton>
</Tooltip>
)}
</ProjectPermissionCan>
</div>
</Td>
</Tr>
);
};

View File

@@ -0,0 +1,448 @@
import { useEffect, useMemo, useState } from "react";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import {
faArrowDown,
faArrowUp,
faCheck,
faClock,
faFilter,
faMagnifyingGlass,
faPlug,
faSearch,
faWarning
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TBody,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { useSyncIntegration } from "@app/hooks/api/integrations/queries";
import { TCloudIntegration, TIntegration } from "@app/hooks/api/integrations/types";
import { getIntegrationDestination } from "./IntegrationDetails";
import { IntegrationRow } from "./IntegrationRow";
type Props = {
integrations?: TIntegration[];
cloudIntegrations?: TCloudIntegration[];
workspaceId: string;
isLoading?: boolean;
environments: Array<{ name: string; slug: string; id: string }>;
onDeleteIntegration: (integration: TIntegration) => void;
};
enum IntegrationsOrderBy {
App = "app",
Status = "status",
SecretPath = "secretPath",
Environment = "environment",
Destination = "destination"
}
enum IntegrationStatus {
Synced = "synced",
NotSynced = "not-synced",
PendingSync = "pending-sync"
}
type IntegrationFilters = {
environmentIds: string[];
integrations: string[];
status: IntegrationStatus[];
};
const STATUS_ICON_MAP = {
[IntegrationStatus.Synced]: { icon: faCheck, className: "text-green" },
[IntegrationStatus.NotSynced]: { icon: faWarning, className: "text-red" },
[IntegrationStatus.PendingSync]: { icon: faClock, className: "text-yellow" }
};
export const IntegrationsTable = ({
integrations = [],
cloudIntegrations = [],
workspaceId,
environments,
onDeleteIntegration,
isLoading
}: Props) => {
const { mutate: syncIntegration } = useSyncIntegration();
const initialFilters = useMemo(
() => ({
environmentIds: environments.map((env) => env.id),
integrations: [...new Set(integrations.map(({ integration }) => integration))],
status: Object.values(IntegrationStatus)
}),
[environments, integrations]
);
const [filters, setFilters] = useState<IntegrationFilters>(initialFilters);
const cloudIntegrationMap = useMemo(() => {
return new Map(
cloudIntegrations.map((cloudIntegration) => [cloudIntegration.slug, cloudIntegration])
);
}, [cloudIntegrations]);
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection,
orderBy,
setOrderDirection,
setOrderBy
} = usePagination<IntegrationsOrderBy>(IntegrationsOrderBy.App, { initPerPage: 20 });
useEffect(() => {
if (integrations?.some((integration) => integration.isSynced === false))
setOrderBy(IntegrationsOrderBy.Status);
}, []);
const environmentMap = new Map(environments.map((env) => [env.id, env]));
const filteredIntegrations = useMemo(
() =>
integrations
.filter((integration) => {
const { secretPath, envId, isSynced } = integration;
if (!filters.status.includes(IntegrationStatus.Synced) && isSynced) return false;
if (!filters.status.includes(IntegrationStatus.NotSynced) && isSynced === false)
return false;
if (
!filters.status.includes(IntegrationStatus.PendingSync) &&
typeof isSynced !== "boolean"
)
return false;
if (!filters.integrations.includes(integration.integration)) return false;
if (!filters.environmentIds.includes(envId)) return false;
return (
integration.integration
.replace("-", " ")
.toLowerCase()
.includes(search.trim().toLowerCase()) ||
secretPath.replace("-", " ").toLowerCase().includes(search.trim().toLowerCase()) ||
getIntegrationDestination(integration)
.toLowerCase()
.includes(search.trim().toLowerCase()) ||
environmentMap
.get(envId)
?.name.replace("-", " ")
.toLowerCase()
.includes(search.trim().toLowerCase())
);
})
.sort((a, b) => {
const [integrationOne, integrationTwo] =
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) {
case IntegrationsOrderBy.SecretPath:
return integrationOne.secretPath
.toLowerCase()
.localeCompare(integrationTwo.secretPath.toLowerCase());
case IntegrationsOrderBy.Environment:
return (environmentMap.get(integrationOne.envId)?.name ?? "-")
.toLowerCase()
.localeCompare(
(environmentMap.get(integrationTwo.envId)?.name ?? "-").toLowerCase()
);
case IntegrationsOrderBy.Destination:
return getIntegrationDestination(integrationOne)
.toLowerCase()
.localeCompare(getIntegrationDestination(integrationTwo).toLowerCase());
case IntegrationsOrderBy.Status:
if (typeof integrationOne.isSynced !== "boolean") return 1; // Place undefined at the end
if (typeof integrationTwo.isSynced !== "boolean") return -1;
return Number(integrationOne.isSynced) - Number(integrationTwo.isSynced);
case IntegrationsOrderBy.App:
default:
return integrationOne.integration
.toLowerCase()
.localeCompare(integrationTwo.integration.toLowerCase());
}
}),
[integrations, orderDirection, search, orderBy, filters]
);
useResetPageHelper({
totalCount: filteredIntegrations.length,
offset,
setPage
});
const handleSort = (column: IntegrationsOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
const getClassName = (col: IntegrationsOrderBy) =>
twMerge("ml-2", orderBy === col ? "" : "opacity-30");
const getColSortIcon = (col: IntegrationsOrderBy) =>
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
const isTableFiltered =
filters.integrations.length !== initialFilters.integrations.length ||
filters.environmentIds.length !== initialFilters.environmentIds.length ||
filters.status.length !== initialFilters.status.length;
return (
<div>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search integrations..."
className="flex-1"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Environments"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
isTableFiltered && "border-primary/50 text-primary"
)}
>
<Tooltip content="Filter Integrations" className="mb-2">
<FontAwesomeIcon icon={faFilter} />
</Tooltip>
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-[70vh] overflow-y-auto" align="end">
<DropdownMenuLabel>Status</DropdownMenuLabel>
{Object.values(IntegrationStatus).map((status) => (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
status: prev.status.includes(status)
? prev.status.filter((s) => s !== status)
: [...prev.status, status]
}));
}}
key={status}
icon={
filters.status.includes(status) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<FontAwesomeIcon
icon={STATUS_ICON_MAP[status].icon}
className={STATUS_ICON_MAP[status].className}
/>
<span className="capitalize">{status.replace("-", " ")}</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuLabel>Integration</DropdownMenuLabel>
{[...new Set(integrations.map(({ integration }) => integration))].map((integration) => (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
integrations: prev.integrations.includes(integration)
? prev.integrations.filter((i) => i !== integration)
: [...prev.integrations, integration]
}));
}}
key={integration}
icon={
filters.integrations.includes(integration) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<div className="flex items-center gap-2">
<img
alt={`${cloudIntegrationMap.get(integration)!.name} integration`}
src={`/images/integrations/${cloudIntegrationMap.get(integration)!.image}`}
className="h-4 w-4"
/>
<span className="capitalize">{cloudIntegrationMap.get(integration)!.name}</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuLabel>Environment</DropdownMenuLabel>
{environments.map((env) => (
<DropdownMenuItem
onClick={(e) => {
e.preventDefault();
setFilters((prev) => ({
...prev,
environmentIds: prev.environmentIds.includes(env.id)
? prev.environmentIds.filter((i) => i !== env.id)
: [...prev.environmentIds, env.id]
}));
}}
key={env.id}
icon={
filters.environmentIds.includes(env.id) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
iconPos="right"
>
<span className="capitalize">{env.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-[25%]">
<div className="flex items-center">
Integration
<IconButton
variant="plain"
className={getClassName(IntegrationsOrderBy.App)}
ariaLabel="sort"
onClick={() => handleSort(IntegrationsOrderBy.App)}
>
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.App)} />
</IconButton>
</div>
</Th>
<Th className="w-1/5">
<div className="flex items-center">
Source Path
<IconButton
variant="plain"
className={getClassName(IntegrationsOrderBy.SecretPath)}
ariaLabel="sort"
onClick={() => handleSort(IntegrationsOrderBy.SecretPath)}
>
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.SecretPath)} />
</IconButton>
</div>
</Th>
<Th className="w-1/5">
<div className="flex items-center">
Source Environment
<IconButton
variant="plain"
className={getClassName(IntegrationsOrderBy.Environment)}
ariaLabel="sort"
onClick={() => handleSort(IntegrationsOrderBy.Environment)}
>
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.Environment)} />
</IconButton>
</div>
</Th>
<Th className="w-1/5">
<div className="flex items-center">
Destination
<IconButton
variant="plain"
className={getClassName(IntegrationsOrderBy.Destination)}
ariaLabel="sort"
onClick={() => handleSort(IntegrationsOrderBy.Destination)}
>
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.Destination)} />
</IconButton>
</div>
</Th>
<Th className="w-1/5">
<div className="flex items-center">
Status
<IconButton
variant="plain"
className={getClassName(IntegrationsOrderBy.Status)}
ariaLabel="sort"
onClick={() => handleSort(IntegrationsOrderBy.Status)}
>
<FontAwesomeIcon icon={getColSortIcon(IntegrationsOrderBy.Status)} />
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{filteredIntegrations.slice(offset, perPage * page).map((integration) => (
<IntegrationRow
cloudIntegration={cloudIntegrationMap.get(integration.integration)!}
key={`integration-${integration.id}`}
onManualSyncIntegration={() => {
syncIntegration({
workspaceId,
id: integration.id,
lastUsed: integration.lastUsed as string
});
}}
onRemoveIntegration={() => onDeleteIntegration(integration)}
integration={integration}
environment={environmentMap.get(integration.envId)}
/>
))}
</TBody>
</Table>
{Boolean(filteredIntegrations.length) && (
<Pagination
count={filteredIntegrations.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredIntegrations?.length && (
<EmptyState
title={
integrations.length
? "No integrations match search..."
: "This project has no integrations configured"
}
icon={integrations.length ? faSearch : faPlug}
/>
)}
</TableContainer>
</div>
);
};

View File

@@ -0,0 +1 @@
export * from "./IntegrationsTable";

View File

@@ -6,14 +6,14 @@ import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem
ModalContent
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { findOrgMembershipRole } from "@app/helpers/roles";
import { useCreateGroup, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -23,7 +23,7 @@ const GroupFormSchema = z.object({
.string()
.min(5, "Slug must be at least 5 characters long")
.max(36, "Slug must be 36 characters or fewer"),
role: z.string()
role: z.object({ name: z.string(), slug: z.string() })
});
export type TGroupFormData = z.infer<typeof GroupFormSchema>;
@@ -62,13 +62,13 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
reset({
name: group.name,
slug: group.slug,
role: group?.customRole?.slug ?? group.role
role: group?.customRole ?? findOrgMembershipRole(roles, group.role)
});
} else {
reset({
name: "",
slug: "",
role: roles[0].slug
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
});
}
}, [popUp?.group?.data, roles]);
@@ -88,14 +88,14 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
id: group.groupId,
name,
slug,
role: role || undefined
role: role.slug || undefined
});
} else {
await createMutateAsync({
name,
slug,
organizationId: currentOrg.id,
role: role || undefined
role: role.slug || undefined
});
}
handlePopUpToggle("group", false);
@@ -121,7 +121,10 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
reset();
}}
>
<ModalContent title={`${popUp?.group?.data ? "Update" : "Create"} Group`}>
<ModalContent
bodyClassName="overflow-visible"
title={`${popUp?.group?.data ? "Update" : "Create"} Group`}
>
<form onSubmit={handleSubmit(onGroupModalSubmit)}>
<Controller
control={control}
@@ -144,26 +147,21 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
<Controller
control={control}
name="role"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label={`${popUp?.group?.data ? "Update" : ""} Role`}
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(roles || []).map(({ name, slug }) => (
<SelectItem value={slug} key={`org-group-role-${slug}`}>
{name}
</SelectItem>
))}
</Select>
<FilterableSelect
options={roles}
placeholder="Select role..."
onChange={onChange}
value={value}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>

View File

@@ -9,27 +9,24 @@ import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
FormLabel,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem
ModalContent
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { findOrgMembershipRole } from "@app/helpers/roles";
import { useCreateIdentity, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api";
import {
// IdentityAuthMethod,
useAddIdentityUniversalAuth
} from "@app/hooks/api/identities";
import { useAddIdentityUniversalAuth } from "@app/hooks/api/identities";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
.object({
name: z.string(),
role: z.string(),
name: z.string().min(1, "Required"),
role: z.object({ slug: z.string(), name: z.string() }),
metadata: z
.object({
key: z.string().trim().min(1),
@@ -101,13 +98,13 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
if (identity) {
reset({
name: identity.name,
role: identity?.customRole?.slug ?? identity.role,
role: identity.customRole ?? findOrgMembershipRole(roles, identity.role),
metadata: identity.metadata
});
} else {
reset({
name: "",
role: roles[0].slug
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole)
});
}
}, [popUp?.identity?.data, roles]);
@@ -126,7 +123,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
await updateMutateAsync({
identityId: identity.identityId,
name,
role: role || undefined,
role: role.slug || undefined,
organizationId: orgId,
metadata
});
@@ -137,7 +134,7 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
const { id: createdId } = await createMutateAsync({
name,
role: role || undefined,
role: role.slug || undefined,
organizationId: orgId,
metadata
});
@@ -184,7 +181,10 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
reset();
}}
>
<ModalContent title={`${popUp?.identity?.data ? "Update" : "Create"} Identity`}>
<ModalContent
bodyClassName="overflow-visible"
title={`${popUp?.identity?.data ? "Update" : "Create"} Identity`}
>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
@@ -199,26 +199,21 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller
control={control}
name="role"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label={`${popUp?.identity?.data ? "Update" : ""} Role`}
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(roles || []).map(({ name, slug }) => (
<SelectItem value={slug} key={`st-role-${slug}`}>
{name}
</SelectItem>
))}
</Select>
<FilterableSelect
placeholder="Select role..."
options={roles}
onChange={onChange}
value={value}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>

View File

@@ -15,7 +15,7 @@ import {
TextArea
} from "@app/components/v2";
import { useOrganization } from "@app/context";
import { isCustomOrgRole } from "@app/helpers/roles";
import { findOrgMembershipRole } from "@app/helpers/roles";
import {
useAddUsersToOrg,
useFetchServerStatus,
@@ -45,7 +45,7 @@ const addMemberFormSchema = z.object({
)
.default([]),
projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG),
organizationRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG)
organizationRole: z.object({ name: z.string(), slug: z.string() })
});
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
@@ -87,16 +87,17 @@ export const AddOrgMemberModal = ({
useEffect(() => {
if (organizationRoles) {
reset({
organizationRoleSlug: isCustomOrgRole(currentOrg?.defaultMembershipRole!)
? organizationRoles?.find((role) => role.id === currentOrg?.defaultMembershipRole)?.slug!
: currentOrg?.defaultMembershipRole
organizationRole: findOrgMembershipRole(
organizationRoles,
currentOrg?.defaultMembershipRole!
)
});
}
}, [organizationRoles]);
const onAddMembers = async ({
emails,
organizationRoleSlug,
organizationRole,
projects: selectedProjects,
projectRoleSlug
}: TAddMemberForm) => {
@@ -138,7 +139,7 @@ export const AddOrgMemberModal = ({
const { data } = await addUsersMutateAsync({
organizationId: currentOrg?.id,
inviteeEmails: emails.split(",").map((email) => email.trim()),
organizationRoleSlug,
organizationRoleSlug: organizationRole.slug,
projects: selectedProjects.map(({ id }) => ({ id, projectRoleSlug: [projectRoleSlug] }))
});
@@ -207,27 +208,22 @@ export const AddOrgMemberModal = ({
<Controller
control={control}
name="organizationRoleSlug"
render={({ field, fieldState: { error } }) => (
name="organizationRole"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText="Select which organization role you want to assign to the user."
label="Assign organization role"
isError={Boolean(error)}
errorText={error?.message}
>
<div>
<Select
className="w-full"
{...field}
onValueChange={(val) => field.onChange(val)}
>
{organizationRoles?.map((role) => (
<SelectItem key={role.id} value={role.slug}>
{role.name}
</SelectItem>
))}
</Select>
</div>
<FilterableSelect
placeholder="Select role..."
options={organizationRoles}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>

View File

@@ -296,7 +296,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
status,
isActive
}) => {
const name = u && u.firstName ? `${u.firstName} ${u.lastName}` : "-";
const name =
u && u.firstName ? `${u.firstName} ${u.lastName ?? ""}`.trim() : "-";
const email = u?.email || inviteEmail;
const username = u?.username ?? inviteEmail ?? "-";
return (

View File

@@ -123,7 +123,7 @@ export const UserPage = withPermission(
<div className="mb-4 flex items-center justify-between">
<p className="text-3xl font-semibold text-white">
{membership.user.firstName || membership.user.lastName
? `${membership.user.firstName} ${membership.user.lastName}`
? `${membership.user.firstName} ${membership.user.lastName ?? ""}`.trim()
: "-"}
</p>
{userId !== membership.user.id && (
@@ -148,7 +148,8 @@ export const UserPage = withPermission(
onClick={() =>
handlePopUpOpen("orgMembership", {
membershipId: membership.id,
role: membership.role
role: membership.role,
roleId: membership.roleId
})
}
disabled={!isAllowed}

View File

@@ -100,6 +100,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
handlePopUpOpen("orgMembership", {
membershipId: membership.id,
role: membership.role,
roleId: membership.roleId,
metadata: membership.metadata
});
}}
@@ -117,7 +118,7 @@ export const UserDetailsSection = ({ membershipId, handlePopUpOpen }: Props) =>
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
<p className="text-sm text-mineshaft-300">
{membership.user.firstName || membership.user.lastName
? `${membership.user.firstName} ${membership.user.lastName}`
? `${membership.user.firstName} ${membership.user.lastName ?? ""}`.trim()
: "-"}
</p>
</div>

View File

@@ -1,5 +1,6 @@
import { useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { SingleValue } from "react-select";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -8,21 +9,21 @@ import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
FormLabel,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem
ModalContent
} from "@app/components/v2";
import { useOrganization, useSubscription } from "@app/context";
import { findOrgMembershipRole, isCustomOrgRole } from "@app/helpers/roles";
import { useGetOrgRoles, useUpdateOrgMembership } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z.object({
role: z.string(),
role: z.object({ name: z.string(), slug: z.string() }),
metadata: z
.object({
key: z.string().trim().min(1),
@@ -45,7 +46,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
const { currentOrg } = useOrganization();
const orgId = currentOrg?.id || "";
const { data: roles } = useGetOrgRoles(orgId);
const { data: roles = [] } = useGetOrgRoles(orgId);
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
@@ -66,6 +67,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
const popUpData = popUp?.orgMembership?.data as {
membershipId: string;
role: string;
roleId?: string;
metadata: { key: string; value: string }[];
};
@@ -74,12 +76,12 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
if (popUpData) {
reset({
role: popUpData.role,
role: findOrgMembershipRole(roles, popUpData.roleId ?? popUpData.role),
metadata: popUpData.metadata
});
} else {
reset({
role: roles[0].slug
role: findOrgMembershipRole(roles, currentOrg!.defaultMembershipRole!)
});
}
}, [popUp?.orgMembership?.data, roles]);
@@ -91,7 +93,7 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
await updateOrgMembership({
organizationId: orgId,
membershipId: popUpData.membershipId,
role,
role: role.slug,
metadata
});
@@ -123,23 +125,26 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
reset();
}}
>
<ModalContent title="Update Membership">
<ModalContent bodyClassName="overflow-visible" title="Update Membership">
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="role"
defaultValue=""
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Update Organization Role"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => {
const isCustomRole = !["admin", "member", "no-access"].includes(e);
<FilterableSelect
placeholder="Select role..."
options={roles}
onChange={(newValue) => {
const role = newValue as SingleValue<(typeof roles)[number]>;
if (!role) return;
const isCustomRole = isCustomOrgRole(role.slug);
if (isCustomRole && subscription && !subscription?.rbac) {
handlePopUpOpen("upgradePlan", {
@@ -149,16 +154,12 @@ export const UserOrgMembershipModal = ({ popUp, handlePopUpOpen, handlePopUpTogg
return;
}
onChange(e);
onChange(role);
}}
className="w-full"
>
{(roles || []).map(({ name, slug }) => (
<SelectItem value={slug} key={`st-role-${slug}`}>
{name}
</SelectItem>
))}
</Select>
value={value}
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
/>
</FormControl>
)}
/>

View File

@@ -1,6 +1,27 @@
import { faFolder } from "@fortawesome/free-solid-svg-icons";
import { useMemo } from "react";
import {
faArrowDown,
faArrowUp,
faMagnifyingGlass,
faSearch,
faUser
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { EmptyState, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2";
import {
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TBody,
Th,
THead,
Tr
} from "@app/components/v2";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { OrgUser } from "@app/hooks/api/types";
import { useListUserGroupMemberships } from "@app/hooks/api/users/queries";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -12,31 +33,106 @@ type Props = {
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromGroup"]>, data?: {}) => void;
};
enum UserGroupsOrderBy {
Name = "name"
}
export const UserGroupsTable = ({ handlePopUpOpen, orgMembership }: Props) => {
const { data: groups, isLoading } = useListUserGroupMemberships(orgMembership.user.username);
const { data: groupMemberships = [], isLoading } = useListUserGroupMemberships(
orgMembership.user.username
);
const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
toggleOrderDirection
} = usePagination(UserGroupsOrderBy.Name, { initPerPage: 10 });
const filteredGroupMemberships = useMemo(
() =>
groupMemberships
.filter((group) => group.name.toLowerCase().includes(search.trim().toLowerCase()))
.sort((a, b) => {
const [membershipOne, membershipTwo] =
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
return membershipOne.name.toLowerCase().localeCompare(membershipTwo.name.toLowerCase());
}),
[groupMemberships, orderDirection, search]
);
useResetPageHelper({
totalCount: filteredGroupMemberships.length,
offset,
setPage
});
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{groups?.map((group) => (
<UserGroupsRow
key={`user-group-${group.id}`}
group={group}
handlePopUpOpen={handlePopUpOpen}
/>
))}
</TBody>
</Table>
{!isLoading && !groups?.length && (
<EmptyState title="This user has not been assigned to any groups" icon={faFolder} />
)}
</TableContainer>
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search groups..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-full">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={toggleOrderDirection}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{filteredGroupMemberships.slice(offset, perPage * page).map((group) => (
<UserGroupsRow
key={`user-group-${group.id}`}
group={group}
handlePopUpOpen={handlePopUpOpen}
/>
))}
</TBody>
</Table>
{Boolean(filteredGroupMemberships.length) && (
<Pagination
count={filteredGroupMemberships.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isLoading && !filteredGroupMemberships?.length && (
<EmptyState
title={
groupMemberships.length
? "No groups match search..."
: "This user has not been assigned to any groups"
}
icon={groupMemberships.length ? faSearch : faUser}
/>
)}
</TableContainer>
</div>
);
};

View File

@@ -1,4 +1,5 @@
import { useRouter } from "next/router";
import { subject } from "@casl/ability";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
@@ -94,7 +95,9 @@ export const IdentityDetailsPage = withProjectPermission(
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Identity}
a={subject(ProjectPermissionSub.Identity, {
identityId: identityMembershipDetails?.identity?.id
})}
renderTooltip
allowedLabel="Remove from project"
>

View File

@@ -1,4 +1,5 @@
import { Controller, FormProvider, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import {
faCaretDown,
faChevronLeft,
@@ -17,12 +18,13 @@ import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
FormControl,
FormLabel,
Input,
Modal,
ModalContent,
ModalTrigger,
Popover,
PopoverContent,
PopoverTrigger,
@@ -35,7 +37,6 @@ import {
useProjectPermission,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
useCreateIdentityProjectAdditionalPrivilege,
useGetIdentityProjectPrivilegeDetails,
@@ -43,10 +44,10 @@ import {
} from "@app/hooks/api";
import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/hooks/api/identityProjectAdditionalPrivilege/types";
import { GeneralPermissionPolicies } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/GeneralPermissionPolicies";
import { NewPermissionRule } from "@app/views/Project/RolePage/components/RolePermissionsSection/components/NewPermissionRule";
import { PermissionEmptyState } from "@app/views/Project/RolePage/components/RolePermissionsSection/PermissionEmptyState";
import {
formRolePermission2API,
isConditionalSubjects,
PROJECT_PERMISSION_OBJECT,
projectRoleFormSchema,
rolePermission2Form
@@ -88,7 +89,6 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
}: Props) => {
const isCreate = !privilegeId;
const { currentWorkspace } = useWorkspace();
const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const);
const projectId = currentWorkspace?.id || "";
const { data: privilegeDetails, isLoading } = useGetIdentityProjectPrivilegeDetails({
identityId,
@@ -98,7 +98,7 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
const { permission } = useProjectPermission();
const isIdentityEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionSub.Identity
subject(ProjectPermissionSub.Identity, { identityId })
);
const form = useForm<TFormSchema>({
@@ -194,6 +194,30 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
}
}
const onNewPolicy = (selectedSubject: ProjectPermissionSub) => {
const rootPolicyValue = form.getValues(`permissions.${selectedSubject}`);
if (rootPolicyValue && isConditionalSubjects(selectedSubject)) {
form.setValue(
`permissions.${selectedSubject}`,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
[...rootPolicyValue, ...[]],
{ shouldDirty: true, shouldTouch: true }
);
} else {
form.setValue(
`permissions.${selectedSubject}`,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error akhilmhdh: this is because of ts collision with both
[{}],
{
shouldDirty: true,
shouldTouch: true
}
);
}
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
@@ -232,24 +256,39 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
>
Save
</Button>
<Modal
isOpen={popUp.createPolicy.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("createPolicy", isOpen)}
>
<ModalTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger>
<Button
isDisabled={isDisabled}
className="h-10 rounded-l-none"
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
isDisabled={isDisabled}
>
New Policy
New policy
</Button>
</ModalTrigger>
<ModalContent title="New Policy" subTitle="Policies grant additional permissions.">
<NewPermissionRule onClose={() => handlePopUpToggle("createPolicy")} />
</ModalContent>
</Modal>
</DropdownMenuTrigger>
<DropdownMenuContent className="thin-scrollbar max-h-96" align="end">
{Object.keys(PROJECT_PERMISSION_OBJECT)
.sort((a, b) =>
PROJECT_PERMISSION_OBJECT[a as keyof typeof PROJECT_PERMISSION_OBJECT].title
.toLowerCase()
.localeCompare(
PROJECT_PERMISSION_OBJECT[
b as keyof typeof PROJECT_PERMISSION_OBJECT
].title.toLowerCase()
)
)
.map((permissionSubject) => (
<DropdownMenuItem
key={`permission-create-${permissionSubject}`}
className="py-3"
onClick={() => onNewPolicy(permissionSubject as ProjectPermissionSub)}
>
{PROJECT_PERMISSION_OBJECT[permissionSubject as ProjectPermissionSub].title}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
@@ -376,17 +415,19 @@ export const IdentityProjectAdditionalPrivilegeModifySection = ({
<div className="p-4">
<div className="mb-2 text-lg">Policies</div>
{(isCreate || !isLoading) && <PermissionEmptyState />}
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
<GeneralPermissionPolicies
subject={subject}
actions={PROJECT_PERMISSION_OBJECT[subject].actions}
title={PROJECT_PERMISSION_OBJECT[subject].title}
key={`project-permission-${subject}`}
isDisabled={isDisabled}
>
{renderConditionalComponents(subject, isDisabled)}
</GeneralPermissionPolicies>
))}
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map(
(permissionSubject) => (
<GeneralPermissionPolicies
subject={permissionSubject}
actions={PROJECT_PERMISSION_OBJECT[permissionSubject].actions}
title={PROJECT_PERMISSION_OBJECT[permissionSubject].title}
key={`project-permission-${permissionSubject}`}
isDisabled={isDisabled}
>
{renderConditionalComponents(permissionSubject, isDisabled)}
</GeneralPermissionPolicies>
)
)}
</div>
</FormProvider>
</form>

Some files were not shown because too many files have changed in this diff Show More