mirror of
https://github.com/Infisical/infisical.git
synced 2025-07-02 16:55:02 +00:00
Compare commits
243 Commits
docker-com
...
infisical/
Author | SHA1 | Date | |
---|---|---|---|
9c67d43ebe | |||
1cf9aaeb1b | |||
bbe769a961 | |||
31cc3ece0c | |||
52cfa1ba39 | |||
4553c6bb37 | |||
554f0cfd00 | |||
0a5112d302 | |||
bdb0ed3e5e | |||
7816d8593e | |||
816c793ae3 | |||
9f0d09f8ed | |||
2cbd2ee75f | |||
368974cf01 | |||
8be976a694 | |||
cab47d0b98 | |||
aa81711824 | |||
10fbb99a15 | |||
4657985468 | |||
68ac1d285a | |||
fe7524fca1 | |||
bf9b47ad66 | |||
8e49825e16 | |||
27b4749205 | |||
5b1f07a661 | |||
50128bbac6 | |||
debf80cfdc | |||
4ab47ca175 | |||
021413fbd9 | |||
8a39276e04 | |||
b5e64bc8b8 | |||
faa842c3d2 | |||
28b24115b7 | |||
198dc05753 | |||
178492e9bd | |||
fb9cdb591c | |||
4c5100de6b | |||
b587e6a4a1 | |||
773756d731 | |||
9efece1f01 | |||
bb6e8b1a51 | |||
0f98fc94f0 | |||
7f1963f1ac | |||
6064c393c6 | |||
0cecf05a5b | |||
dc6497f9eb | |||
e445970f36 | |||
c33741d588 | |||
5dfc84190d | |||
a1d11c0fcd | |||
863bbd420c | |||
4b37b2afba | |||
a366dbb16d | |||
423ad49490 | |||
2a4bda481d | |||
5b550a97a1 | |||
0fa0e4eb0f | |||
65e3f0ec95 | |||
c20f6e51ae | |||
cee8ead78a | |||
82fe0bb5c4 | |||
0b7efa57be | |||
9c11226b71 | |||
ae3606c9fb | |||
a0e25b8ea2 | |||
0931a17af5 | |||
c16bf2afdb | |||
04b4e80dd1 | |||
f178220c5a | |||
ed353d3263 | |||
ec6ec8813e | |||
3ea529d525 | |||
f35f10558b | |||
28287b8ed4 | |||
0f3ec51d14 | |||
75813deb81 | |||
66e57d5d11 | |||
fb2a213214 | |||
c0b11b8350 | |||
bea24d9654 | |||
a7bc62f8e4 | |||
2ef7e8f58e | |||
41d3b9314e | |||
1e9d49008b | |||
49d07a6762 | |||
9ce71371a9 | |||
c1c66da92b | |||
4121c1d573 | |||
108f3cf117 | |||
a6e263eded | |||
419916ee0c | |||
f7e6a96a02 | |||
b0356ba941 | |||
7ea5323a37 | |||
23e198d891 | |||
9f9849ccfd | |||
0c53eb8e22 | |||
9b62937db2 | |||
ebb8d632c4 | |||
43aae87fb0 | |||
3415514fde | |||
c0e0ddde76 | |||
39ae66a84f | |||
e8ec5b8b49 | |||
592271de3b | |||
5680b984cf | |||
f378d6cc2b | |||
04c12d9a75 | |||
31b5f779fb | |||
bb92cef764 | |||
6090f86b74 | |||
8c3569a047 | |||
6fa11fe637 | |||
9287eb7031 | |||
e54b261c0f | |||
60747b10b6 | |||
bf278355c4 | |||
d3d429db37 | |||
f2dcc83a56 | |||
26576b6bcd | |||
4cca82c3c8 | |||
1b82a157cc | |||
5409cffe33 | |||
45327f10b1 | |||
37645ba126 | |||
858b49d766 | |||
a3a1a0007d | |||
075f457bd1 | |||
5156971d75 | |||
8f3de3cc90 | |||
69cba4e6c7 | |||
6dcab6646c | |||
8e13eb6077 | |||
819a9b8d27 | |||
ec3cf0208c | |||
4aa5822ae2 | |||
5364480ca2 | |||
4802a36473 | |||
8333250b0b | |||
0cfab8ab6b | |||
8fd99855bd | |||
f2c36c58f9 | |||
f47fdfe386 | |||
8a11eebab8 | |||
3b1fc4b156 | |||
84cab17f5c | |||
db773864d5 | |||
b9840ceba9 | |||
729ec7866a | |||
a7140941ee | |||
34d1bbc2ed | |||
3ad0382cb0 | |||
ccc409e9cd | |||
fe21ba0e54 | |||
80a802386c | |||
aec0e86182 | |||
8e3cddc1ea | |||
3612e5834c | |||
031a2416a9 | |||
2eb9592b1a | |||
bbd9fa4a56 | |||
318ad25c11 | |||
c372eb7d20 | |||
68a99a0b32 | |||
7512231e20 | |||
f0e580d68b | |||
116015d3cf | |||
308ff50197 | |||
9df5cbbe85 | |||
a714a64bc2 | |||
ea18d99793 | |||
7c098529f7 | |||
e20c623e91 | |||
3260932741 | |||
f0e73474b7 | |||
7db829b0b5 | |||
ccaa9fd96e | |||
b4db06c763 | |||
3ebd2fdc6d | |||
8d06a6c969 | |||
2996efe9d5 | |||
43879f6813 | |||
72d4490ee7 | |||
2336a7265b | |||
d428fd055b | |||
e4b89371f0 | |||
6f9b30b46e | |||
35d589a15f | |||
8d77f2d8f3 | |||
7070a69711 | |||
7a65f8c837 | |||
678306b350 | |||
8864c811fe | |||
79206efcd0 | |||
06d30fc10f | |||
abd28d9269 | |||
c6c64b5499 | |||
5481b84a94 | |||
ab878e00c9 | |||
6773996d40 | |||
2e20b38bce | |||
bccbedfc31 | |||
0ab811194d | |||
7b54109168 | |||
2d088a865f | |||
0a8ec6b9da | |||
01b29c3917 | |||
5439ddeadf | |||
9d17d5277b | |||
c70fc7826a | |||
9ed2bb38c3 | |||
f458cf0d40 | |||
ce3dc86f78 | |||
d1927cb9cf | |||
e80426f72e | |||
f8a8ea2118 | |||
f5cd68168b | |||
b74ce14d80 | |||
afdc5e8531 | |||
b84579b866 | |||
4f3cf046fa | |||
c71af00146 | |||
793440feb6 | |||
b24d748462 | |||
4c49119ac5 | |||
90f09c7a78 | |||
00876f788c | |||
f09c48d79b | |||
57daeb71e6 | |||
98b5f713a5 | |||
120d7e42bf | |||
c2bd259c12 | |||
242d770098 | |||
1855fc769d | |||
217fef65e8 | |||
e15ed4cc58 | |||
c73ee49425 | |||
3a7b697549 | |||
d5064fe75a | |||
00650df501 | |||
6ff5fb69d4 | |||
9fe2021d9f | |||
fe2f2f972e |
4
.env.test.example
Normal file
4
.env.test.example
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
DB_CONNECTION_URI=postgres://infisical:infisical@localhost/infisical?sslmode=disable
|
||||||
|
AUTH_SECRET=4bnfe4e407b8921c104518903515b218
|
||||||
|
ENCRYPTION_KEY=4bnfe4e407b8921c104518903515b218
|
@ -28,7 +28,7 @@ jobs:
|
|||||||
run: docker build --tag infisical-api .
|
run: docker build --tag infisical-api .
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
- name: Start postgres and redis
|
- name: Start postgres and redis
|
||||||
run: touch .env && docker-compose -f docker-compose.prod.yml up -d db redis
|
run: touch .env && docker-compose -f docker-compose.dev.yml up -d db redis
|
||||||
- name: Start the server
|
- name: Start the server
|
||||||
run: |
|
run: |
|
||||||
echo "SECRET_SCANNING_GIT_APP_ID=793712" >> .env
|
echo "SECRET_SCANNING_GIT_APP_ID=793712" >> .env
|
||||||
@ -70,6 +70,6 @@ jobs:
|
|||||||
run: oasdiff breaking https://app.infisical.com/api/docs/json http://localhost:4000/api/docs/json --fail-on ERR
|
run: oasdiff breaking https://app.infisical.com/api/docs/json http://localhost:4000/api/docs/json --fail-on ERR
|
||||||
- name: cleanup
|
- name: cleanup
|
||||||
run: |
|
run: |
|
||||||
docker-compose -f "docker-compose.pg.yml" down
|
docker-compose -f "docker-compose.dev.yml" down
|
||||||
docker stop infisical-api
|
docker stop infisical-api
|
||||||
docker remove infisical-api
|
docker remove infisical-api
|
@ -5,9 +5,14 @@ on:
|
|||||||
- "infisical/v*.*.*-postgres"
|
- "infisical/v*.*.*-postgres"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
infisical-tests:
|
||||||
|
name: Run tests before deployment
|
||||||
|
# https://docs.github.com/en/actions/using-workflows/reusing-workflows#overview
|
||||||
|
uses: ./.github/workflows/run-backend-tests.yml
|
||||||
infisical-standalone:
|
infisical-standalone:
|
||||||
name: Build infisical standalone image postgres
|
name: Build infisical standalone image postgres
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: [infisical-tests]
|
||||||
steps:
|
steps:
|
||||||
- name: Extract version from tag
|
- name: Extract version from tag
|
||||||
id: extract_version
|
id: extract_version
|
||||||
|
47
.github/workflows/run-backend-tests.yml
vendored
Normal file
47
.github/workflows/run-backend-tests.yml
vendored
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
name: "Run backend tests"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize]
|
||||||
|
paths:
|
||||||
|
- "backend/**"
|
||||||
|
- "!backend/README.md"
|
||||||
|
- "!backend/.*"
|
||||||
|
- "backend/.eslintrc.js"
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-be-pr:
|
||||||
|
name: Run integration test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: ☁️ Checkout source
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
- uses: KengoTODA/actions-setup-docker-compose@v1
|
||||||
|
if: ${{ env.ACT }}
|
||||||
|
name: Install `docker-compose` for local simulations
|
||||||
|
with:
|
||||||
|
version: "2.14.2"
|
||||||
|
- name: 🔧 Setup Node 20
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: backend/package-lock.json
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm install
|
||||||
|
working-directory: backend
|
||||||
|
- name: Start postgres and redis
|
||||||
|
run: touch .env && docker-compose -f docker-compose.dev.yml up -d db redis
|
||||||
|
- name: Start integration test
|
||||||
|
run: npm run test:e2e
|
||||||
|
working-directory: backend
|
||||||
|
env:
|
||||||
|
REDIS_URL: redis://172.17.0.1:6379
|
||||||
|
DB_CONNECTION_URI: postgres://infisical:infisical@172.17.0.1:5432/infisical?sslmode=disable
|
||||||
|
AUTH_SECRET: something-random
|
||||||
|
ENCRYPTION_KEY: 4bnfe4e407b8921c104518903515b218
|
||||||
|
- name: cleanup
|
||||||
|
run: |
|
||||||
|
docker-compose -f "docker-compose.dev.yml" down
|
@ -1,2 +1,3 @@
|
|||||||
vitest-environment-infisical.ts
|
vitest-environment-infisical.ts
|
||||||
vitest.config.ts
|
vitest.config.ts
|
||||||
|
vitest.e2e.config.ts
|
||||||
|
@ -21,6 +21,18 @@ module.exports = {
|
|||||||
tsconfigRootDir: __dirname
|
tsconfigRootDir: __dirname
|
||||||
},
|
},
|
||||||
root: true,
|
root: true,
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ["./e2e-test/**/*"],
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-argument": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-return": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-call": "off",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
rules: {
|
rules: {
|
||||||
"@typescript-eslint/no-empty-function": "off",
|
"@typescript-eslint/no-empty-function": "off",
|
||||||
"@typescript-eslint/no-unsafe-enum-comparison": "off",
|
"@typescript-eslint/no-unsafe-enum-comparison": "off",
|
||||||
|
71
backend/e2e-test/routes/v1/identity.spec.ts
Normal file
71
backend/e2e-test/routes/v1/identity.spec.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { OrgMembershipRole } from "@app/db/schemas";
|
||||||
|
import { seedData1 } from "@app/db/seed-data";
|
||||||
|
|
||||||
|
export const createIdentity = async (name: string, role: string) => {
|
||||||
|
const createIdentityRes = await testServer.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/identities",
|
||||||
|
body: {
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
organizationId: seedData1.organization.id
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(createIdentityRes.statusCode).toBe(200);
|
||||||
|
return createIdentityRes.json().identity;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteIdentity = async (id: string) => {
|
||||||
|
const deleteIdentityRes = await testServer.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/api/v1/identities/${id}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(deleteIdentityRes.statusCode).toBe(200);
|
||||||
|
return deleteIdentityRes.json().identity;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Identity v1", async () => {
|
||||||
|
test("Create identity", async () => {
|
||||||
|
const newIdentity = await createIdentity("mac1", OrgMembershipRole.Admin);
|
||||||
|
expect(newIdentity.name).toBe("mac1");
|
||||||
|
expect(newIdentity.authMethod).toBeNull();
|
||||||
|
|
||||||
|
await deleteIdentity(newIdentity.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update identity", async () => {
|
||||||
|
const newIdentity = await createIdentity("mac1", OrgMembershipRole.Admin);
|
||||||
|
expect(newIdentity.name).toBe("mac1");
|
||||||
|
expect(newIdentity.authMethod).toBeNull();
|
||||||
|
|
||||||
|
const updatedIdentity = await testServer.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/v1/identities/${newIdentity.id}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: "updated-mac-1",
|
||||||
|
role: OrgMembershipRole.Member
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updatedIdentity.statusCode).toBe(200);
|
||||||
|
expect(updatedIdentity.json().identity.name).toBe("updated-mac-1");
|
||||||
|
|
||||||
|
await deleteIdentity(newIdentity.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Delete Identity", async () => {
|
||||||
|
const newIdentity = await createIdentity("mac1", OrgMembershipRole.Admin);
|
||||||
|
|
||||||
|
const deletedIdentity = await deleteIdentity(newIdentity.id);
|
||||||
|
expect(deletedIdentity.name).toBe("mac1");
|
||||||
|
});
|
||||||
|
});
|
@ -1,6 +1,7 @@
|
|||||||
import { seedData1 } from "@app/db/seed-data";
|
|
||||||
import jsrp from "jsrp";
|
import jsrp from "jsrp";
|
||||||
|
|
||||||
|
import { seedData1 } from "@app/db/seed-data";
|
||||||
|
|
||||||
describe("Login V1 Router", async () => {
|
describe("Login V1 Router", async () => {
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
const client = new jsrp.client();
|
const client = new jsrp.client();
|
||||||
|
@ -1,6 +1,40 @@
|
|||||||
import { seedData1 } from "@app/db/seed-data";
|
import { seedData1 } from "@app/db/seed-data";
|
||||||
import { DEFAULT_PROJECT_ENVS } from "@app/db/seeds/3-project";
|
import { DEFAULT_PROJECT_ENVS } from "@app/db/seeds/3-project";
|
||||||
|
|
||||||
|
const createProjectEnvironment = async (name: string, slug: string) => {
|
||||||
|
const res = await testServer.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/v1/workspace/${seedData1.project.id}/environments`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name,
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const payload = JSON.parse(res.payload);
|
||||||
|
expect(payload).toHaveProperty("environment");
|
||||||
|
return payload.environment;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteProjectEnvironment = async (envId: string) => {
|
||||||
|
const res = await testServer.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/api/v1/workspace/${seedData1.project.id}/environments/${envId}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const payload = JSON.parse(res.payload);
|
||||||
|
expect(payload).toHaveProperty("environment");
|
||||||
|
return payload.environment;
|
||||||
|
};
|
||||||
|
|
||||||
describe("Project Environment Router", async () => {
|
describe("Project Environment Router", async () => {
|
||||||
test("Get default environments", async () => {
|
test("Get default environments", async () => {
|
||||||
const res = await testServer.inject({
|
const res = await testServer.inject({
|
||||||
@ -31,24 +65,10 @@ describe("Project Environment Router", async () => {
|
|||||||
expect(payload.workspace.environments.length).toBe(3);
|
expect(payload.workspace.environments.length).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockProjectEnv = { name: "temp", slug: "temp", id: "" }; // id will be filled in create op
|
const mockProjectEnv = { name: "temp", slug: "temp" }; // id will be filled in create op
|
||||||
test("Create environment", async () => {
|
test("Create environment", async () => {
|
||||||
const res = await testServer.inject({
|
const newEnvironment = await createProjectEnvironment(mockProjectEnv.name, mockProjectEnv.slug);
|
||||||
method: "POST",
|
expect(newEnvironment).toEqual(
|
||||||
url: `/api/v1/workspace/${seedData1.project.id}/environments`,
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${jwtAuthToken}`
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
name: mockProjectEnv.name,
|
|
||||||
slug: mockProjectEnv.slug
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const payload = JSON.parse(res.payload);
|
|
||||||
expect(payload).toHaveProperty("environment");
|
|
||||||
expect(payload.environment).toEqual(
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
name: mockProjectEnv.name,
|
name: mockProjectEnv.name,
|
||||||
@ -59,14 +79,15 @@ describe("Project Environment Router", async () => {
|
|||||||
updatedAt: expect.any(String)
|
updatedAt: expect.any(String)
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
mockProjectEnv.id = payload.environment.id;
|
await deleteProjectEnvironment(newEnvironment.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Update environment", async () => {
|
test("Update environment", async () => {
|
||||||
|
const newEnvironment = await createProjectEnvironment(mockProjectEnv.name, mockProjectEnv.slug);
|
||||||
const updatedName = { name: "temp#2", slug: "temp2" };
|
const updatedName = { name: "temp#2", slug: "temp2" };
|
||||||
const res = await testServer.inject({
|
const res = await testServer.inject({
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
url: `/api/v1/workspace/${seedData1.project.id}/environments/${mockProjectEnv.id}`,
|
url: `/api/v1/workspace/${seedData1.project.id}/environments/${newEnvironment.id}`,
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${jwtAuthToken}`
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
},
|
},
|
||||||
@ -82,7 +103,7 @@ describe("Project Environment Router", async () => {
|
|||||||
expect(payload).toHaveProperty("environment");
|
expect(payload).toHaveProperty("environment");
|
||||||
expect(payload.environment).toEqual(
|
expect(payload.environment).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: expect.any(String),
|
id: newEnvironment.id,
|
||||||
name: updatedName.name,
|
name: updatedName.name,
|
||||||
slug: updatedName.slug,
|
slug: updatedName.slug,
|
||||||
projectId: seedData1.project.id,
|
projectId: seedData1.project.id,
|
||||||
@ -91,61 +112,21 @@ describe("Project Environment Router", async () => {
|
|||||||
updatedAt: expect.any(String)
|
updatedAt: expect.any(String)
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
mockProjectEnv.name = updatedName.name;
|
await deleteProjectEnvironment(newEnvironment.id);
|
||||||
mockProjectEnv.slug = updatedName.slug;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Delete environment", async () => {
|
test("Delete environment", async () => {
|
||||||
const res = await testServer.inject({
|
const newEnvironment = await createProjectEnvironment(mockProjectEnv.name, mockProjectEnv.slug);
|
||||||
method: "DELETE",
|
const deletedProjectEnvironment = await deleteProjectEnvironment(newEnvironment.id);
|
||||||
url: `/api/v1/workspace/${seedData1.project.id}/environments/${mockProjectEnv.id}`,
|
expect(deletedProjectEnvironment).toEqual(
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${jwtAuthToken}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const payload = JSON.parse(res.payload);
|
|
||||||
expect(payload).toHaveProperty("environment");
|
|
||||||
expect(payload.environment).toEqual(
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: expect.any(String),
|
id: deletedProjectEnvironment.id,
|
||||||
name: mockProjectEnv.name,
|
name: mockProjectEnv.name,
|
||||||
slug: mockProjectEnv.slug,
|
slug: mockProjectEnv.slug,
|
||||||
position: 1,
|
position: 4,
|
||||||
createdAt: expect.any(String),
|
createdAt: expect.any(String),
|
||||||
updatedAt: expect.any(String)
|
updatedAt: expect.any(String)
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// after all these opreations the list of environment should be still same
|
|
||||||
test("Default list of environment", async () => {
|
|
||||||
const res = await testServer.inject({
|
|
||||||
method: "GET",
|
|
||||||
url: `/api/v1/workspace/${seedData1.project.id}`,
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${jwtAuthToken}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const payload = JSON.parse(res.payload);
|
|
||||||
expect(payload).toHaveProperty("workspace");
|
|
||||||
// check for default environments
|
|
||||||
expect(payload).toEqual({
|
|
||||||
workspace: expect.objectContaining({
|
|
||||||
name: seedData1.project.name,
|
|
||||||
id: seedData1.project.id,
|
|
||||||
slug: seedData1.project.slug,
|
|
||||||
environments: expect.arrayContaining([
|
|
||||||
expect.objectContaining(DEFAULT_PROJECT_ENVS[0]),
|
|
||||||
expect.objectContaining(DEFAULT_PROJECT_ENVS[1]),
|
|
||||||
expect.objectContaining(DEFAULT_PROJECT_ENVS[2])
|
|
||||||
])
|
|
||||||
})
|
|
||||||
});
|
|
||||||
// ensure only two default environments exist
|
|
||||||
expect(payload.workspace.environments.length).toBe(3);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
@ -1,12 +1,6 @@
|
|||||||
import { seedData1 } from "@app/db/seed-data";
|
import { seedData1 } from "@app/db/seed-data";
|
||||||
|
|
||||||
describe("Secret Folder Router", async () => {
|
const createFolder = async (dto: { path: string; name: string }) => {
|
||||||
test.each([
|
|
||||||
{ name: "folder1", path: "/" }, // one in root
|
|
||||||
{ name: "folder1", path: "/level1/level2" }, // then create a deep one creating intermediate ones
|
|
||||||
{ name: "folder2", path: "/" },
|
|
||||||
{ name: "folder1", path: "/level1/level2" } // this should not create folder return same thing
|
|
||||||
])("Create folder $name in $path", async ({ name, path }) => {
|
|
||||||
const res = await testServer.inject({
|
const res = await testServer.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/api/v1/folders`,
|
url: `/api/v1/folders`,
|
||||||
@ -16,21 +10,47 @@ describe("Secret Folder Router", async () => {
|
|||||||
body: {
|
body: {
|
||||||
workspaceId: seedData1.project.id,
|
workspaceId: seedData1.project.id,
|
||||||
environment: seedData1.environment.slug,
|
environment: seedData1.environment.slug,
|
||||||
name,
|
name: dto.name,
|
||||||
path
|
path: dto.path
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
const payload = JSON.parse(res.payload);
|
return res.json().folder;
|
||||||
expect(payload).toHaveProperty("folder");
|
};
|
||||||
|
|
||||||
|
const deleteFolder = async (dto: { path: string; id: string }) => {
|
||||||
|
const res = await testServer.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/api/v1/folders/${dto.id}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
workspaceId: seedData1.project.id,
|
||||||
|
environment: seedData1.environment.slug,
|
||||||
|
path: dto.path
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
return res.json().folder;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Secret Folder Router", async () => {
|
||||||
|
test.each([
|
||||||
|
{ name: "folder1", path: "/" }, // one in root
|
||||||
|
{ name: "folder1", path: "/level1/level2" }, // then create a deep one creating intermediate ones
|
||||||
|
{ name: "folder2", path: "/" },
|
||||||
|
{ name: "folder1", path: "/level1/level2" } // this should not create folder return same thing
|
||||||
|
])("Create folder $name in $path", async ({ name, path }) => {
|
||||||
|
const createdFolder = await createFolder({ path, name });
|
||||||
// check for default environments
|
// check for default environments
|
||||||
expect(payload).toEqual({
|
expect(createdFolder).toEqual(
|
||||||
folder: expect.objectContaining({
|
expect.objectContaining({
|
||||||
name,
|
name,
|
||||||
id: expect.any(String)
|
id: expect.any(String)
|
||||||
})
|
})
|
||||||
});
|
);
|
||||||
|
await deleteFolder({ path, id: createdFolder.id });
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
@ -43,6 +63,8 @@ describe("Secret Folder Router", async () => {
|
|||||||
},
|
},
|
||||||
{ path: "/level1/level2", expected: { folders: [{ name: "folder1" }], length: 1 } }
|
{ path: "/level1/level2", expected: { folders: [{ name: "folder1" }], length: 1 } }
|
||||||
])("Get folders $path", async ({ path, expected }) => {
|
])("Get folders $path", async ({ path, expected }) => {
|
||||||
|
const newFolders = await Promise.all(expected.folders.map(({ name }) => createFolder({ name, path })));
|
||||||
|
|
||||||
const res = await testServer.inject({
|
const res = await testServer.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `/api/v1/folders`,
|
url: `/api/v1/folders`,
|
||||||
@ -59,36 +81,22 @@ describe("Secret Folder Router", async () => {
|
|||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
const payload = JSON.parse(res.payload);
|
const payload = JSON.parse(res.payload);
|
||||||
expect(payload).toHaveProperty("folders");
|
expect(payload).toHaveProperty("folders");
|
||||||
expect(payload.folders.length).toBe(expected.length);
|
expect(payload.folders.length >= expected.folders.length).toBeTruthy();
|
||||||
expect(payload).toEqual({ folders: expected.folders.map((el) => expect.objectContaining(el)) });
|
expect(payload).toEqual({
|
||||||
|
folders: expect.arrayContaining(expected.folders.map((el) => expect.objectContaining(el)))
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(newFolders.map(({ id }) => deleteFolder({ path, id })));
|
||||||
});
|
});
|
||||||
|
|
||||||
let toBeDeleteFolderId = "";
|
|
||||||
test("Update a deep folder", async () => {
|
test("Update a deep folder", async () => {
|
||||||
const res = await testServer.inject({
|
const newFolder = await createFolder({ name: "folder-updated", path: "/level1/level2" });
|
||||||
method: "PATCH",
|
expect(newFolder).toEqual(
|
||||||
url: `/api/v1/folders/folder1`,
|
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${jwtAuthToken}`
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
workspaceId: seedData1.project.id,
|
|
||||||
environment: seedData1.environment.slug,
|
|
||||||
name: "folder-updated",
|
|
||||||
path: "/level1/level2"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const payload = JSON.parse(res.payload);
|
|
||||||
expect(payload).toHaveProperty("folder");
|
|
||||||
expect(payload.folder).toEqual(
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
name: "folder-updated"
|
name: "folder-updated"
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
toBeDeleteFolderId = payload.folder.id;
|
|
||||||
|
|
||||||
const resUpdatedFolders = await testServer.inject({
|
const resUpdatedFolders = await testServer.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@ -106,14 +114,16 @@ describe("Secret Folder Router", async () => {
|
|||||||
expect(resUpdatedFolders.statusCode).toBe(200);
|
expect(resUpdatedFolders.statusCode).toBe(200);
|
||||||
const updatedFolderList = JSON.parse(resUpdatedFolders.payload);
|
const updatedFolderList = JSON.parse(resUpdatedFolders.payload);
|
||||||
expect(updatedFolderList).toHaveProperty("folders");
|
expect(updatedFolderList).toHaveProperty("folders");
|
||||||
expect(updatedFolderList.folders.length).toEqual(1);
|
|
||||||
expect(updatedFolderList.folders[0].name).toEqual("folder-updated");
|
expect(updatedFolderList.folders[0].name).toEqual("folder-updated");
|
||||||
|
|
||||||
|
await deleteFolder({ path: "/level1/level2", id: newFolder.id });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Delete a deep folder", async () => {
|
test("Delete a deep folder", async () => {
|
||||||
|
const newFolder = await createFolder({ name: "folder-updated", path: "/level1/level2" });
|
||||||
const res = await testServer.inject({
|
const res = await testServer.inject({
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
url: `/api/v1/folders/${toBeDeleteFolderId}`,
|
url: `/api/v1/folders/${newFolder.id}`,
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${jwtAuthToken}`
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
},
|
},
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
import { seedData1 } from "@app/db/seed-data";
|
import { seedData1 } from "@app/db/seed-data";
|
||||||
|
|
||||||
describe("Secret Folder Router", async () => {
|
const createSecretImport = async (importPath: string, importEnv: string) => {
|
||||||
test.each([
|
|
||||||
{ importEnv: "dev", importPath: "/" }, // one in root
|
|
||||||
{ importEnv: "staging", importPath: "/" } // then create a deep one creating intermediate ones
|
|
||||||
])("Create secret import $importEnv with path $importPath", async ({ importPath, importEnv }) => {
|
|
||||||
const res = await testServer.inject({
|
const res = await testServer.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/api/v1/secret-imports`,
|
url: `/api/v1/secret-imports`,
|
||||||
@ -25,8 +21,37 @@ describe("Secret Folder Router", async () => {
|
|||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
const payload = JSON.parse(res.payload);
|
const payload = JSON.parse(res.payload);
|
||||||
expect(payload).toHaveProperty("secretImport");
|
expect(payload).toHaveProperty("secretImport");
|
||||||
|
return payload.secretImport;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSecretImport = async (id: string) => {
|
||||||
|
const res = await testServer.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/api/v1/secret-imports/${id}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
workspaceId: seedData1.project.id,
|
||||||
|
environment: seedData1.environment.slug,
|
||||||
|
path: "/"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const payload = JSON.parse(res.payload);
|
||||||
|
expect(payload).toHaveProperty("secretImport");
|
||||||
|
return payload.secretImport;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Secret Import Router", async () => {
|
||||||
|
test.each([
|
||||||
|
{ importEnv: "dev", importPath: "/" }, // one in root
|
||||||
|
{ importEnv: "staging", importPath: "/" } // then create a deep one creating intermediate ones
|
||||||
|
])("Create secret import $importEnv with path $importPath", async ({ importPath, importEnv }) => {
|
||||||
// check for default environments
|
// check for default environments
|
||||||
expect(payload.secretImport).toEqual(
|
const payload = await createSecretImport(importPath, importEnv);
|
||||||
|
expect(payload).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
importPath: expect.any(String),
|
importPath: expect.any(String),
|
||||||
@ -37,10 +62,12 @@ describe("Secret Folder Router", async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
await deleteSecretImport(payload.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
let testSecretImportId = "";
|
|
||||||
test("Get secret imports", async () => {
|
test("Get secret imports", async () => {
|
||||||
|
const createdImport1 = await createSecretImport("/", "dev");
|
||||||
|
const createdImport2 = await createSecretImport("/", "staging");
|
||||||
const res = await testServer.inject({
|
const res = await testServer.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `/api/v1/secret-imports`,
|
url: `/api/v1/secret-imports`,
|
||||||
@ -58,7 +85,6 @@ describe("Secret Folder Router", async () => {
|
|||||||
const payload = JSON.parse(res.payload);
|
const payload = JSON.parse(res.payload);
|
||||||
expect(payload).toHaveProperty("secretImports");
|
expect(payload).toHaveProperty("secretImports");
|
||||||
expect(payload.secretImports.length).toBe(2);
|
expect(payload.secretImports.length).toBe(2);
|
||||||
testSecretImportId = payload.secretImports[0].id;
|
|
||||||
expect(payload.secretImports).toEqual(
|
expect(payload.secretImports).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@ -72,12 +98,20 @@ describe("Secret Folder Router", async () => {
|
|||||||
})
|
})
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
await deleteSecretImport(createdImport1.id);
|
||||||
|
await deleteSecretImport(createdImport2.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Update secret import position", async () => {
|
test("Update secret import position", async () => {
|
||||||
const res = await testServer.inject({
|
const devImportDetails = { path: "/", envSlug: "dev" };
|
||||||
|
const stagingImportDetails = { path: "/", envSlug: "staging" };
|
||||||
|
|
||||||
|
const createdImport1 = await createSecretImport(devImportDetails.path, devImportDetails.envSlug);
|
||||||
|
const createdImport2 = await createSecretImport(stagingImportDetails.path, stagingImportDetails.envSlug);
|
||||||
|
|
||||||
|
const updateImportRes = await testServer.inject({
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
url: `/api/v1/secret-imports/${testSecretImportId}`,
|
url: `/api/v1/secret-imports/${createdImport1.id}`,
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${jwtAuthToken}`
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
},
|
},
|
||||||
@ -91,8 +125,8 @@ describe("Secret Folder Router", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(updateImportRes.statusCode).toBe(200);
|
||||||
const payload = JSON.parse(res.payload);
|
const payload = JSON.parse(updateImportRes.payload);
|
||||||
expect(payload).toHaveProperty("secretImport");
|
expect(payload).toHaveProperty("secretImport");
|
||||||
// check for default environments
|
// check for default environments
|
||||||
expect(payload.secretImport).toEqual(
|
expect(payload.secretImport).toEqual(
|
||||||
@ -102,7 +136,7 @@ describe("Secret Folder Router", async () => {
|
|||||||
position: 2,
|
position: 2,
|
||||||
importEnv: expect.objectContaining({
|
importEnv: expect.objectContaining({
|
||||||
name: expect.any(String),
|
name: expect.any(String),
|
||||||
slug: expect.any(String),
|
slug: expect.stringMatching(devImportDetails.envSlug),
|
||||||
id: expect.any(String)
|
id: expect.any(String)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -124,28 +158,19 @@ describe("Secret Folder Router", async () => {
|
|||||||
expect(secretImportsListRes.statusCode).toBe(200);
|
expect(secretImportsListRes.statusCode).toBe(200);
|
||||||
const secretImportList = JSON.parse(secretImportsListRes.payload);
|
const secretImportList = JSON.parse(secretImportsListRes.payload);
|
||||||
expect(secretImportList).toHaveProperty("secretImports");
|
expect(secretImportList).toHaveProperty("secretImports");
|
||||||
expect(secretImportList.secretImports[1].id).toEqual(testSecretImportId);
|
expect(secretImportList.secretImports[1].id).toEqual(createdImport1.id);
|
||||||
|
expect(secretImportList.secretImports[0].id).toEqual(createdImport2.id);
|
||||||
|
|
||||||
|
await deleteSecretImport(createdImport1.id);
|
||||||
|
await deleteSecretImport(createdImport2.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Delete secret import position", async () => {
|
test("Delete secret import position", async () => {
|
||||||
const res = await testServer.inject({
|
const createdImport1 = await createSecretImport("/", "dev");
|
||||||
method: "DELETE",
|
const createdImport2 = await createSecretImport("/", "staging");
|
||||||
url: `/api/v1/secret-imports/${testSecretImportId}`,
|
const deletedImport = await deleteSecretImport(createdImport1.id);
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${jwtAuthToken}`
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
workspaceId: seedData1.project.id,
|
|
||||||
environment: seedData1.environment.slug,
|
|
||||||
path: "/"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
|
||||||
const payload = JSON.parse(res.payload);
|
|
||||||
expect(payload).toHaveProperty("secretImport");
|
|
||||||
// check for default environments
|
// check for default environments
|
||||||
expect(payload.secretImport).toEqual(
|
expect(deletedImport).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: expect.any(String),
|
id: expect.any(String),
|
||||||
importPath: expect.any(String),
|
importPath: expect.any(String),
|
||||||
@ -175,5 +200,7 @@ describe("Secret Folder Router", async () => {
|
|||||||
expect(secretImportList).toHaveProperty("secretImports");
|
expect(secretImportList).toHaveProperty("secretImports");
|
||||||
expect(secretImportList.secretImports.length).toEqual(1);
|
expect(secretImportList.secretImports.length).toEqual(1);
|
||||||
expect(secretImportList.secretImports[0].position).toEqual(1);
|
expect(secretImportList.secretImports[0].position).toEqual(1);
|
||||||
|
|
||||||
|
await deleteSecretImport(createdImport2.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
579
backend/e2e-test/routes/v2/service-token.spec.ts
Normal file
579
backend/e2e-test/routes/v2/service-token.spec.ts
Normal file
@ -0,0 +1,579 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
import { SecretType, TSecrets } from "@app/db/schemas";
|
||||||
|
import { decryptSecret, encryptSecret, getUserPrivateKey, seedData1 } from "@app/db/seed-data";
|
||||||
|
import { decryptAsymmetric, decryptSymmetric128BitHexKeyUTF8, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
|
||||||
|
|
||||||
|
const createServiceToken = async (
|
||||||
|
scopes: { environment: string; secretPath: string }[],
|
||||||
|
permissions: ("read" | "write")[]
|
||||||
|
) => {
|
||||||
|
const projectKeyRes = await testServer.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/v2/workspace/${seedData1.project.id}/encrypted-key`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const projectKeyEnc = JSON.parse(projectKeyRes.payload);
|
||||||
|
|
||||||
|
const userInfoRes = await testServer.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v2/users/me",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { user: userInfo } = JSON.parse(userInfoRes.payload);
|
||||||
|
const privateKey = await getUserPrivateKey(seedData1.password, userInfo);
|
||||||
|
const projectKey = decryptAsymmetric({
|
||||||
|
ciphertext: projectKeyEnc.encryptedKey,
|
||||||
|
nonce: projectKeyEnc.nonce,
|
||||||
|
publicKey: projectKeyEnc.sender.publicKey,
|
||||||
|
privateKey
|
||||||
|
});
|
||||||
|
|
||||||
|
const randomBytes = crypto.randomBytes(16).toString("hex");
|
||||||
|
const { ciphertext, iv, tag } = encryptSymmetric128BitHexKeyUTF8(projectKey, randomBytes);
|
||||||
|
const serviceTokenRes = await testServer.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v2/service-token",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
name: "test-token",
|
||||||
|
workspaceId: seedData1.project.id,
|
||||||
|
scopes,
|
||||||
|
encryptedKey: ciphertext,
|
||||||
|
iv,
|
||||||
|
tag,
|
||||||
|
permissions,
|
||||||
|
expiresIn: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(serviceTokenRes.statusCode).toBe(200);
|
||||||
|
const serviceTokenInfo = serviceTokenRes.json();
|
||||||
|
expect(serviceTokenInfo).toHaveProperty("serviceToken");
|
||||||
|
expect(serviceTokenInfo).toHaveProperty("serviceTokenData");
|
||||||
|
return `${serviceTokenInfo.serviceToken}.${randomBytes}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteServiceToken = async () => {
|
||||||
|
const serviceTokenListRes = await testServer.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/v1/workspace/${seedData1.project.id}/service-token-data`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(serviceTokenListRes.statusCode).toBe(200);
|
||||||
|
const serviceTokens = JSON.parse(serviceTokenListRes.payload).serviceTokenData as { name: string; id: string }[];
|
||||||
|
expect(serviceTokens.length).toBeGreaterThan(0);
|
||||||
|
const serviceTokenInfo = serviceTokens.find(({ name }) => name === "test-token");
|
||||||
|
expect(serviceTokenInfo).toBeDefined();
|
||||||
|
|
||||||
|
const deleteTokenRes = await testServer.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/api/v2/service-token/${serviceTokenInfo?.id}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(deleteTokenRes.statusCode).toBe(200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSecret = async (dto: {
|
||||||
|
projectKey: string;
|
||||||
|
path: string;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
comment: string;
|
||||||
|
type?: SecretType;
|
||||||
|
token: string;
|
||||||
|
}) => {
|
||||||
|
const createSecretReqBody = {
|
||||||
|
workspaceId: seedData1.project.id,
|
||||||
|
environment: seedData1.environment.slug,
|
||||||
|
type: dto.type || SecretType.Shared,
|
||||||
|
secretPath: dto.path,
|
||||||
|
...encryptSecret(dto.projectKey, dto.key, dto.value, dto.comment)
|
||||||
|
};
|
||||||
|
const createSecRes = await testServer.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/v3/secrets/${dto.key}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${dto.token}`
|
||||||
|
},
|
||||||
|
body: createSecretReqBody
|
||||||
|
});
|
||||||
|
expect(createSecRes.statusCode).toBe(200);
|
||||||
|
const createdSecretPayload = JSON.parse(createSecRes.payload);
|
||||||
|
expect(createdSecretPayload).toHaveProperty("secret");
|
||||||
|
return createdSecretPayload.secret;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSecret = async (dto: { path: string; key: string; token: string }) => {
|
||||||
|
const deleteSecRes = await testServer.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/api/v3/secrets/${dto.key}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${dto.token}`
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
workspaceId: seedData1.project.id,
|
||||||
|
environment: seedData1.environment.slug,
|
||||||
|
secretPath: dto.path
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(deleteSecRes.statusCode).toBe(200);
|
||||||
|
const updatedSecretPayload = JSON.parse(deleteSecRes.payload);
|
||||||
|
expect(updatedSecretPayload).toHaveProperty("secret");
|
||||||
|
return updatedSecretPayload.secret;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Service token secret ops", async () => {
|
||||||
|
let serviceToken = "";
|
||||||
|
let projectKey = "";
|
||||||
|
let folderId = "";
|
||||||
|
beforeAll(async () => {
|
||||||
|
serviceToken = await createServiceToken(
|
||||||
|
[{ secretPath: "/**", environment: seedData1.environment.slug }],
|
||||||
|
["read", "write"]
|
||||||
|
);
|
||||||
|
|
||||||
|
// this is ensure cli service token decryptiong working fine
|
||||||
|
const serviceTokenInfoRes = await testServer.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v2/service-token",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${serviceToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(serviceTokenInfoRes.statusCode).toBe(200);
|
||||||
|
const serviceTokenInfo = serviceTokenInfoRes.json();
|
||||||
|
const serviceTokenParts = serviceToken.split(".");
|
||||||
|
projectKey = decryptSymmetric128BitHexKeyUTF8({
|
||||||
|
key: serviceTokenParts[3],
|
||||||
|
tag: serviceTokenInfo.tag,
|
||||||
|
ciphertext: serviceTokenInfo.encryptedKey,
|
||||||
|
iv: serviceTokenInfo.iv
|
||||||
|
});
|
||||||
|
|
||||||
|
// create a deep folder
|
||||||
|
const folderCreate = await testServer.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/v1/folders`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
workspaceId: seedData1.project.id,
|
||||||
|
environment: seedData1.environment.slug,
|
||||||
|
name: "folder",
|
||||||
|
path: "/nested1/nested2"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(folderCreate.statusCode).toBe(200);
|
||||||
|
folderId = folderCreate.json().folder.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await deleteServiceToken();
|
||||||
|
|
||||||
|
// create a deep folder
|
||||||
|
const deleteFolder = await testServer.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/api/v1/folders/${folderId}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
workspaceId: seedData1.project.id,
|
||||||
|
environment: seedData1.environment.slug,
|
||||||
|
path: "/nested1/nested2"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(deleteFolder.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
const testSecrets = [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
secret: {
|
||||||
|
key: "ST-SEC",
|
||||||
|
value: "something-secret",
|
||||||
|
comment: "some comment"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/nested1/nested2/folder",
|
||||||
|
secret: {
|
||||||
|
key: "NESTED-ST-SEC",
|
||||||
|
value: "something-secret",
|
||||||
|
comment: "some comment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const getSecrets = async (environment: string, secretPath = "/") => {
|
||||||
|
const res = await testServer.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/v3/secrets`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${serviceToken}`
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
secretPath,
|
||||||
|
environment,
|
||||||
|
workspaceId: seedData1.project.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const secrets: TSecrets[] = JSON.parse(res.payload).secrets || [];
|
||||||
|
return secrets.map((el) => ({ ...decryptSecret(projectKey, el), type: el.type }));
|
||||||
|
};
|
||||||
|
|
||||||
|
test.each(testSecrets)("Create secret in path $path", async ({ secret, path }) => {
|
||||||
|
const createdSecret = await createSecret({ projectKey, path, ...secret, token: serviceToken });
|
||||||
|
const decryptedSecret = decryptSecret(projectKey, createdSecret);
|
||||||
|
expect(decryptedSecret.key).toEqual(secret.key);
|
||||||
|
expect(decryptedSecret.value).toEqual(secret.value);
|
||||||
|
expect(decryptedSecret.comment).toEqual(secret.comment);
|
||||||
|
expect(decryptedSecret.version).toEqual(1);
|
||||||
|
|
||||||
|
const secrets = await getSecrets(seedData1.environment.slug, path);
|
||||||
|
expect(secrets).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
key: secret.key,
|
||||||
|
value: secret.value,
|
||||||
|
type: SecretType.Shared
|
||||||
|
})
|
||||||
|
])
|
||||||
|
);
|
||||||
|
await deleteSecret({ path, key: secret.key, token: serviceToken });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(testSecrets)("Get secret by name in path $path", async ({ secret, path }) => {
|
||||||
|
await createSecret({ projectKey, path, ...secret, token: serviceToken });
|
||||||
|
|
||||||
|
const getSecByNameRes = await testServer.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/v3/secrets/${secret.key}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${serviceToken}`
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
secretPath: path,
|
||||||
|
workspaceId: seedData1.project.id,
|
||||||
|
environment: seedData1.environment.slug
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(getSecByNameRes.statusCode).toBe(200);
|
||||||
|
const getSecretByNamePayload = JSON.parse(getSecByNameRes.payload);
|
||||||
|
expect(getSecretByNamePayload).toHaveProperty("secret");
|
||||||
|
const decryptedSecret = decryptSecret(projectKey, getSecretByNamePayload.secret);
|
||||||
|
expect(decryptedSecret.key).toEqual(secret.key);
|
||||||
|
expect(decryptedSecret.value).toEqual(secret.value);
|
||||||
|
expect(decryptedSecret.comment).toEqual(secret.comment);
|
||||||
|
|
||||||
|
await deleteSecret({ path, key: secret.key, token: serviceToken });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(testSecrets)("Update secret in path $path", async ({ path, secret }) => {
|
||||||
|
await createSecret({ projectKey, path, ...secret, token: serviceToken });
|
||||||
|
const updateSecretReqBody = {
|
||||||
|
workspaceId: seedData1.project.id,
|
||||||
|
environment: seedData1.environment.slug,
|
||||||
|
type: SecretType.Shared,
|
||||||
|
secretPath: path,
|
||||||
|
...encryptSecret(projectKey, secret.key, "new-value", secret.comment)
|
||||||
|
};
|
||||||
|
const updateSecRes = await testServer.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/v3/secrets/${secret.key}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${serviceToken}`
|
||||||
|
},
|
||||||
|
body: updateSecretReqBody
|
||||||
|
});
|
||||||
|
expect(updateSecRes.statusCode).toBe(200);
|
||||||
|
const updatedSecretPayload = JSON.parse(updateSecRes.payload);
|
||||||
|
expect(updatedSecretPayload).toHaveProperty("secret");
|
||||||
|
const decryptedSecret = decryptSecret(projectKey, updatedSecretPayload.secret);
|
||||||
|
expect(decryptedSecret.key).toEqual(secret.key);
|
||||||
|
expect(decryptedSecret.value).toEqual("new-value");
|
||||||
|
expect(decryptedSecret.comment).toEqual(secret.comment);
|
||||||
|
|
||||||
|
// list secret should have updated value
|
||||||
|
const secrets = await getSecrets(seedData1.environment.slug, path);
|
||||||
|
expect(secrets).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
key: secret.key,
|
||||||
|
value: "new-value",
|
||||||
|
type: SecretType.Shared
|
||||||
|
})
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
await deleteSecret({ path, key: secret.key, token: serviceToken });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(testSecrets)("Delete secret in path $path", async ({ secret, path }) => {
|
||||||
|
await createSecret({ projectKey, path, ...secret, token: serviceToken });
|
||||||
|
const deletedSecret = await deleteSecret({ path, key: secret.key, token: serviceToken });
|
||||||
|
const decryptedSecret = decryptSecret(projectKey, deletedSecret);
|
||||||
|
expect(decryptedSecret.key).toEqual(secret.key);
|
||||||
|
|
||||||
|
// shared secret deletion should delete personal ones also
|
||||||
|
const secrets = await getSecrets(seedData1.environment.slug, path);
|
||||||
|
expect(secrets).toEqual(
|
||||||
|
expect.not.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
key: secret.key,
|
||||||
|
type: SecretType.Shared
|
||||||
|
})
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(testSecrets)("Bulk create secrets in path $path", async ({ secret, path }) => {
|
||||||
|
const createSharedSecRes = await testServer.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/v3/secrets/batch`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${serviceToken}`
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
workspaceId: seedData1.project.id,
|
||||||
|
environment: seedData1.environment.slug,
|
||||||
|
secretPath: path,
|
||||||
|
secrets: Array.from(Array(5)).map((_e, i) => ({
|
||||||
|
secretName: `BULK-${secret.key}-${i + 1}`,
|
||||||
|
...encryptSecret(projectKey, `BULK-${secret.key}-${i + 1}`, secret.value, secret.comment)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(createSharedSecRes.statusCode).toBe(200);
|
||||||
|
const createSharedSecPayload = JSON.parse(createSharedSecRes.payload);
|
||||||
|
expect(createSharedSecPayload).toHaveProperty("secrets");
|
||||||
|
|
||||||
|
// bulk ones should exist
|
||||||
|
const secrets = await getSecrets(seedData1.environment.slug, path);
|
||||||
|
expect(secrets).toEqual(
|
||||||
|
expect.arrayContaining(
|
||||||
|
Array.from(Array(5)).map((_e, i) =>
|
||||||
|
expect.objectContaining({
|
||||||
|
key: `BULK-${secret.key}-${i + 1}`,
|
||||||
|
type: SecretType.Shared
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(Array(5)).map((_e, i) =>
|
||||||
|
deleteSecret({ path, token: serviceToken, key: `BULK-${secret.key}-${i + 1}` })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(testSecrets)("Bulk create fail on existing secret in path $path", async ({ secret, path }) => {
|
||||||
|
await createSecret({ projectKey, ...secret, key: `BULK-${secret.key}-1`, path, token: serviceToken });
|
||||||
|
|
||||||
|
const createSharedSecRes = await testServer.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/v3/secrets/batch`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${serviceToken}`
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
workspaceId: seedData1.project.id,
|
||||||
|
environment: seedData1.environment.slug,
|
||||||
|
secretPath: path,
|
||||||
|
secrets: Array.from(Array(5)).map((_e, i) => ({
|
||||||
|
secretName: `BULK-${secret.key}-${i + 1}`,
|
||||||
|
...encryptSecret(projectKey, `BULK-${secret.key}-${i + 1}`, secret.value, secret.comment)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(createSharedSecRes.statusCode).toBe(400);
|
||||||
|
|
||||||
|
await deleteSecret({ path, key: `BULK-${secret.key}-1`, token: serviceToken });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(testSecrets)("Bulk update secrets in path $path", async ({ secret, path }) => {
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(Array(5)).map((_e, i) =>
|
||||||
|
createSecret({ projectKey, token: serviceToken, ...secret, key: `BULK-${secret.key}-${i + 1}`, path })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateSharedSecRes = await testServer.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/v3/secrets/batch`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${serviceToken}`
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
workspaceId: seedData1.project.id,
|
||||||
|
environment: seedData1.environment.slug,
|
||||||
|
secretPath: path,
|
||||||
|
secrets: Array.from(Array(5)).map((_e, i) => ({
|
||||||
|
secretName: `BULK-${secret.key}-${i + 1}`,
|
||||||
|
...encryptSecret(projectKey, `BULK-${secret.key}-${i + 1}`, "update-value", secret.comment)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(updateSharedSecRes.statusCode).toBe(200);
|
||||||
|
const updateSharedSecPayload = JSON.parse(updateSharedSecRes.payload);
|
||||||
|
expect(updateSharedSecPayload).toHaveProperty("secrets");
|
||||||
|
|
||||||
|
// bulk ones should exist
|
||||||
|
const secrets = await getSecrets(seedData1.environment.slug, path);
|
||||||
|
expect(secrets).toEqual(
|
||||||
|
expect.arrayContaining(
|
||||||
|
Array.from(Array(5)).map((_e, i) =>
|
||||||
|
expect.objectContaining({
|
||||||
|
key: `BULK-${secret.key}-${i + 1}`,
|
||||||
|
value: "update-value",
|
||||||
|
type: SecretType.Shared
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(Array(5)).map((_e, i) =>
|
||||||
|
deleteSecret({ path, key: `BULK-${secret.key}-${i + 1}`, token: serviceToken })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(testSecrets)("Bulk delete secrets in path $path", async ({ secret, path }) => {
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(Array(5)).map((_e, i) =>
|
||||||
|
createSecret({ projectKey, token: serviceToken, ...secret, key: `BULK-${secret.key}-${i + 1}`, path })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const deletedSharedSecRes = await testServer.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/api/v3/secrets/batch`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${serviceToken}`
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
workspaceId: seedData1.project.id,
|
||||||
|
environment: seedData1.environment.slug,
|
||||||
|
secretPath: path,
|
||||||
|
secrets: Array.from(Array(5)).map((_e, i) => ({
|
||||||
|
secretName: `BULK-${secret.key}-${i + 1}`
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deletedSharedSecRes.statusCode).toBe(200);
|
||||||
|
const deletedSecretPayload = JSON.parse(deletedSharedSecRes.payload);
|
||||||
|
expect(deletedSecretPayload).toHaveProperty("secrets");
|
||||||
|
|
||||||
|
// bulk ones should exist
|
||||||
|
const secrets = await getSecrets(seedData1.environment.slug, path);
|
||||||
|
expect(secrets).toEqual(
|
||||||
|
expect.not.arrayContaining(
|
||||||
|
Array.from(Array(5)).map((_e, i) =>
|
||||||
|
expect.objectContaining({
|
||||||
|
key: `BULK-${secret.value}-${i + 1}`,
|
||||||
|
type: SecretType.Shared
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Service token fail cases", async () => {
|
||||||
|
test("Unauthorized secret path access", async () => {
|
||||||
|
const serviceToken = await createServiceToken(
|
||||||
|
[{ secretPath: "/", environment: seedData1.environment.slug }],
|
||||||
|
["read", "write"]
|
||||||
|
);
|
||||||
|
const fetchSecrets = await testServer.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v3/secrets",
|
||||||
|
query: {
|
||||||
|
workspaceId: seedData1.project.id,
|
||||||
|
environment: seedData1.environment.slug,
|
||||||
|
secretPath: "/nested/deep"
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${serviceToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(fetchSecrets.statusCode).toBe(401);
|
||||||
|
expect(fetchSecrets.json().error).toBe("PermissionDenied");
|
||||||
|
await deleteServiceToken();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Unauthorized secret environment access", async () => {
|
||||||
|
const serviceToken = await createServiceToken(
|
||||||
|
[{ secretPath: "/", environment: seedData1.environment.slug }],
|
||||||
|
["read", "write"]
|
||||||
|
);
|
||||||
|
const fetchSecrets = await testServer.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v3/secrets",
|
||||||
|
query: {
|
||||||
|
workspaceId: seedData1.project.id,
|
||||||
|
environment: "prod",
|
||||||
|
secretPath: "/"
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${serviceToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(fetchSecrets.statusCode).toBe(401);
|
||||||
|
expect(fetchSecrets.json().error).toBe("PermissionDenied");
|
||||||
|
await deleteServiceToken();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Unauthorized write operation", async () => {
|
||||||
|
const serviceToken = await createServiceToken(
|
||||||
|
[{ secretPath: "/", environment: seedData1.environment.slug }],
|
||||||
|
["read"]
|
||||||
|
);
|
||||||
|
const writeSecrets = await testServer.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/v3/secrets/NEW`,
|
||||||
|
body: {
|
||||||
|
workspaceId: seedData1.project.id,
|
||||||
|
environment: seedData1.environment.slug,
|
||||||
|
type: SecretType.Shared,
|
||||||
|
secretPath: "/",
|
||||||
|
// doesn't matter project key because this will fail before that due to read only access
|
||||||
|
...encryptSecret(crypto.randomBytes(16).toString("hex"), "NEW", "value", "")
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${serviceToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(writeSecrets.statusCode).toBe(401);
|
||||||
|
expect(writeSecrets.json().error).toBe("PermissionDenied");
|
||||||
|
|
||||||
|
// but read access should still work fine
|
||||||
|
const fetchSecrets = await testServer.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v3/secrets",
|
||||||
|
query: {
|
||||||
|
workspaceId: seedData1.project.id,
|
||||||
|
environment: seedData1.environment.slug,
|
||||||
|
secretPath: "/"
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${serviceToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(fetchSecrets.statusCode).toBe(200);
|
||||||
|
await deleteServiceToken();
|
||||||
|
});
|
||||||
|
});
|
File diff suppressed because it is too large
Load Diff
@ -1,26 +1,30 @@
|
|||||||
// import { main } from "@app/server/app";
|
// eslint-disable-next-line
|
||||||
import { initEnvConfig } from "@app/lib/config/env";
|
import "ts-node/register";
|
||||||
|
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
import knex from "knex";
|
import knex from "knex";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { mockSmtpServer } from "./mocks/smtp";
|
|
||||||
import { initLogger } from "@app/lib/logger";
|
|
||||||
import jwt from "jsonwebtoken";
|
|
||||||
|
|
||||||
import "ts-node/register";
|
|
||||||
import { main } from "@app/server/app";
|
|
||||||
import { mockQueue } from "./mocks/queue";
|
|
||||||
import { AuthTokenType } from "@app/services/auth/auth-type";
|
|
||||||
import { seedData1 } from "@app/db/seed-data";
|
import { seedData1 } from "@app/db/seed-data";
|
||||||
|
import { initEnvConfig } from "@app/lib/config/env";
|
||||||
|
import { initLogger } from "@app/lib/logger";
|
||||||
|
import { main } from "@app/server/app";
|
||||||
|
import { AuthTokenType } from "@app/services/auth/auth-type";
|
||||||
|
|
||||||
dotenv.config({ path: path.join(__dirname, "../.env.test") });
|
import { mockQueue } from "./mocks/queue";
|
||||||
|
import { mockSmtpServer } from "./mocks/smtp";
|
||||||
|
|
||||||
|
dotenv.config({ path: path.join(__dirname, "../../.env.test"), debug: true });
|
||||||
export default {
|
export default {
|
||||||
name: "knex-env",
|
name: "knex-env",
|
||||||
transformMode: "ssr",
|
transformMode: "ssr",
|
||||||
async setup() {
|
async setup() {
|
||||||
|
const logger = await initLogger();
|
||||||
|
const cfg = initEnvConfig(logger);
|
||||||
const db = knex({
|
const db = knex({
|
||||||
client: "pg",
|
client: "pg",
|
||||||
connection: process.env.DB_CONNECTION_URI,
|
connection: cfg.DB_CONNECTION_URI,
|
||||||
migrations: {
|
migrations: {
|
||||||
directory: path.join(__dirname, "../src/db/migrations"),
|
directory: path.join(__dirname, "../src/db/migrations"),
|
||||||
extension: "ts",
|
extension: "ts",
|
||||||
@ -37,8 +41,6 @@ export default {
|
|||||||
await db.seed.run();
|
await db.seed.run();
|
||||||
const smtp = mockSmtpServer();
|
const smtp = mockSmtpServer();
|
||||||
const queue = mockQueue();
|
const queue = mockQueue();
|
||||||
const logger = await initLogger();
|
|
||||||
const cfg = initEnvConfig(logger);
|
|
||||||
const server = await main({ db, smtp, logger, queue });
|
const server = await main({ db, smtp, logger, queue });
|
||||||
// @ts-expect-error type
|
// @ts-expect-error type
|
||||||
globalThis.testServer = server;
|
globalThis.testServer = server;
|
||||||
@ -54,6 +56,7 @@ export default {
|
|||||||
{ expiresIn: cfg.JWT_AUTH_LIFETIME }
|
{ expiresIn: cfg.JWT_AUTH_LIFETIME }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log("[TEST] Error setting up environment", error);
|
||||||
await db.destroy();
|
await db.destroy();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
2175
backend/package-lock.json
generated
2175
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -24,8 +24,8 @@
|
|||||||
"migration:latest": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:latest",
|
"migration:latest": "knex --knexfile ./src/db/knexfile.ts --client pg migrate:latest",
|
||||||
"migration:rollback": "knex --knexfile ./src/db/knexfile.ts migrate:rollback",
|
"migration:rollback": "knex --knexfile ./src/db/knexfile.ts migrate:rollback",
|
||||||
"seed:new": "tsx ./scripts/create-seed-file.ts",
|
"seed:new": "tsx ./scripts/create-seed-file.ts",
|
||||||
"seed:run": "knex --knexfile ./src/db/knexfile.ts --client pg seed:run",
|
"seed": "knex --knexfile ./src/db/knexfile.ts --client pg seed:run",
|
||||||
"db:reset": "npm run migration:rollback -- --all && npm run migration:latest && npm run seed:run"
|
"db:reset": "npm run migration:rollback -- --all && npm run migration:latest"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@ -67,10 +67,10 @@
|
|||||||
"tsx": "^4.4.0",
|
"tsx": "^4.4.0",
|
||||||
"typescript": "^5.3.2",
|
"typescript": "^5.3.2",
|
||||||
"vite-tsconfig-paths": "^4.2.2",
|
"vite-tsconfig-paths": "^4.2.2",
|
||||||
"vitest": "^1.0.4"
|
"vitest": "^1.2.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-secrets-manager": "^3.485.0",
|
"@aws-sdk/client-secrets-manager": "^3.502.0",
|
||||||
"@casl/ability": "^6.5.0",
|
"@casl/ability": "^6.5.0",
|
||||||
"@fastify/cookie": "^9.2.0",
|
"@fastify/cookie": "^9.2.0",
|
||||||
"@fastify/cors": "^8.4.1",
|
"@fastify/cors": "^8.4.1",
|
||||||
@ -81,7 +81,7 @@
|
|||||||
"@fastify/rate-limit": "^9.0.0",
|
"@fastify/rate-limit": "^9.0.0",
|
||||||
"@fastify/session": "^10.7.0",
|
"@fastify/session": "^10.7.0",
|
||||||
"@fastify/swagger": "^8.12.0",
|
"@fastify/swagger": "^8.12.0",
|
||||||
"@fastify/swagger-ui": "^1.10.1",
|
"@fastify/swagger-ui": "^2.1.0",
|
||||||
"@node-saml/passport-saml": "^4.0.4",
|
"@node-saml/passport-saml": "^4.0.4",
|
||||||
"@octokit/rest": "^20.0.2",
|
"@octokit/rest": "^20.0.2",
|
||||||
"@octokit/webhooks-types": "^7.3.1",
|
"@octokit/webhooks-types": "^7.3.1",
|
||||||
@ -90,8 +90,8 @@
|
|||||||
"@ucast/mongo2js": "^1.3.4",
|
"@ucast/mongo2js": "^1.3.4",
|
||||||
"ajv": "^8.12.0",
|
"ajv": "^8.12.0",
|
||||||
"argon2": "^0.31.2",
|
"argon2": "^0.31.2",
|
||||||
"aws-sdk": "^2.1532.0",
|
"aws-sdk": "^2.1545.0",
|
||||||
"axios": "^1.6.2",
|
"axios": "^1.6.4",
|
||||||
"axios-retry": "^4.0.0",
|
"axios-retry": "^4.0.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bullmq": "^5.1.1",
|
"bullmq": "^5.1.1",
|
||||||
@ -109,7 +109,8 @@
|
|||||||
"mysql2": "^3.6.5",
|
"mysql2": "^3.6.5",
|
||||||
"nanoid": "^5.0.4",
|
"nanoid": "^5.0.4",
|
||||||
"node-cache": "^5.1.2",
|
"node-cache": "^5.1.2",
|
||||||
"nodemailer": "^6.9.7",
|
"nodemailer": "^6.9.9",
|
||||||
|
"ora": "^7.0.1",
|
||||||
"passport-github": "^1.1.0",
|
"passport-github": "^1.1.0",
|
||||||
"passport-gitlab2": "^5.0.0",
|
"passport-gitlab2": "^5.0.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
@ -117,7 +118,7 @@
|
|||||||
"picomatch": "^3.0.1",
|
"picomatch": "^3.0.1",
|
||||||
"pino": "^8.16.2",
|
"pino": "^8.16.2",
|
||||||
"posthog-node": "^3.6.0",
|
"posthog-node": "^3.6.0",
|
||||||
"probot": "^12.3.3",
|
"probot": "^13.0.0",
|
||||||
"smee-client": "^2.0.0",
|
"smee-client": "^2.0.0",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
"tweetnacl-util": "^0.15.1",
|
"tweetnacl-util": "^0.15.1",
|
||||||
|
@ -7,11 +7,10 @@ import promptSync from "prompt-sync";
|
|||||||
const prompt = promptSync({ sigint: true });
|
const prompt = promptSync({ sigint: true });
|
||||||
|
|
||||||
const migrationName = prompt("Enter name for seedfile: ");
|
const migrationName = prompt("Enter name for seedfile: ");
|
||||||
const fileCounter = readdirSync(path.join(__dirname, "../src/db/seed")).length || 1;
|
const fileCounter = readdirSync(path.join(__dirname, "../src/db/seeds")).length || 1;
|
||||||
execSync(
|
execSync(
|
||||||
`npx knex seed:make --knexfile ${path.join(
|
`npx knex seed:make --knexfile ${path.join(__dirname, "../src/db/knexfile.ts")} -x ts ${
|
||||||
__dirname,
|
fileCounter + 1
|
||||||
"../src/db/knexfile.ts"
|
}-${migrationName}`,
|
||||||
)} -x ts ${fileCounter}-${migrationName}`,
|
|
||||||
{ stdio: "inherit" }
|
{ stdio: "inherit" }
|
||||||
);
|
);
|
||||||
|
2
backend/src/@types/fastify.d.ts
vendored
2
backend/src/@types/fastify.d.ts
vendored
@ -6,6 +6,7 @@ import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
|
|||||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service";
|
import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service";
|
||||||
|
import { TScimServiceFactory } from "@app/ee/services/scim/scim-service";
|
||||||
import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
|
import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
|
||||||
import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-service";
|
import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-service";
|
||||||
import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service";
|
import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service";
|
||||||
@ -105,6 +106,7 @@ declare module "fastify" {
|
|||||||
secretRotation: TSecretRotationServiceFactory;
|
secretRotation: TSecretRotationServiceFactory;
|
||||||
snapshot: TSecretSnapshotServiceFactory;
|
snapshot: TSecretSnapshotServiceFactory;
|
||||||
saml: TSamlConfigServiceFactory;
|
saml: TSamlConfigServiceFactory;
|
||||||
|
scim: TScimServiceFactory;
|
||||||
auditLog: TAuditLogServiceFactory;
|
auditLog: TAuditLogServiceFactory;
|
||||||
secretScanning: TSecretScanningServiceFactory;
|
secretScanning: TSecretScanningServiceFactory;
|
||||||
license: TLicenseServiceFactory;
|
license: TLicenseServiceFactory;
|
||||||
|
4
backend/src/@types/knex.d.ts
vendored
4
backend/src/@types/knex.d.ts
vendored
@ -83,6 +83,9 @@ import {
|
|||||||
TSamlConfigs,
|
TSamlConfigs,
|
||||||
TSamlConfigsInsert,
|
TSamlConfigsInsert,
|
||||||
TSamlConfigsUpdate,
|
TSamlConfigsUpdate,
|
||||||
|
TScimTokens,
|
||||||
|
TScimTokensInsert,
|
||||||
|
TScimTokensUpdate,
|
||||||
TSecretApprovalPolicies,
|
TSecretApprovalPolicies,
|
||||||
TSecretApprovalPoliciesApprovers,
|
TSecretApprovalPoliciesApprovers,
|
||||||
TSecretApprovalPoliciesApproversInsert,
|
TSecretApprovalPoliciesApproversInsert,
|
||||||
@ -262,6 +265,7 @@ declare module "knex/types/tables" {
|
|||||||
TIdentityProjectMembershipsInsert,
|
TIdentityProjectMembershipsInsert,
|
||||||
TIdentityProjectMembershipsUpdate
|
TIdentityProjectMembershipsUpdate
|
||||||
>;
|
>;
|
||||||
|
[TableName.ScimToken]: Knex.CompositeTableType<TScimTokens, TScimTokensInsert, TScimTokensUpdate>;
|
||||||
[TableName.SecretApprovalPolicy]: Knex.CompositeTableType<
|
[TableName.SecretApprovalPolicy]: Knex.CompositeTableType<
|
||||||
TSecretApprovalPolicies,
|
TSecretApprovalPolicies,
|
||||||
TSecretApprovalPoliciesInsert,
|
TSecretApprovalPoliciesInsert,
|
||||||
|
@ -10,6 +10,10 @@ dotenv.config({
|
|||||||
path: path.join(__dirname, "../../../.env.migration"),
|
path: path.join(__dirname, "../../../.env.migration"),
|
||||||
debug: true
|
debug: true
|
||||||
});
|
});
|
||||||
|
dotenv.config({
|
||||||
|
path: path.join(__dirname, "../../../.env"),
|
||||||
|
debug: true
|
||||||
|
});
|
||||||
export default {
|
export default {
|
||||||
development: {
|
development: {
|
||||||
client: "postgres",
|
client: "postgres",
|
||||||
|
31
backend/src/db/migrations/20240208234120_scim-token.ts
Normal file
31
backend/src/db/migrations/20240208234120_scim-token.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "../schemas";
|
||||||
|
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
if (!(await knex.schema.hasTable(TableName.ScimToken))) {
|
||||||
|
await knex.schema.createTable(TableName.ScimToken, (t) => {
|
||||||
|
t.string("id", 36).primary().defaultTo(knex.fn.uuid());
|
||||||
|
t.bigInteger("ttlDays").defaultTo(365).notNullable();
|
||||||
|
t.string("description").notNullable();
|
||||||
|
t.uuid("orgId").notNullable();
|
||||||
|
t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE");
|
||||||
|
t.timestamps(true, true, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await knex.schema.alterTable(TableName.Organization, (t) => {
|
||||||
|
t.boolean("scimEnabled").defaultTo(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await createOnUpdateTrigger(knex, TableName.ScimToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
await knex.schema.dropTableIfExists(TableName.ScimToken);
|
||||||
|
await dropOnUpdateTrigger(knex, TableName.ScimToken);
|
||||||
|
await knex.schema.alterTable(TableName.Organization, (t) => {
|
||||||
|
t.dropColumn("scimEnabled");
|
||||||
|
});
|
||||||
|
}
|
39
backend/src/db/migrations/20240216154123_ghost_users.ts
Normal file
39
backend/src/db/migrations/20240216154123_ghost_users.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { ProjectVersion, TableName } from "../schemas";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
const hasGhostUserColumn = await knex.schema.hasColumn(TableName.Users, "isGhost");
|
||||||
|
const hasProjectVersionColumn = await knex.schema.hasColumn(TableName.Project, "version");
|
||||||
|
|
||||||
|
if (!hasGhostUserColumn) {
|
||||||
|
await knex.schema.alterTable(TableName.Users, (t) => {
|
||||||
|
t.boolean("isGhost").defaultTo(false).notNullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasProjectVersionColumn) {
|
||||||
|
await knex.schema.alterTable(TableName.Project, (t) => {
|
||||||
|
t.integer("version").defaultTo(ProjectVersion.V1).notNullable();
|
||||||
|
t.string("upgradeStatus").nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
const hasGhostUserColumn = await knex.schema.hasColumn(TableName.Users, "isGhost");
|
||||||
|
const hasProjectVersionColumn = await knex.schema.hasColumn(TableName.Project, "version");
|
||||||
|
|
||||||
|
if (hasGhostUserColumn) {
|
||||||
|
await knex.schema.alterTable(TableName.Users, (t) => {
|
||||||
|
t.dropColumn("isGhost");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasProjectVersionColumn) {
|
||||||
|
await knex.schema.alterTable(TableName.Project, (t) => {
|
||||||
|
t.dropColumn("version");
|
||||||
|
t.dropColumn("upgradeStatus");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -26,6 +26,7 @@ export * from "./project-memberships";
|
|||||||
export * from "./project-roles";
|
export * from "./project-roles";
|
||||||
export * from "./projects";
|
export * from "./projects";
|
||||||
export * from "./saml-configs";
|
export * from "./saml-configs";
|
||||||
|
export * from "./scim-tokens";
|
||||||
export * from "./secret-approval-policies";
|
export * from "./secret-approval-policies";
|
||||||
export * from "./secret-approval-policies-approvers";
|
export * from "./secret-approval-policies-approvers";
|
||||||
export * from "./secret-approval-request-secret-tags";
|
export * from "./secret-approval-request-secret-tags";
|
||||||
|
@ -40,6 +40,7 @@ export enum TableName {
|
|||||||
IdentityUaClientSecret = "identity_ua_client_secrets",
|
IdentityUaClientSecret = "identity_ua_client_secrets",
|
||||||
IdentityOrgMembership = "identity_org_memberships",
|
IdentityOrgMembership = "identity_org_memberships",
|
||||||
IdentityProjectMembership = "identity_project_memberships",
|
IdentityProjectMembership = "identity_project_memberships",
|
||||||
|
ScimToken = "scim_tokens",
|
||||||
SecretApprovalPolicy = "secret_approval_policies",
|
SecretApprovalPolicy = "secret_approval_policies",
|
||||||
SecretApprovalPolicyApprover = "secret_approval_policies_approvers",
|
SecretApprovalPolicyApprover = "secret_approval_policies_approvers",
|
||||||
SecretApprovalRequest = "secret_approval_requests",
|
SecretApprovalRequest = "secret_approval_requests",
|
||||||
@ -111,6 +112,17 @@ export enum SecretType {
|
|||||||
Personal = "personal"
|
Personal = "personal"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ProjectVersion {
|
||||||
|
V1 = 1,
|
||||||
|
V2 = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ProjectUpgradeStatus {
|
||||||
|
InProgress = "IN_PROGRESS",
|
||||||
|
// Completed -> Will be null if completed. So a completed status is not needed
|
||||||
|
Failed = "FAILED"
|
||||||
|
}
|
||||||
|
|
||||||
export enum IdentityAuthMethod {
|
export enum IdentityAuthMethod {
|
||||||
Univeral = "universal-auth"
|
Univeral = "universal-auth"
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,8 @@ export const OrganizationsSchema = z.object({
|
|||||||
slug: z.string(),
|
slug: z.string(),
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: z.date(),
|
updatedAt: z.date(),
|
||||||
authEnforced: z.boolean().default(false).nullable().optional()
|
authEnforced: z.boolean().default(false).nullable().optional(),
|
||||||
|
scimEnabled: z.boolean().default(false).nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TOrganizations = z.infer<typeof OrganizationsSchema>;
|
export type TOrganizations = z.infer<typeof OrganizationsSchema>;
|
||||||
|
@ -14,7 +14,9 @@ export const ProjectsSchema = z.object({
|
|||||||
autoCapitalization: z.boolean().default(true).nullable().optional(),
|
autoCapitalization: z.boolean().default(true).nullable().optional(),
|
||||||
orgId: z.string().uuid(),
|
orgId: z.string().uuid(),
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: z.date()
|
updatedAt: z.date(),
|
||||||
|
version: z.number().default(1),
|
||||||
|
upgradeStatus: z.string().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TProjects = z.infer<typeof ProjectsSchema>;
|
export type TProjects = z.infer<typeof ProjectsSchema>;
|
||||||
|
21
backend/src/db/schemas/scim-tokens.ts
Normal file
21
backend/src/db/schemas/scim-tokens.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
// Code generated by automation script, DO NOT EDIT.
|
||||||
|
// Automated by pulling database and generating zod schema
|
||||||
|
// To update. Just run npm run generate:schema
|
||||||
|
// Written by akhilmhdh.
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { TImmutableDBKeys } from "./models";
|
||||||
|
|
||||||
|
export const ScimTokensSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
ttlDays: z.coerce.number().default(365),
|
||||||
|
description: z.string(),
|
||||||
|
orgId: z.string().uuid(),
|
||||||
|
createdAt: z.date(),
|
||||||
|
updatedAt: z.date()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TScimTokens = z.infer<typeof ScimTokensSchema>;
|
||||||
|
export type TScimTokensInsert = Omit<TScimTokens, TImmutableDBKeys>;
|
||||||
|
export type TScimTokensUpdate = Partial<Omit<TScimTokens, TImmutableDBKeys>>;
|
@ -19,7 +19,8 @@ export const UsersSchema = z.object({
|
|||||||
mfaMethods: z.string().array().nullable().optional(),
|
mfaMethods: z.string().array().nullable().optional(),
|
||||||
devices: z.unknown().nullable().optional(),
|
devices: z.unknown().nullable().optional(),
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
updatedAt: z.date()
|
updatedAt: z.date(),
|
||||||
|
isGhost: z.boolean().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TUsers = z.infer<typeof UsersSchema>;
|
export type TUsers = z.infer<typeof UsersSchema>;
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable import/no-mutable-exports */
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
import argon2, { argon2id } from "argon2";
|
import argon2, { argon2id } from "argon2";
|
||||||
@ -6,17 +7,21 @@ import nacl from "tweetnacl";
|
|||||||
import { encodeBase64 } from "tweetnacl-util";
|
import { encodeBase64 } from "tweetnacl-util";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
decryptAsymmetric,
|
||||||
// decryptAsymmetric,
|
// decryptAsymmetric,
|
||||||
decryptSymmetric,
|
decryptSymmetric128BitHexKeyUTF8,
|
||||||
encryptAsymmetric,
|
encryptAsymmetric,
|
||||||
encryptSymmetric
|
encryptSymmetric128BitHexKeyUTF8
|
||||||
} from "@app/lib/crypto";
|
} from "@app/lib/crypto";
|
||||||
|
|
||||||
import { TUserEncryptionKeys } from "./schemas";
|
import { TSecrets, TUserEncryptionKeys } from "./schemas";
|
||||||
|
|
||||||
|
export let userPrivateKey: string | undefined;
|
||||||
|
export let userPublicKey: string | undefined;
|
||||||
|
|
||||||
export const seedData1 = {
|
export const seedData1 = {
|
||||||
id: "3dafd81d-4388-432b-a4c5-f735616868c1",
|
id: "3dafd81d-4388-432b-a4c5-f735616868c1",
|
||||||
email: "test@localhost.local",
|
email: process.env.TEST_USER_EMAIL || "test@localhost.local",
|
||||||
password: process.env.TEST_USER_PASSWORD || "testInfisical@1",
|
password: process.env.TEST_USER_PASSWORD || "testInfisical@1",
|
||||||
organization: {
|
organization: {
|
||||||
id: "180870b7-f464-4740-8ffe-9d11c9245ea7",
|
id: "180870b7-f464-4740-8ffe-9d11c9245ea7",
|
||||||
@ -31,8 +36,22 @@ export const seedData1 = {
|
|||||||
name: "Development",
|
name: "Development",
|
||||||
slug: "dev"
|
slug: "dev"
|
||||||
},
|
},
|
||||||
|
machineIdentity: {
|
||||||
|
id: "88fa7aed-9288-401e-a4c9-fa9430be62a0",
|
||||||
|
name: "mac1",
|
||||||
|
clientCredentials: {
|
||||||
|
id: "3f6135db-f237-421d-af66-a8f4e80d443b",
|
||||||
|
secret: "da35a5a5a7b57f977a9a73394506e878a7175d06606df43dc93e1472b10cf339"
|
||||||
|
}
|
||||||
|
},
|
||||||
token: {
|
token: {
|
||||||
id: "a9dfafba-a3b7-42e3-8618-91abb702fd36"
|
id: "a9dfafba-a3b7-42e3-8618-91abb702fd36"
|
||||||
|
},
|
||||||
|
|
||||||
|
// We set these values during user creation, and later re-use them during project seeding.
|
||||||
|
encryptionKeys: {
|
||||||
|
publicKey: "",
|
||||||
|
privateKey: ""
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -73,7 +92,7 @@ export const generateUserSrpKeys = async (password: string) => {
|
|||||||
ciphertext: encryptedPrivateKey,
|
ciphertext: encryptedPrivateKey,
|
||||||
iv: encryptedPrivateKeyIV,
|
iv: encryptedPrivateKeyIV,
|
||||||
tag: encryptedPrivateKeyTag
|
tag: encryptedPrivateKeyTag
|
||||||
} = encryptSymmetric(privateKey, key.toString("base64"));
|
} = encryptSymmetric128BitHexKeyUTF8(privateKey, key);
|
||||||
|
|
||||||
// create the protected key by encrypting the symmetric key
|
// create the protected key by encrypting the symmetric key
|
||||||
// [key] with the derived key
|
// [key] with the derived key
|
||||||
@ -81,7 +100,7 @@ export const generateUserSrpKeys = async (password: string) => {
|
|||||||
ciphertext: protectedKey,
|
ciphertext: protectedKey,
|
||||||
iv: protectedKeyIV,
|
iv: protectedKeyIV,
|
||||||
tag: protectedKeyTag
|
tag: protectedKeyTag
|
||||||
} = encryptSymmetric(key.toString("hex"), derivedKey.toString("base64"));
|
} = encryptSymmetric128BitHexKeyUTF8(key.toString("hex"), derivedKey);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
protectedKey,
|
protectedKey,
|
||||||
@ -107,32 +126,102 @@ export const getUserPrivateKey = async (password: string, user: TUserEncryptionK
|
|||||||
raw: true
|
raw: true
|
||||||
});
|
});
|
||||||
if (!derivedKey) throw new Error("Failed to derive key from password");
|
if (!derivedKey) throw new Error("Failed to derive key from password");
|
||||||
const key = decryptSymmetric({
|
|
||||||
|
const key = decryptSymmetric128BitHexKeyUTF8({
|
||||||
ciphertext: user.protectedKey as string,
|
ciphertext: user.protectedKey as string,
|
||||||
iv: user.protectedKeyIV as string,
|
iv: user.protectedKeyIV as string,
|
||||||
tag: user.protectedKeyTag as string,
|
tag: user.protectedKeyTag as string,
|
||||||
key: derivedKey.toString("base64")
|
key: derivedKey
|
||||||
});
|
});
|
||||||
const privateKey = decryptSymmetric({
|
|
||||||
|
const privateKey = decryptSymmetric128BitHexKeyUTF8({
|
||||||
ciphertext: user.encryptedPrivateKey,
|
ciphertext: user.encryptedPrivateKey,
|
||||||
iv: user.iv,
|
iv: user.iv,
|
||||||
tag: user.tag,
|
tag: user.tag,
|
||||||
key
|
key: Buffer.from(key, "hex")
|
||||||
});
|
});
|
||||||
return privateKey;
|
return privateKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildUserProjectKey = async (privateKey: string, publickey: string) => {
|
export const buildUserProjectKey = (privateKey: string, publickey: string) => {
|
||||||
const randomBytes = crypto.randomBytes(16).toString("hex");
|
const randomBytes = crypto.randomBytes(16).toString("hex");
|
||||||
const { nonce, ciphertext } = encryptAsymmetric(randomBytes, publickey, privateKey);
|
const { nonce, ciphertext } = encryptAsymmetric(randomBytes, publickey, privateKey);
|
||||||
return { nonce, ciphertext };
|
return { nonce, ciphertext };
|
||||||
};
|
};
|
||||||
|
|
||||||
// export const getUserProjectKey = async (privateKey: string) => {
|
export const getUserProjectKey = async (privateKey: string, ciphertext: string, nonce: string, publicKey: string) => {
|
||||||
// const key = decryptAsymmetric({
|
return decryptAsymmetric({
|
||||||
// ciphertext: decryptFileKey.encryptedKey,
|
ciphertext,
|
||||||
// nonce: decryptFileKey.nonce,
|
nonce,
|
||||||
// publicKey: decryptFileKey.sender.publicKey,
|
publicKey,
|
||||||
// privateKey: PRIVATE_KEY
|
privateKey
|
||||||
// });
|
});
|
||||||
// };
|
};
|
||||||
|
|
||||||
|
export const encryptSecret = (encKey: string, key: string, value?: string, comment?: string) => {
|
||||||
|
// encrypt key
|
||||||
|
const {
|
||||||
|
ciphertext: secretKeyCiphertext,
|
||||||
|
iv: secretKeyIV,
|
||||||
|
tag: secretKeyTag
|
||||||
|
} = encryptSymmetric128BitHexKeyUTF8(key, encKey);
|
||||||
|
|
||||||
|
// encrypt value
|
||||||
|
const {
|
||||||
|
ciphertext: secretValueCiphertext,
|
||||||
|
iv: secretValueIV,
|
||||||
|
tag: secretValueTag
|
||||||
|
} = encryptSymmetric128BitHexKeyUTF8(value ?? "", encKey);
|
||||||
|
|
||||||
|
// encrypt comment
|
||||||
|
const {
|
||||||
|
ciphertext: secretCommentCiphertext,
|
||||||
|
iv: secretCommentIV,
|
||||||
|
tag: secretCommentTag
|
||||||
|
} = encryptSymmetric128BitHexKeyUTF8(comment ?? "", encKey);
|
||||||
|
|
||||||
|
return {
|
||||||
|
secretKeyCiphertext,
|
||||||
|
secretKeyIV,
|
||||||
|
secretKeyTag,
|
||||||
|
secretValueCiphertext,
|
||||||
|
secretValueIV,
|
||||||
|
secretValueTag,
|
||||||
|
secretCommentCiphertext,
|
||||||
|
secretCommentIV,
|
||||||
|
secretCommentTag
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decryptSecret = (decryptKey: string, encSecret: TSecrets) => {
|
||||||
|
const secretKey = decryptSymmetric128BitHexKeyUTF8({
|
||||||
|
key: decryptKey,
|
||||||
|
ciphertext: encSecret.secretKeyCiphertext,
|
||||||
|
tag: encSecret.secretKeyTag,
|
||||||
|
iv: encSecret.secretKeyIV
|
||||||
|
});
|
||||||
|
|
||||||
|
const secretValue = decryptSymmetric128BitHexKeyUTF8({
|
||||||
|
key: decryptKey,
|
||||||
|
ciphertext: encSecret.secretValueCiphertext,
|
||||||
|
tag: encSecret.secretValueTag,
|
||||||
|
iv: encSecret.secretValueIV
|
||||||
|
});
|
||||||
|
|
||||||
|
const secretComment =
|
||||||
|
encSecret.secretCommentIV && encSecret.secretCommentTag && encSecret.secretCommentCiphertext
|
||||||
|
? decryptSymmetric128BitHexKeyUTF8({
|
||||||
|
key: decryptKey,
|
||||||
|
ciphertext: encSecret.secretCommentCiphertext,
|
||||||
|
tag: encSecret.secretCommentTag,
|
||||||
|
iv: encSecret.secretCommentIV
|
||||||
|
})
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: secretKey,
|
||||||
|
value: secretValue,
|
||||||
|
comment: secretComment,
|
||||||
|
version: encSecret.version
|
||||||
|
};
|
||||||
|
};
|
||||||
|
@ -14,7 +14,8 @@ export async function seed(knex: Knex): Promise<void> {
|
|||||||
const [user] = await knex(TableName.Users)
|
const [user] = await knex(TableName.Users)
|
||||||
.insert([
|
.insert([
|
||||||
{
|
{
|
||||||
// @ts-expect-error exluded type id needs to be inserted here to keep it testable
|
// eslint-disable-next-line
|
||||||
|
// @ts-ignore
|
||||||
id: seedData1.id,
|
id: seedData1.id,
|
||||||
email: seedData1.email,
|
email: seedData1.email,
|
||||||
superAdmin: true,
|
superAdmin: true,
|
||||||
@ -48,7 +49,8 @@ export async function seed(knex: Knex): Promise<void> {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
await knex(TableName.AuthTokenSession).insert({
|
await knex(TableName.AuthTokenSession).insert({
|
||||||
// @ts-expect-error exluded type id needs to be inserted here to keep it testable
|
// eslint-disable-next-line
|
||||||
|
// @ts-ignore
|
||||||
id: seedData1.token.id,
|
id: seedData1.token.id,
|
||||||
userId: seedData1.id,
|
userId: seedData1.id,
|
||||||
ip: "151.196.220.213",
|
ip: "151.196.220.213",
|
||||||
|
@ -14,7 +14,8 @@ export async function seed(knex: Knex): Promise<void> {
|
|||||||
const [org] = await knex(TableName.Organization)
|
const [org] = await knex(TableName.Organization)
|
||||||
.insert([
|
.insert([
|
||||||
{
|
{
|
||||||
// @ts-expect-error exluded type id needs to be inserted here to keep it testable
|
// eslint-disable-next-line
|
||||||
|
// @ts-ignore
|
||||||
id: seedData1.organization.id,
|
id: seedData1.organization.id,
|
||||||
name: "infisical",
|
name: "infisical",
|
||||||
slug: "infisical",
|
slug: "infisical",
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
import { Knex } from "knex";
|
import { Knex } from "knex";
|
||||||
|
|
||||||
import { OrgMembershipRole, TableName } from "../schemas";
|
import { encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
|
||||||
import { seedData1 } from "../seed-data";
|
|
||||||
|
import { OrgMembershipRole, SecretEncryptionAlgo, SecretKeyEncoding, TableName } from "../schemas";
|
||||||
|
import { buildUserProjectKey, getUserPrivateKey, seedData1 } from "../seed-data";
|
||||||
|
|
||||||
export const DEFAULT_PROJECT_ENVS = [
|
export const DEFAULT_PROJECT_ENVS = [
|
||||||
{ name: "Development", slug: "dev" },
|
{ name: "Development", slug: "dev" },
|
||||||
@ -20,21 +24,32 @@ export async function seed(knex: Knex): Promise<void> {
|
|||||||
name: seedData1.project.name,
|
name: seedData1.project.name,
|
||||||
orgId: seedData1.organization.id,
|
orgId: seedData1.organization.id,
|
||||||
slug: "first-project",
|
slug: "first-project",
|
||||||
// @ts-expect-error exluded type id needs to be inserted here to keep it testable
|
// eslint-disable-next-line
|
||||||
|
// @ts-ignore
|
||||||
id: seedData1.project.id
|
id: seedData1.project.id
|
||||||
})
|
})
|
||||||
.returning("*");
|
.returning("*");
|
||||||
|
|
||||||
// await knex(TableName.ProjectKeys).insert({
|
|
||||||
// projectId: project.id,
|
|
||||||
// senderId: seedData1.id
|
|
||||||
// });
|
|
||||||
|
|
||||||
await knex(TableName.ProjectMembership).insert({
|
await knex(TableName.ProjectMembership).insert({
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
role: OrgMembershipRole.Admin,
|
role: OrgMembershipRole.Admin,
|
||||||
userId: seedData1.id
|
userId: seedData1.id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const user = await knex(TableName.UserEncryptionKey).where({ userId: seedData1.id }).first();
|
||||||
|
if (!user) throw new Error("User not found");
|
||||||
|
|
||||||
|
const userPrivateKey = await getUserPrivateKey(seedData1.password, user);
|
||||||
|
const projectKey = buildUserProjectKey(userPrivateKey, user.publicKey);
|
||||||
|
await knex(TableName.ProjectKeys).insert({
|
||||||
|
projectId: project.id,
|
||||||
|
nonce: projectKey.nonce,
|
||||||
|
encryptedKey: projectKey.ciphertext,
|
||||||
|
receiverId: seedData1.id,
|
||||||
|
senderId: seedData1.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// create default environments and default folders
|
||||||
const envs = await knex(TableName.Environment)
|
const envs = await knex(TableName.Environment)
|
||||||
.insert(
|
.insert(
|
||||||
DEFAULT_PROJECT_ENVS.map(({ name, slug }, index) => ({
|
DEFAULT_PROJECT_ENVS.map(({ name, slug }, index) => ({
|
||||||
@ -46,4 +61,19 @@ export async function seed(knex: Knex): Promise<void> {
|
|||||||
)
|
)
|
||||||
.returning("*");
|
.returning("*");
|
||||||
await knex(TableName.SecretFolder).insert(envs.map(({ id }) => ({ name: "root", envId: id, parentId: null })));
|
await knex(TableName.SecretFolder).insert(envs.map(({ id }) => ({ name: "root", envId: id, parentId: null })));
|
||||||
|
|
||||||
|
// save secret secret blind index
|
||||||
|
const encKey = process.env.ENCRYPTION_KEY;
|
||||||
|
if (!encKey) throw new Error("Missing ENCRYPTION_KEY");
|
||||||
|
const salt = crypto.randomBytes(16).toString("base64");
|
||||||
|
const secretBlindIndex = encryptSymmetric128BitHexKeyUTF8(salt, encKey);
|
||||||
|
// insert secret blind index for project
|
||||||
|
await knex(TableName.SecretBlindIndex).insert({
|
||||||
|
projectId: project.id,
|
||||||
|
encryptedSaltCipherText: secretBlindIndex.ciphertext,
|
||||||
|
saltIV: secretBlindIndex.iv,
|
||||||
|
saltTag: secretBlindIndex.tag,
|
||||||
|
algorithm: SecretEncryptionAlgo.AES_256_GCM,
|
||||||
|
keyEncoding: SecretKeyEncoding.UTF8
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
83
backend/src/db/seeds/4-machine-identity.ts
Normal file
83
backend/src/db/seeds/4-machine-identity.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { IdentityAuthMethod, OrgMembershipRole, ProjectMembershipRole, TableName } from "../schemas";
|
||||||
|
import { seedData1 } from "../seed-data";
|
||||||
|
|
||||||
|
export async function seed(knex: Knex): Promise<void> {
|
||||||
|
// Deletes ALL existing entries
|
||||||
|
await knex(TableName.Identity).del();
|
||||||
|
await knex(TableName.IdentityOrgMembership).del();
|
||||||
|
|
||||||
|
// Inserts seed entries
|
||||||
|
await knex(TableName.Identity).insert([
|
||||||
|
{
|
||||||
|
// eslint-disable-next-line
|
||||||
|
// @ts-ignore
|
||||||
|
id: seedData1.machineIdentity.id,
|
||||||
|
name: seedData1.machineIdentity.name,
|
||||||
|
authMethod: IdentityAuthMethod.Univeral
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const identityUa = await knex(TableName.IdentityUniversalAuth)
|
||||||
|
.insert([
|
||||||
|
{
|
||||||
|
identityId: seedData1.machineIdentity.id,
|
||||||
|
clientId: seedData1.machineIdentity.clientCredentials.id,
|
||||||
|
clientSecretTrustedIps: JSON.stringify([
|
||||||
|
{
|
||||||
|
type: "ipv4",
|
||||||
|
prefix: 0,
|
||||||
|
ipAddress: "0.0.0.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "ipv6",
|
||||||
|
prefix: 0,
|
||||||
|
ipAddress: "::"
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
accessTokenTrustedIps: JSON.stringify([
|
||||||
|
{
|
||||||
|
type: "ipv4",
|
||||||
|
prefix: 0,
|
||||||
|
ipAddress: "0.0.0.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "ipv6",
|
||||||
|
prefix: 0,
|
||||||
|
ipAddress: "::"
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
accessTokenTTL: 2592000,
|
||||||
|
accessTokenMaxTTL: 2592000,
|
||||||
|
accessTokenNumUsesLimit: 0
|
||||||
|
}
|
||||||
|
])
|
||||||
|
.returning("*");
|
||||||
|
const clientSecretHash = await bcrypt.hash(seedData1.machineIdentity.clientCredentials.secret, 10);
|
||||||
|
await knex(TableName.IdentityUaClientSecret).insert([
|
||||||
|
{
|
||||||
|
identityUAId: identityUa[0].id,
|
||||||
|
description: "",
|
||||||
|
clientSecretTTL: 0,
|
||||||
|
clientSecretNumUses: 0,
|
||||||
|
clientSecretNumUsesLimit: 0,
|
||||||
|
clientSecretPrefix: seedData1.machineIdentity.clientCredentials.secret.slice(0, 4),
|
||||||
|
clientSecretHash,
|
||||||
|
isClientSecretRevoked: false
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
await knex(TableName.IdentityOrgMembership).insert([
|
||||||
|
{
|
||||||
|
identityId: seedData1.machineIdentity.id,
|
||||||
|
orgId: seedData1.organization.id,
|
||||||
|
role: OrgMembershipRole.Admin
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await knex(TableName.IdentityProjectMembership).insert({
|
||||||
|
identityId: seedData1.machineIdentity.id,
|
||||||
|
role: ProjectMembershipRole.Admin,
|
||||||
|
projectId: seedData1.project.id
|
||||||
|
});
|
||||||
|
}
|
@ -3,6 +3,7 @@ import { registerOrgRoleRouter } from "./org-role-router";
|
|||||||
import { registerProjectRoleRouter } from "./project-role-router";
|
import { registerProjectRoleRouter } from "./project-role-router";
|
||||||
import { registerProjectRouter } from "./project-router";
|
import { registerProjectRouter } from "./project-router";
|
||||||
import { registerSamlRouter } from "./saml-router";
|
import { registerSamlRouter } from "./saml-router";
|
||||||
|
import { registerScimRouter } from "./scim-router";
|
||||||
import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router";
|
import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router";
|
||||||
import { registerSecretApprovalRequestRouter } from "./secret-approval-request-router";
|
import { registerSecretApprovalRequestRouter } from "./secret-approval-request-router";
|
||||||
import { registerSecretRotationProviderRouter } from "./secret-rotation-provider-router";
|
import { registerSecretRotationProviderRouter } from "./secret-rotation-provider-router";
|
||||||
@ -33,6 +34,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
|
|||||||
prefix: "/secret-rotation-providers"
|
prefix: "/secret-rotation-providers"
|
||||||
});
|
});
|
||||||
await server.register(registerSamlRouter, { prefix: "/sso" });
|
await server.register(registerSamlRouter, { prefix: "/sso" });
|
||||||
|
await server.register(registerScimRouter, { prefix: "/scim" });
|
||||||
await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" });
|
await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" });
|
||||||
await server.register(registerSecretRotationRouter, { prefix: "/secret-rotations" });
|
await server.register(registerSecretRotationRouter, { prefix: "/secret-rotations" });
|
||||||
await server.register(registerSecretVersionRouter, { prefix: "/secret" });
|
await server.register(registerSecretVersionRouter, { prefix: "/secret" });
|
||||||
|
331
backend/src/ee/routes/v1/scim-router.ts
Normal file
331
backend/src/ee/routes/v1/scim-router.ts
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { ScimTokensSchema } from "@app/db/schemas";
|
||||||
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
|
|
||||||
|
export const registerScimRouter = async (server: FastifyZodProvider) => {
|
||||||
|
server.addContentTypeParser("application/scim+json", { parseAs: "string" }, function (req, body, done) {
|
||||||
|
try {
|
||||||
|
const strBody = body instanceof Buffer ? body.toString() : body;
|
||||||
|
|
||||||
|
const json: unknown = JSON.parse(strBody);
|
||||||
|
done(null, json);
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error;
|
||||||
|
done(error, undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/scim-tokens",
|
||||||
|
method: "POST",
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
schema: {
|
||||||
|
body: z.object({
|
||||||
|
organizationId: z.string().trim(),
|
||||||
|
description: z.string().trim().default(""),
|
||||||
|
ttlDays: z.number().min(0).default(0)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
scimToken: z.string().trim()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async (req) => {
|
||||||
|
const { scimToken } = await server.services.scim.createScimToken({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
orgId: req.body.organizationId,
|
||||||
|
description: req.body.description,
|
||||||
|
ttlDays: req.body.ttlDays
|
||||||
|
});
|
||||||
|
|
||||||
|
return { scimToken };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/scim-tokens",
|
||||||
|
method: "GET",
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
schema: {
|
||||||
|
querystring: z.object({
|
||||||
|
organizationId: z.string().trim()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
scimTokens: z.array(ScimTokensSchema)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async (req) => {
|
||||||
|
const scimTokens = await server.services.scim.listScimTokens({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
orgId: req.query.organizationId
|
||||||
|
});
|
||||||
|
|
||||||
|
return { scimTokens };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/scim-tokens/:scimTokenId",
|
||||||
|
method: "DELETE",
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
scimTokenId: z.string().trim()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
scimToken: ScimTokensSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handler: async (req) => {
|
||||||
|
const scimToken = await server.services.scim.deleteScimToken({
|
||||||
|
scimTokenId: req.params.scimTokenId,
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorOrgId: req.permission.orgId
|
||||||
|
});
|
||||||
|
|
||||||
|
return { scimToken };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SCIM server endpoints
|
||||||
|
server.route({
|
||||||
|
url: "/Users",
|
||||||
|
method: "GET",
|
||||||
|
schema: {
|
||||||
|
querystring: z.object({
|
||||||
|
startIndex: z.coerce.number().default(1),
|
||||||
|
count: z.coerce.number().default(20),
|
||||||
|
filter: z.string().trim().optional()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
Resources: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().trim(),
|
||||||
|
userName: z.string().trim(),
|
||||||
|
name: z.object({
|
||||||
|
familyName: z.string().trim(),
|
||||||
|
givenName: z.string().trim()
|
||||||
|
}),
|
||||||
|
emails: z.array(
|
||||||
|
z.object({
|
||||||
|
primary: z.boolean(),
|
||||||
|
value: z.string().email(),
|
||||||
|
type: z.string().trim()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
displayName: z.string().trim(),
|
||||||
|
active: z.boolean()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
itemsPerPage: z.number(),
|
||||||
|
schemas: z.array(z.string()),
|
||||||
|
startIndex: z.number(),
|
||||||
|
totalResults: z.number()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const users = await req.server.services.scim.listScimUsers({
|
||||||
|
offset: req.query.startIndex,
|
||||||
|
limit: req.query.count,
|
||||||
|
filter: req.query.filter,
|
||||||
|
orgId: req.permission.orgId as string
|
||||||
|
});
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/Users/:userId",
|
||||||
|
method: "GET",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
userId: z.string().trim()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
201: z.object({
|
||||||
|
schemas: z.array(z.string()),
|
||||||
|
id: z.string().trim(),
|
||||||
|
userName: z.string().trim(),
|
||||||
|
name: z.object({
|
||||||
|
familyName: z.string().trim(),
|
||||||
|
givenName: z.string().trim()
|
||||||
|
}),
|
||||||
|
emails: z.array(
|
||||||
|
z.object({
|
||||||
|
primary: z.boolean(),
|
||||||
|
value: z.string().email(),
|
||||||
|
type: z.string().trim()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
displayName: z.string().trim(),
|
||||||
|
active: z.boolean()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const user = await req.server.services.scim.getScimUser({
|
||||||
|
userId: req.params.userId,
|
||||||
|
orgId: req.permission.orgId as string
|
||||||
|
});
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/Users",
|
||||||
|
method: "POST",
|
||||||
|
schema: {
|
||||||
|
body: z.object({
|
||||||
|
schemas: z.array(z.string()),
|
||||||
|
userName: z.string().trim().email(),
|
||||||
|
name: z.object({
|
||||||
|
familyName: z.string().trim(),
|
||||||
|
givenName: z.string().trim()
|
||||||
|
}),
|
||||||
|
// emails: z.array( // optional?
|
||||||
|
// z.object({
|
||||||
|
// primary: z.boolean(),
|
||||||
|
// value: z.string().email(),
|
||||||
|
// type: z.string().trim()
|
||||||
|
// })
|
||||||
|
// ),
|
||||||
|
// displayName: z.string().trim(),
|
||||||
|
active: z.boolean()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
schemas: z.array(z.string()),
|
||||||
|
id: z.string().trim(),
|
||||||
|
userName: z.string().trim().email(),
|
||||||
|
name: z.object({
|
||||||
|
familyName: z.string().trim(),
|
||||||
|
givenName: z.string().trim()
|
||||||
|
}),
|
||||||
|
emails: z.array(
|
||||||
|
z.object({
|
||||||
|
primary: z.boolean(),
|
||||||
|
value: z.string().email(),
|
||||||
|
type: z.string().trim()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
displayName: z.string().trim(),
|
||||||
|
active: z.boolean()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const user = await req.server.services.scim.createScimUser({
|
||||||
|
email: req.body.userName,
|
||||||
|
firstName: req.body.name.givenName,
|
||||||
|
lastName: req.body.name.familyName,
|
||||||
|
orgId: req.permission.orgId as string
|
||||||
|
});
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/Users/:userId",
|
||||||
|
method: "PATCH",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
userId: z.string().trim()
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
schemas: z.array(z.string()),
|
||||||
|
Operations: z.array(
|
||||||
|
z.object({
|
||||||
|
op: z.string().trim(),
|
||||||
|
path: z.string().trim().optional(),
|
||||||
|
value: z.union([
|
||||||
|
z.object({
|
||||||
|
active: z.boolean()
|
||||||
|
}),
|
||||||
|
z.string().trim()
|
||||||
|
])
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const user = await req.server.services.scim.updateScimUser({
|
||||||
|
userId: req.params.userId,
|
||||||
|
orgId: req.permission.orgId as string,
|
||||||
|
operations: req.body.Operations
|
||||||
|
});
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/Users/:userId",
|
||||||
|
method: "PUT",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
userId: z.string().trim()
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
schemas: z.array(z.string()),
|
||||||
|
id: z.string().trim(),
|
||||||
|
userName: z.string().trim(),
|
||||||
|
name: z.object({
|
||||||
|
familyName: z.string().trim(),
|
||||||
|
givenName: z.string().trim()
|
||||||
|
}),
|
||||||
|
displayName: z.string().trim(),
|
||||||
|
active: z.boolean()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
schemas: z.array(z.string()),
|
||||||
|
id: z.string().trim(),
|
||||||
|
userName: z.string().trim(),
|
||||||
|
name: z.object({
|
||||||
|
familyName: z.string().trim(),
|
||||||
|
givenName: z.string().trim()
|
||||||
|
}),
|
||||||
|
emails: z.array(
|
||||||
|
z.object({
|
||||||
|
primary: z.boolean(),
|
||||||
|
value: z.string().email(),
|
||||||
|
type: z.string().trim()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
displayName: z.string().trim(),
|
||||||
|
active: z.boolean()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.SCIM_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const user = await req.server.services.scim.replaceScimUser({
|
||||||
|
userId: req.params.userId,
|
||||||
|
orgId: req.permission.orgId as string,
|
||||||
|
active: req.body.active
|
||||||
|
});
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
@ -58,6 +58,7 @@ export const auditLogServiceFactory = ({
|
|||||||
if (data.event.type !== EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH) {
|
if (data.event.type !== EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH) {
|
||||||
if (!data.projectId && !data.orgId) throw new BadRequestError({ message: "Must either project id or org id" });
|
if (!data.projectId && !data.orgId) throw new BadRequestError({ message: "Must either project id or org id" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return auditLogQueue.pushToLog(data);
|
return auditLogQueue.pushToLog(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ export type TListProjectAuditLogDTO = {
|
|||||||
|
|
||||||
export type TCreateAuditLogDTO = {
|
export type TCreateAuditLogDTO = {
|
||||||
event: Event;
|
event: Event;
|
||||||
actor: UserActor | IdentityActor | ServiceActor;
|
actor: UserActor | IdentityActor | ServiceActor | ScimClientActor;
|
||||||
orgId?: string;
|
orgId?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
} & BaseAuthData;
|
} & BaseAuthData;
|
||||||
@ -105,6 +105,8 @@ interface IdentityActorMetadata {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ScimClientActorMetadata {}
|
||||||
|
|
||||||
export interface UserActor {
|
export interface UserActor {
|
||||||
type: ActorType.USER;
|
type: ActorType.USER;
|
||||||
metadata: UserActorMetadata;
|
metadata: UserActorMetadata;
|
||||||
@ -120,7 +122,12 @@ export interface IdentityActor {
|
|||||||
metadata: IdentityActorMetadata;
|
metadata: IdentityActorMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Actor = UserActor | ServiceActor | IdentityActor;
|
export interface ScimClientActor {
|
||||||
|
type: ActorType.SCIM_CLIENT;
|
||||||
|
metadata: ScimClientActorMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Actor = UserActor | ServiceActor | IdentityActor | ScimClientActor;
|
||||||
|
|
||||||
interface GetSecretsEvent {
|
interface GetSecretsEvent {
|
||||||
type: EventType.GET_SECRETS;
|
type: EventType.GET_SECRETS;
|
||||||
|
27
backend/src/ee/services/license/__mocks__/licence-fns.ts
Normal file
27
backend/src/ee/services/license/__mocks__/licence-fns.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
export const getDefaultOnPremFeatures = () => {
|
||||||
|
return {
|
||||||
|
_id: null,
|
||||||
|
slug: null,
|
||||||
|
tier: -1,
|
||||||
|
workspaceLimit: null,
|
||||||
|
workspacesUsed: 0,
|
||||||
|
memberLimit: null,
|
||||||
|
membersUsed: 0,
|
||||||
|
environmentLimit: null,
|
||||||
|
environmentsUsed: 0,
|
||||||
|
secretVersioning: true,
|
||||||
|
pitRecovery: false,
|
||||||
|
ipAllowlisting: true,
|
||||||
|
rbac: false,
|
||||||
|
customRateLimits: false,
|
||||||
|
customAlerts: false,
|
||||||
|
auditLogs: false,
|
||||||
|
auditLogsRetentionDays: 0,
|
||||||
|
samlSSO: false,
|
||||||
|
status: null,
|
||||||
|
trial_end: null,
|
||||||
|
has_used_trial: true,
|
||||||
|
secretApproval: false,
|
||||||
|
secretRotation: true
|
||||||
|
};
|
||||||
|
};
|
@ -24,6 +24,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
|
|||||||
auditLogs: false,
|
auditLogs: false,
|
||||||
auditLogsRetentionDays: 0,
|
auditLogsRetentionDays: 0,
|
||||||
samlSSO: false,
|
samlSSO: false,
|
||||||
|
scim: false,
|
||||||
status: null,
|
status: null,
|
||||||
trial_end: null,
|
trial_end: null,
|
||||||
has_used_trial: true,
|
has_used_trial: true,
|
||||||
|
@ -25,6 +25,7 @@ export type TFeatureSet = {
|
|||||||
auditLogs: false;
|
auditLogs: false;
|
||||||
auditLogsRetentionDays: 0;
|
auditLogsRetentionDays: 0;
|
||||||
samlSSO: false;
|
samlSSO: false;
|
||||||
|
scim: false;
|
||||||
status: null;
|
status: null;
|
||||||
trial_end: null;
|
trial_end: null;
|
||||||
has_used_trial: true;
|
has_used_trial: true;
|
||||||
|
@ -16,6 +16,7 @@ export enum OrgPermissionSubjects {
|
|||||||
Settings = "settings",
|
Settings = "settings",
|
||||||
IncidentAccount = "incident-contact",
|
IncidentAccount = "incident-contact",
|
||||||
Sso = "sso",
|
Sso = "sso",
|
||||||
|
Scim = "scim",
|
||||||
Billing = "billing",
|
Billing = "billing",
|
||||||
SecretScanning = "secret-scanning",
|
SecretScanning = "secret-scanning",
|
||||||
Identity = "identity"
|
Identity = "identity"
|
||||||
@ -29,6 +30,7 @@ export type OrgPermissionSet =
|
|||||||
| [OrgPermissionActions, OrgPermissionSubjects.Settings]
|
| [OrgPermissionActions, OrgPermissionSubjects.Settings]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount]
|
| [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
|
| [OrgPermissionActions, OrgPermissionSubjects.Sso]
|
||||||
|
| [OrgPermissionActions, OrgPermissionSubjects.Scim]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
|
| [OrgPermissionActions, OrgPermissionSubjects.SecretScanning]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
|
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
|
||||||
| [OrgPermissionActions, OrgPermissionSubjects.Identity];
|
| [OrgPermissionActions, OrgPermissionSubjects.Identity];
|
||||||
@ -69,6 +71,11 @@ const buildAdminPermission = () => {
|
|||||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
|
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
|
||||||
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Sso);
|
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Sso);
|
||||||
|
|
||||||
|
can(OrgPermissionActions.Read, OrgPermissionSubjects.Scim);
|
||||||
|
can(OrgPermissionActions.Create, OrgPermissionSubjects.Scim);
|
||||||
|
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim);
|
||||||
|
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Scim);
|
||||||
|
|
||||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
|
can(OrgPermissionActions.Read, OrgPermissionSubjects.Billing);
|
||||||
can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing);
|
can(OrgPermissionActions.Create, OrgPermissionSubjects.Billing);
|
||||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing);
|
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing);
|
||||||
|
@ -195,7 +195,7 @@ export const samlConfigServiceFactory = ({
|
|||||||
updateQuery.certTag = certTag;
|
updateQuery.certTag = certTag;
|
||||||
}
|
}
|
||||||
const [ssoConfig] = await samlConfigDAL.update({ orgId }, updateQuery);
|
const [ssoConfig] = await samlConfigDAL.update({ orgId }, updateQuery);
|
||||||
await orgDAL.updateById(orgId, { authEnforced: false });
|
await orgDAL.updateById(orgId, { authEnforced: false, scimEnabled: false });
|
||||||
|
|
||||||
return ssoConfig;
|
return ssoConfig;
|
||||||
};
|
};
|
||||||
@ -338,7 +338,8 @@ export const samlConfigServiceFactory = ({
|
|||||||
email,
|
email,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
authMethods: [AuthMethod.EMAIL]
|
authMethods: [AuthMethod.EMAIL],
|
||||||
|
isGhost: false
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
|
10
backend/src/ee/services/scim/scim-dal.ts
Normal file
10
backend/src/ee/services/scim/scim-dal.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { TDbClient } from "@app/db";
|
||||||
|
import { TableName } from "@app/db/schemas";
|
||||||
|
import { ormify } from "@app/lib/knex";
|
||||||
|
|
||||||
|
export type TScimDALFactory = ReturnType<typeof scimDALFactory>;
|
||||||
|
|
||||||
|
export const scimDALFactory = (db: TDbClient) => {
|
||||||
|
const scimTokenOrm = ormify(db, TableName.ScimToken);
|
||||||
|
return scimTokenOrm;
|
||||||
|
};
|
58
backend/src/ee/services/scim/scim-fns.ts
Normal file
58
backend/src/ee/services/scim/scim-fns.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { TListScimUsers, TScimUser } from "./scim-types";
|
||||||
|
|
||||||
|
export const buildScimUserList = ({
|
||||||
|
scimUsers,
|
||||||
|
offset,
|
||||||
|
limit
|
||||||
|
}: {
|
||||||
|
scimUsers: TScimUser[];
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
}): TListScimUsers => {
|
||||||
|
return {
|
||||||
|
Resources: scimUsers,
|
||||||
|
itemsPerPage: limit,
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
|
||||||
|
startIndex: offset,
|
||||||
|
totalResults: scimUsers.length
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildScimUser = ({
|
||||||
|
userId,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email,
|
||||||
|
active
|
||||||
|
}: {
|
||||||
|
userId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
active: boolean;
|
||||||
|
}): TScimUser => {
|
||||||
|
return {
|
||||||
|
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||||
|
id: userId,
|
||||||
|
userName: email,
|
||||||
|
displayName: `${firstName} ${lastName}`,
|
||||||
|
name: {
|
||||||
|
givenName: firstName,
|
||||||
|
middleName: null,
|
||||||
|
familyName: lastName
|
||||||
|
},
|
||||||
|
emails: [
|
||||||
|
{
|
||||||
|
primary: true,
|
||||||
|
value: email,
|
||||||
|
type: "work"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
active,
|
||||||
|
groups: [],
|
||||||
|
meta: {
|
||||||
|
resourceType: "User",
|
||||||
|
location: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
431
backend/src/ee/services/scim/scim-service.ts
Normal file
431
backend/src/ee/services/scim/scim-service.ts
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
import { ForbiddenError } from "@casl/ability";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
|
||||||
|
import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas";
|
||||||
|
import { TScimDALFactory } from "@app/ee/services/scim/scim-dal";
|
||||||
|
import { getConfig } from "@app/lib/config/env";
|
||||||
|
import { BadRequestError, ScimRequestError, UnauthorizedError } from "@app/lib/errors";
|
||||||
|
import { TOrgPermission } from "@app/lib/types";
|
||||||
|
import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type";
|
||||||
|
import { TOrgDALFactory } from "@app/services/org/org-dal";
|
||||||
|
import { deleteOrgMembership } from "@app/services/org/org-fns";
|
||||||
|
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||||
|
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
|
||||||
|
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
||||||
|
import { TUserDALFactory } from "@app/services/user/user-dal";
|
||||||
|
|
||||||
|
import { TLicenseServiceFactory } from "../license/license-service";
|
||||||
|
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||||
|
import { TPermissionServiceFactory } from "../permission/permission-service";
|
||||||
|
import { buildScimUser, buildScimUserList } from "./scim-fns";
|
||||||
|
import {
|
||||||
|
TCreateScimTokenDTO,
|
||||||
|
TCreateScimUserDTO,
|
||||||
|
TDeleteScimTokenDTO,
|
||||||
|
TGetScimUserDTO,
|
||||||
|
TListScimUsers,
|
||||||
|
TListScimUsersDTO,
|
||||||
|
TReplaceScimUserDTO,
|
||||||
|
TScimTokenJwtPayload,
|
||||||
|
TUpdateScimUserDTO
|
||||||
|
} from "./scim-types";
|
||||||
|
|
||||||
|
type TScimServiceFactoryDep = {
|
||||||
|
scimDAL: Pick<TScimDALFactory, "create" | "find" | "findById" | "deleteById">;
|
||||||
|
userDAL: Pick<TUserDALFactory, "findOne" | "create" | "transaction">;
|
||||||
|
orgDAL: Pick<
|
||||||
|
TOrgDALFactory,
|
||||||
|
"createMembership" | "findById" | "findMembership" | "deleteMembershipById" | "transaction"
|
||||||
|
>;
|
||||||
|
projectDAL: Pick<TProjectDALFactory, "find">;
|
||||||
|
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "delete">;
|
||||||
|
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||||
|
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
||||||
|
smtpService: TSmtpService;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TScimServiceFactory = ReturnType<typeof scimServiceFactory>;
|
||||||
|
|
||||||
|
export const scimServiceFactory = ({
|
||||||
|
licenseService,
|
||||||
|
scimDAL,
|
||||||
|
userDAL,
|
||||||
|
orgDAL,
|
||||||
|
projectDAL,
|
||||||
|
projectMembershipDAL,
|
||||||
|
permissionService,
|
||||||
|
smtpService
|
||||||
|
}: TScimServiceFactoryDep) => {
|
||||||
|
const createScimToken = async ({ actor, actorId, actorOrgId, orgId, description, ttlDays }: TCreateScimTokenDTO) => {
|
||||||
|
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Scim);
|
||||||
|
|
||||||
|
const plan = await licenseService.getPlan(orgId);
|
||||||
|
if (!plan.scim)
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Failed to create a SCIM token due to plan restriction. Upgrade plan to create a SCIM token."
|
||||||
|
});
|
||||||
|
|
||||||
|
const appCfg = getConfig();
|
||||||
|
|
||||||
|
const scimTokenData = await scimDAL.create({
|
||||||
|
orgId,
|
||||||
|
description,
|
||||||
|
ttlDays
|
||||||
|
});
|
||||||
|
|
||||||
|
const scimToken = jwt.sign(
|
||||||
|
{
|
||||||
|
scimTokenId: scimTokenData.id,
|
||||||
|
authTokenType: AuthTokenType.SCIM_TOKEN
|
||||||
|
},
|
||||||
|
appCfg.AUTH_SECRET
|
||||||
|
);
|
||||||
|
|
||||||
|
return { scimToken };
|
||||||
|
};
|
||||||
|
|
||||||
|
const listScimTokens = async ({ actor, actorId, actorOrgId, orgId }: TOrgPermission) => {
|
||||||
|
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Scim);
|
||||||
|
|
||||||
|
const plan = await licenseService.getPlan(orgId);
|
||||||
|
if (!plan.scim)
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Failed to get SCIM tokens due to plan restriction. Upgrade plan to get SCIM tokens."
|
||||||
|
});
|
||||||
|
|
||||||
|
const scimTokens = await scimDAL.find({ orgId });
|
||||||
|
return scimTokens;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteScimToken = async ({ scimTokenId, actor, actorId, actorOrgId }: TDeleteScimTokenDTO) => {
|
||||||
|
let scimToken = await scimDAL.findById(scimTokenId);
|
||||||
|
if (!scimToken) throw new BadRequestError({ message: "Failed to find SCIM token to delete" });
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getOrgPermission(actor, actorId, scimToken.orgId, actorOrgId);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Scim);
|
||||||
|
|
||||||
|
const plan = await licenseService.getPlan(scimToken.orgId);
|
||||||
|
if (!plan.scim)
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Failed to delete the SCIM token due to plan restriction. Upgrade plan to delete the SCIM token."
|
||||||
|
});
|
||||||
|
|
||||||
|
scimToken = await scimDAL.deleteById(scimTokenId);
|
||||||
|
|
||||||
|
return scimToken;
|
||||||
|
};
|
||||||
|
|
||||||
|
// SCIM server endpoints
|
||||||
|
const listScimUsers = async ({ offset, limit, filter, orgId }: TListScimUsersDTO): Promise<TListScimUsers> => {
|
||||||
|
const org = await orgDAL.findById(orgId);
|
||||||
|
|
||||||
|
if (!org.scimEnabled)
|
||||||
|
throw new ScimRequestError({
|
||||||
|
detail: "SCIM is disabled for the organization",
|
||||||
|
status: 403
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseFilter = (filterToParse: string | undefined) => {
|
||||||
|
if (!filterToParse) return {};
|
||||||
|
const [parsedName, parsedValue] = filterToParse.split("eq").map((s) => s.trim());
|
||||||
|
|
||||||
|
let attributeName = parsedName;
|
||||||
|
if (parsedName === "userName") {
|
||||||
|
attributeName = "email";
|
||||||
|
}
|
||||||
|
|
||||||
|
return { [attributeName]: parsedValue };
|
||||||
|
};
|
||||||
|
|
||||||
|
const findOpts = {
|
||||||
|
...(offset && { offset }),
|
||||||
|
...(limit && { limit })
|
||||||
|
};
|
||||||
|
|
||||||
|
const users = await orgDAL.findMembership(
|
||||||
|
{
|
||||||
|
orgId,
|
||||||
|
...parseFilter(filter)
|
||||||
|
},
|
||||||
|
findOpts
|
||||||
|
);
|
||||||
|
|
||||||
|
const scimUsers = users.map(({ userId, firstName, lastName, email }) =>
|
||||||
|
buildScimUser({
|
||||||
|
userId: userId ?? "",
|
||||||
|
firstName: firstName ?? "",
|
||||||
|
lastName: lastName ?? "",
|
||||||
|
email,
|
||||||
|
active: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return buildScimUserList({
|
||||||
|
scimUsers,
|
||||||
|
offset,
|
||||||
|
limit
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getScimUser = async ({ userId, orgId }: TGetScimUserDTO) => {
|
||||||
|
const [membership] = await orgDAL
|
||||||
|
.findMembership({
|
||||||
|
userId,
|
||||||
|
orgId
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
throw new ScimRequestError({
|
||||||
|
detail: "User not found",
|
||||||
|
status: 404
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership)
|
||||||
|
throw new ScimRequestError({
|
||||||
|
detail: "User not found",
|
||||||
|
status: 404
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership.scimEnabled)
|
||||||
|
throw new ScimRequestError({
|
||||||
|
detail: "SCIM is disabled for the organization",
|
||||||
|
status: 403
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildScimUser({
|
||||||
|
userId: membership.userId as string,
|
||||||
|
firstName: membership.firstName as string,
|
||||||
|
lastName: membership.lastName as string,
|
||||||
|
email: membership.email,
|
||||||
|
active: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createScimUser = async ({ firstName, lastName, email, orgId }: TCreateScimUserDTO) => {
|
||||||
|
const org = await orgDAL.findById(orgId);
|
||||||
|
|
||||||
|
if (!org)
|
||||||
|
throw new ScimRequestError({
|
||||||
|
detail: "Organization not found",
|
||||||
|
status: 404
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!org.scimEnabled)
|
||||||
|
throw new ScimRequestError({
|
||||||
|
detail: "SCIM is disabled for the organization",
|
||||||
|
status: 403
|
||||||
|
});
|
||||||
|
|
||||||
|
let user = await userDAL.findOne({
|
||||||
|
email
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
await userDAL.transaction(async (tx) => {
|
||||||
|
const [orgMembership] = await orgDAL.findMembership({ userId: user.id, orgId }, { tx });
|
||||||
|
if (orgMembership)
|
||||||
|
throw new ScimRequestError({
|
||||||
|
detail: "User already exists in the database",
|
||||||
|
status: 409
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!orgMembership) {
|
||||||
|
await orgDAL.createMembership(
|
||||||
|
{
|
||||||
|
userId: user.id,
|
||||||
|
orgId,
|
||||||
|
inviteEmail: email,
|
||||||
|
role: OrgMembershipRole.Member,
|
||||||
|
status: OrgMembershipStatus.Invited
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
user = await userDAL.transaction(async (tx) => {
|
||||||
|
const newUser = await userDAL.create(
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
authMethods: [AuthMethod.EMAIL],
|
||||||
|
isGhost: false
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
await orgDAL.createMembership(
|
||||||
|
{
|
||||||
|
inviteEmail: email,
|
||||||
|
orgId,
|
||||||
|
userId: newUser.id,
|
||||||
|
role: OrgMembershipRole.Member,
|
||||||
|
status: OrgMembershipStatus.Invited
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
return newUser;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const appCfg = getConfig();
|
||||||
|
await smtpService.sendMail({
|
||||||
|
template: SmtpTemplates.ScimUserProvisioned,
|
||||||
|
subjectLine: "Infisical organization invitation",
|
||||||
|
recipients: [email],
|
||||||
|
substitutions: {
|
||||||
|
organizationName: org.name,
|
||||||
|
callback_url: `${appCfg.SITE_URL}/api/v1/sso/redirect/saml2/organizations/${org.slug}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildScimUser({
|
||||||
|
userId: user.id,
|
||||||
|
firstName: user.firstName as string,
|
||||||
|
lastName: user.lastName as string,
|
||||||
|
email: user.email,
|
||||||
|
active: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateScimUser = async ({ userId, orgId, operations }: TUpdateScimUserDTO) => {
|
||||||
|
const [membership] = await orgDAL
|
||||||
|
.findMembership({
|
||||||
|
userId,
|
||||||
|
orgId
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
throw new ScimRequestError({
|
||||||
|
detail: "User not found",
|
||||||
|
status: 404
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership)
|
||||||
|
throw new ScimRequestError({
|
||||||
|
detail: "User not found",
|
||||||
|
status: 404
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership.scimEnabled)
|
||||||
|
throw new ScimRequestError({
|
||||||
|
detail: "SCIM is disabled for the organization",
|
||||||
|
status: 403
|
||||||
|
});
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
operations.forEach((operation) => {
|
||||||
|
if (operation.op.toLowerCase() === "replace") {
|
||||||
|
if (operation.path === "active" && operation.value === "False") {
|
||||||
|
// azure scim op format
|
||||||
|
active = false;
|
||||||
|
} else if (typeof operation.value === "object" && operation.value.active === false) {
|
||||||
|
// okta scim op format
|
||||||
|
active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
await deleteOrgMembership({
|
||||||
|
orgMembershipId: membership.id,
|
||||||
|
orgId: membership.orgId,
|
||||||
|
orgDAL,
|
||||||
|
projectDAL,
|
||||||
|
projectMembershipDAL
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildScimUser({
|
||||||
|
userId: membership.userId as string,
|
||||||
|
firstName: membership.firstName as string,
|
||||||
|
lastName: membership.lastName as string,
|
||||||
|
email: membership.email,
|
||||||
|
active
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceScimUser = async ({ userId, active, orgId }: TReplaceScimUserDTO) => {
|
||||||
|
const [membership] = await orgDAL
|
||||||
|
.findMembership({
|
||||||
|
userId,
|
||||||
|
orgId
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
throw new ScimRequestError({
|
||||||
|
detail: "User not found",
|
||||||
|
status: 404
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership)
|
||||||
|
throw new ScimRequestError({
|
||||||
|
detail: "User not found",
|
||||||
|
status: 404
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership.scimEnabled)
|
||||||
|
throw new ScimRequestError({
|
||||||
|
detail: "SCIM is disabled for the organization",
|
||||||
|
status: 403
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
// tx
|
||||||
|
await deleteOrgMembership({
|
||||||
|
orgMembershipId: membership.id,
|
||||||
|
orgId: membership.orgId,
|
||||||
|
orgDAL,
|
||||||
|
projectDAL,
|
||||||
|
projectMembershipDAL
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildScimUser({
|
||||||
|
userId: membership.userId as string,
|
||||||
|
firstName: membership.firstName as string,
|
||||||
|
lastName: membership.lastName as string,
|
||||||
|
email: membership.email,
|
||||||
|
active
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fnValidateScimToken = async (token: TScimTokenJwtPayload) => {
|
||||||
|
const scimToken = await scimDAL.findById(token.scimTokenId);
|
||||||
|
if (!scimToken) throw new UnauthorizedError();
|
||||||
|
|
||||||
|
const { ttlDays, createdAt } = scimToken;
|
||||||
|
|
||||||
|
// ttl check
|
||||||
|
if (Number(ttlDays) > 0) {
|
||||||
|
const currentDate = new Date();
|
||||||
|
const scimTokenCreatedAt = new Date(createdAt);
|
||||||
|
const ttlInMilliseconds = Number(scimToken.ttlDays) * 86400 * 1000;
|
||||||
|
const expirationDate = new Date(scimTokenCreatedAt.getTime() + ttlInMilliseconds);
|
||||||
|
|
||||||
|
if (currentDate > expirationDate)
|
||||||
|
throw new ScimRequestError({
|
||||||
|
detail: "The access token expired",
|
||||||
|
status: 401
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { scimTokenId: scimToken.id, orgId: scimToken.orgId };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
createScimToken,
|
||||||
|
listScimTokens,
|
||||||
|
deleteScimToken,
|
||||||
|
listScimUsers,
|
||||||
|
getScimUser,
|
||||||
|
createScimUser,
|
||||||
|
updateScimUser,
|
||||||
|
replaceScimUser,
|
||||||
|
fnValidateScimToken
|
||||||
|
};
|
||||||
|
};
|
87
backend/src/ee/services/scim/scim-types.ts
Normal file
87
backend/src/ee/services/scim/scim-types.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { TOrgPermission } from "@app/lib/types";
|
||||||
|
|
||||||
|
export type TCreateScimTokenDTO = {
|
||||||
|
description: string;
|
||||||
|
ttlDays: number;
|
||||||
|
} & TOrgPermission;
|
||||||
|
|
||||||
|
export type TDeleteScimTokenDTO = {
|
||||||
|
scimTokenId: string;
|
||||||
|
} & Omit<TOrgPermission, "orgId">;
|
||||||
|
|
||||||
|
// SCIM server endpoint types
|
||||||
|
|
||||||
|
export type TListScimUsersDTO = {
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
filter?: string;
|
||||||
|
orgId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TListScimUsers = {
|
||||||
|
schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"];
|
||||||
|
totalResults: number;
|
||||||
|
Resources: TScimUser[];
|
||||||
|
itemsPerPage: number;
|
||||||
|
startIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TGetScimUserDTO = {
|
||||||
|
userId: string;
|
||||||
|
orgId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCreateScimUserDTO = {
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
orgId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TUpdateScimUserDTO = {
|
||||||
|
userId: string;
|
||||||
|
orgId: string;
|
||||||
|
operations: {
|
||||||
|
op: string;
|
||||||
|
path?: string;
|
||||||
|
value?:
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TReplaceScimUserDTO = {
|
||||||
|
userId: string;
|
||||||
|
active: boolean;
|
||||||
|
orgId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TScimTokenJwtPayload = {
|
||||||
|
scimTokenId: string;
|
||||||
|
authTokenType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TScimUser = {
|
||||||
|
schemas: string[];
|
||||||
|
id: string;
|
||||||
|
userName: string;
|
||||||
|
displayName: string;
|
||||||
|
name: {
|
||||||
|
givenName: string;
|
||||||
|
middleName: null;
|
||||||
|
familyName: string;
|
||||||
|
};
|
||||||
|
emails: {
|
||||||
|
primary: boolean;
|
||||||
|
value: string;
|
||||||
|
type: string;
|
||||||
|
}[];
|
||||||
|
active: boolean;
|
||||||
|
groups: string[];
|
||||||
|
meta: {
|
||||||
|
resourceType: string;
|
||||||
|
location: null;
|
||||||
|
};
|
||||||
|
};
|
@ -1,8 +1,13 @@
|
|||||||
import { Knex } from "knex";
|
import { Knex } from "knex";
|
||||||
|
|
||||||
import { TDbClient } from "@app/db";
|
import { TDbClient } from "@app/db";
|
||||||
import { SecretApprovalRequestsSecretsSchema, TableName, TSecretTags } from "@app/db/schemas";
|
import {
|
||||||
import { DatabaseError } from "@app/lib/errors";
|
SecretApprovalRequestsSecretsSchema,
|
||||||
|
TableName,
|
||||||
|
TSecretApprovalRequestsSecrets,
|
||||||
|
TSecretTags
|
||||||
|
} from "@app/db/schemas";
|
||||||
|
import { BadRequestError, DatabaseError } from "@app/lib/errors";
|
||||||
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
|
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
|
||||||
|
|
||||||
export type TSecretApprovalRequestSecretDALFactory = ReturnType<typeof secretApprovalRequestSecretDALFactory>;
|
export type TSecretApprovalRequestSecretDALFactory = ReturnType<typeof secretApprovalRequestSecretDALFactory>;
|
||||||
@ -11,6 +16,35 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => {
|
|||||||
const secretApprovalRequestSecretOrm = ormify(db, TableName.SecretApprovalRequestSecret);
|
const secretApprovalRequestSecretOrm = ormify(db, TableName.SecretApprovalRequestSecret);
|
||||||
const secretApprovalRequestSecretTagOrm = ormify(db, TableName.SecretApprovalRequestSecretTag);
|
const secretApprovalRequestSecretTagOrm = ormify(db, TableName.SecretApprovalRequestSecretTag);
|
||||||
|
|
||||||
|
const bulkUpdateNoVersionIncrement = async (data: TSecretApprovalRequestsSecrets[], tx?: Knex) => {
|
||||||
|
try {
|
||||||
|
const existingApprovalSecrets = await secretApprovalRequestSecretOrm.find(
|
||||||
|
{
|
||||||
|
$in: {
|
||||||
|
id: data.map((el) => el.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ tx }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingApprovalSecrets.length !== data.length) {
|
||||||
|
throw new BadRequestError({ message: "Some of the secret approvals do not exist" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) return [];
|
||||||
|
|
||||||
|
const updatedApprovalSecrets = await (tx || db)(TableName.SecretApprovalRequestSecret)
|
||||||
|
.insert(data)
|
||||||
|
.onConflict("id") // this will cause a conflict then merge the data
|
||||||
|
.merge() // Merge the data with the existing data
|
||||||
|
.returning("*");
|
||||||
|
|
||||||
|
return updatedApprovalSecrets;
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "bulk update secret" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const findByRequestId = async (requestId: string, tx?: Knex) => {
|
const findByRequestId = async (requestId: string, tx?: Knex) => {
|
||||||
try {
|
try {
|
||||||
const doc = await (tx || db)({
|
const doc = await (tx || db)({
|
||||||
@ -190,6 +224,7 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => {
|
|||||||
return {
|
return {
|
||||||
...secretApprovalRequestSecretOrm,
|
...secretApprovalRequestSecretOrm,
|
||||||
findByRequestId,
|
findByRequestId,
|
||||||
|
bulkUpdateNoVersionIncrement,
|
||||||
insertApprovalSecretTags: secretApprovalRequestSecretTagOrm.insertMany
|
insertApprovalSecretTags: secretApprovalRequestSecretTagOrm.insertMany
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -11,6 +11,7 @@ import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
|
|||||||
import { groupBy, pick, unique } from "@app/lib/fn";
|
import { groupBy, pick, unique } from "@app/lib/fn";
|
||||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||||
import { ActorType } from "@app/services/auth/auth-type";
|
import { ActorType } from "@app/services/auth/auth-type";
|
||||||
|
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||||
import { TSecretQueueFactory } from "@app/services/secret/secret-queue";
|
import { TSecretQueueFactory } from "@app/services/secret/secret-queue";
|
||||||
import { TSecretServiceFactory } from "@app/services/secret/secret-service";
|
import { TSecretServiceFactory } from "@app/services/secret/secret-service";
|
||||||
import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal";
|
import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal";
|
||||||
@ -47,6 +48,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
|
|||||||
secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "findOne">;
|
secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "findOne">;
|
||||||
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
|
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
|
||||||
secretVersionDAL: Pick<TSecretVersionDALFactory, "findLatestVersionMany">;
|
secretVersionDAL: Pick<TSecretVersionDALFactory, "findLatestVersionMany">;
|
||||||
|
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus">;
|
||||||
secretService: Pick<
|
secretService: Pick<
|
||||||
TSecretServiceFactory,
|
TSecretServiceFactory,
|
||||||
| "fnSecretBulkInsert"
|
| "fnSecretBulkInsert"
|
||||||
@ -67,6 +69,7 @@ export const secretApprovalRequestServiceFactory = ({
|
|||||||
secretApprovalRequestReviewerDAL,
|
secretApprovalRequestReviewerDAL,
|
||||||
secretApprovalRequestSecretDAL,
|
secretApprovalRequestSecretDAL,
|
||||||
secretBlindIndexDAL,
|
secretBlindIndexDAL,
|
||||||
|
projectDAL,
|
||||||
permissionService,
|
permissionService,
|
||||||
snapshotService,
|
snapshotService,
|
||||||
secretService,
|
secretService,
|
||||||
@ -434,6 +437,8 @@ export const secretApprovalRequestServiceFactory = ({
|
|||||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await projectDAL.checkProjectUpgradeStatus(projectId);
|
||||||
|
|
||||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||||
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "GenSecretApproval" });
|
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "GenSecretApproval" });
|
||||||
const folderId = folder.id;
|
const folderId = folder.id;
|
||||||
|
@ -8,6 +8,9 @@ import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas";
|
|||||||
|
|
||||||
import { getConfig } from "../config/env";
|
import { getConfig } from "../config/env";
|
||||||
|
|
||||||
|
export const decodeBase64 = (s: string) => naclUtils.decodeBase64(s);
|
||||||
|
export const encodeBase64 = (u: Uint8Array) => naclUtils.encodeBase64(u);
|
||||||
|
|
||||||
export type TDecryptSymmetricInput = {
|
export type TDecryptSymmetricInput = {
|
||||||
ciphertext: string;
|
ciphertext: string;
|
||||||
iv: string;
|
iv: string;
|
||||||
@ -44,7 +47,7 @@ export const encryptSymmetric = (plaintext: string, key: string) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const encryptSymmetric128BitHexKeyUTF8 = (plaintext: string, key: string) => {
|
export const encryptSymmetric128BitHexKeyUTF8 = (plaintext: string, key: string | Buffer) => {
|
||||||
const iv = crypto.randomBytes(BLOCK_SIZE_BYTES_16);
|
const iv = crypto.randomBytes(BLOCK_SIZE_BYTES_16);
|
||||||
const cipher = crypto.createCipheriv(SecretEncryptionAlgo.AES_256_GCM, key, iv);
|
const cipher = crypto.createCipheriv(SecretEncryptionAlgo.AES_256_GCM, key, iv);
|
||||||
|
|
||||||
@ -58,7 +61,12 @@ export const encryptSymmetric128BitHexKeyUTF8 = (plaintext: string, key: string)
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decryptSymmetric128BitHexKeyUTF8 = ({ ciphertext, iv, tag, key }: TDecryptSymmetricInput): string => {
|
export const decryptSymmetric128BitHexKeyUTF8 = ({
|
||||||
|
ciphertext,
|
||||||
|
iv,
|
||||||
|
tag,
|
||||||
|
key
|
||||||
|
}: Omit<TDecryptSymmetricInput, "key"> & { key: string | Buffer }): string => {
|
||||||
const decipher = crypto.createDecipheriv(SecretEncryptionAlgo.AES_256_GCM, key, Buffer.from(iv, "base64"));
|
const decipher = crypto.createDecipheriv(SecretEncryptionAlgo.AES_256_GCM, key, Buffer.from(iv, "base64"));
|
||||||
|
|
||||||
decipher.setAuthTag(Buffer.from(tag, "base64"));
|
decipher.setAuthTag(Buffer.from(tag, "base64"));
|
||||||
|
@ -1,12 +1,20 @@
|
|||||||
export {
|
export {
|
||||||
buildSecretBlindIndexFromName,
|
buildSecretBlindIndexFromName,
|
||||||
createSecretBlindIndex,
|
createSecretBlindIndex,
|
||||||
|
decodeBase64,
|
||||||
decryptAsymmetric,
|
decryptAsymmetric,
|
||||||
decryptSymmetric,
|
decryptSymmetric,
|
||||||
decryptSymmetric128BitHexKeyUTF8,
|
decryptSymmetric128BitHexKeyUTF8,
|
||||||
|
encodeBase64,
|
||||||
encryptAsymmetric,
|
encryptAsymmetric,
|
||||||
encryptSymmetric,
|
encryptSymmetric,
|
||||||
encryptSymmetric128BitHexKeyUTF8,
|
encryptSymmetric128BitHexKeyUTF8,
|
||||||
generateAsymmetricKeyPair
|
generateAsymmetricKeyPair
|
||||||
} from "./encryption";
|
} from "./encryption";
|
||||||
|
export {
|
||||||
|
decryptIntegrationAuths,
|
||||||
|
decryptSecretApprovals,
|
||||||
|
decryptSecrets,
|
||||||
|
decryptSecretVersions
|
||||||
|
} from "./secret-encryption";
|
||||||
export { generateSrpServerKey, srpCheckClientProof } from "./srp";
|
export { generateSrpServerKey, srpCheckClientProof } from "./srp";
|
||||||
|
293
backend/src/lib/crypto/secret-encryption.ts
Normal file
293
backend/src/lib/crypto/secret-encryption.ts
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import {
|
||||||
|
IntegrationAuthsSchema,
|
||||||
|
SecretApprovalRequestsSecretsSchema,
|
||||||
|
SecretsSchema,
|
||||||
|
SecretVersionsSchema,
|
||||||
|
TIntegrationAuths,
|
||||||
|
TProjectKeys,
|
||||||
|
TSecretApprovalRequestsSecrets,
|
||||||
|
TSecrets,
|
||||||
|
TSecretVersions
|
||||||
|
} from "../../db/schemas";
|
||||||
|
import { decryptAsymmetric } from "./encryption";
|
||||||
|
|
||||||
|
const DecryptedValuesSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
secretKey: z.string(),
|
||||||
|
secretValue: z.string(),
|
||||||
|
secretComment: z.string().optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
const DecryptedSecretSchema = z.object({
|
||||||
|
decrypted: DecryptedValuesSchema,
|
||||||
|
original: SecretsSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
const DecryptedIntegrationAuthsSchema = z.object({
|
||||||
|
decrypted: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
access: z.string(),
|
||||||
|
accessId: z.string(),
|
||||||
|
refresh: z.string()
|
||||||
|
}),
|
||||||
|
original: IntegrationAuthsSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
const DecryptedSecretVersionsSchema = z.object({
|
||||||
|
decrypted: DecryptedValuesSchema,
|
||||||
|
original: SecretVersionsSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
const DecryptedSecretApprovalsSchema = z.object({
|
||||||
|
decrypted: DecryptedValuesSchema,
|
||||||
|
original: SecretApprovalRequestsSecretsSchema
|
||||||
|
});
|
||||||
|
|
||||||
|
type DecryptedSecret = z.infer<typeof DecryptedSecretSchema>;
|
||||||
|
type DecryptedSecretVersions = z.infer<typeof DecryptedSecretVersionsSchema>;
|
||||||
|
type DecryptedSecretApprovals = z.infer<typeof DecryptedSecretApprovalsSchema>;
|
||||||
|
type DecryptedIntegrationAuths = z.infer<typeof DecryptedIntegrationAuthsSchema>;
|
||||||
|
|
||||||
|
type TLatestKey = TProjectKeys & {
|
||||||
|
sender: {
|
||||||
|
publicKey: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const decryptCipher = ({
|
||||||
|
ciphertext,
|
||||||
|
iv,
|
||||||
|
tag,
|
||||||
|
key
|
||||||
|
}: {
|
||||||
|
ciphertext: string;
|
||||||
|
iv: string;
|
||||||
|
tag: string;
|
||||||
|
key: string | Buffer;
|
||||||
|
}) => {
|
||||||
|
const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64"));
|
||||||
|
decipher.setAuthTag(Buffer.from(tag, "base64"));
|
||||||
|
|
||||||
|
let cleartext = decipher.update(ciphertext, "base64", "utf8");
|
||||||
|
cleartext += decipher.final("utf8");
|
||||||
|
|
||||||
|
return cleartext;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDecryptedValues = (data: Array<{ ciphertext: string; iv: string; tag: string }>, key: string | Buffer) => {
|
||||||
|
const results: string[] = [];
|
||||||
|
|
||||||
|
for (const { ciphertext, iv, tag } of data) {
|
||||||
|
if (!ciphertext || !iv || !tag) {
|
||||||
|
results.push("");
|
||||||
|
} else {
|
||||||
|
results.push(decryptCipher({ ciphertext, iv, tag, key }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
export const decryptSecrets = (encryptedSecrets: TSecrets[], privateKey: string, latestKey: TLatestKey) => {
|
||||||
|
const key = decryptAsymmetric({
|
||||||
|
ciphertext: latestKey.encryptedKey,
|
||||||
|
nonce: latestKey.nonce,
|
||||||
|
publicKey: latestKey.sender.publicKey,
|
||||||
|
privateKey
|
||||||
|
});
|
||||||
|
|
||||||
|
const decryptedSecrets: DecryptedSecret[] = [];
|
||||||
|
|
||||||
|
encryptedSecrets.forEach((encSecret) => {
|
||||||
|
const [secretKey, secretValue, secretComment] = getDecryptedValues(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
ciphertext: encSecret.secretKeyCiphertext,
|
||||||
|
iv: encSecret.secretKeyIV,
|
||||||
|
tag: encSecret.secretKeyTag
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ciphertext: encSecret.secretValueCiphertext,
|
||||||
|
iv: encSecret.secretValueIV,
|
||||||
|
tag: encSecret.secretValueTag
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ciphertext: encSecret.secretCommentCiphertext || "",
|
||||||
|
iv: encSecret.secretCommentIV || "",
|
||||||
|
tag: encSecret.secretCommentTag || ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
key
|
||||||
|
);
|
||||||
|
|
||||||
|
const decryptedSecret: DecryptedSecret = {
|
||||||
|
decrypted: {
|
||||||
|
secretKey,
|
||||||
|
secretValue,
|
||||||
|
secretComment,
|
||||||
|
id: encSecret.id
|
||||||
|
},
|
||||||
|
original: encSecret
|
||||||
|
};
|
||||||
|
|
||||||
|
decryptedSecrets.push(DecryptedSecretSchema.parse(decryptedSecret));
|
||||||
|
});
|
||||||
|
|
||||||
|
return decryptedSecrets;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decryptSecretVersions = (
|
||||||
|
encryptedSecretVersions: TSecretVersions[],
|
||||||
|
privateKey: string,
|
||||||
|
latestKey: TLatestKey
|
||||||
|
) => {
|
||||||
|
const key = decryptAsymmetric({
|
||||||
|
ciphertext: latestKey.encryptedKey,
|
||||||
|
nonce: latestKey.nonce,
|
||||||
|
publicKey: latestKey.sender.publicKey,
|
||||||
|
privateKey
|
||||||
|
});
|
||||||
|
|
||||||
|
const decryptedSecrets: DecryptedSecretVersions[] = [];
|
||||||
|
|
||||||
|
encryptedSecretVersions.forEach((encSecret) => {
|
||||||
|
const [secretKey, secretValue, secretComment] = getDecryptedValues(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
ciphertext: encSecret.secretKeyCiphertext,
|
||||||
|
iv: encSecret.secretKeyIV,
|
||||||
|
tag: encSecret.secretKeyTag
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ciphertext: encSecret.secretValueCiphertext,
|
||||||
|
iv: encSecret.secretValueIV,
|
||||||
|
tag: encSecret.secretValueTag
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ciphertext: encSecret.secretCommentCiphertext || "",
|
||||||
|
iv: encSecret.secretCommentIV || "",
|
||||||
|
tag: encSecret.secretCommentTag || ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
key
|
||||||
|
);
|
||||||
|
|
||||||
|
const decryptedSecret: DecryptedSecretVersions = {
|
||||||
|
decrypted: {
|
||||||
|
secretKey,
|
||||||
|
secretValue,
|
||||||
|
secretComment,
|
||||||
|
id: encSecret.id
|
||||||
|
},
|
||||||
|
original: encSecret
|
||||||
|
};
|
||||||
|
|
||||||
|
decryptedSecrets.push(DecryptedSecretVersionsSchema.parse(decryptedSecret));
|
||||||
|
});
|
||||||
|
|
||||||
|
return decryptedSecrets;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decryptSecretApprovals = (
|
||||||
|
encryptedSecretApprovals: TSecretApprovalRequestsSecrets[],
|
||||||
|
privateKey: string,
|
||||||
|
latestKey: TLatestKey
|
||||||
|
) => {
|
||||||
|
const key = decryptAsymmetric({
|
||||||
|
ciphertext: latestKey.encryptedKey,
|
||||||
|
nonce: latestKey.nonce,
|
||||||
|
publicKey: latestKey.sender.publicKey,
|
||||||
|
privateKey
|
||||||
|
});
|
||||||
|
|
||||||
|
const decryptedSecrets: DecryptedSecretApprovals[] = [];
|
||||||
|
|
||||||
|
encryptedSecretApprovals.forEach((encApproval) => {
|
||||||
|
const [secretKey, secretValue, secretComment] = getDecryptedValues(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
ciphertext: encApproval.secretKeyCiphertext,
|
||||||
|
iv: encApproval.secretKeyIV,
|
||||||
|
tag: encApproval.secretKeyTag
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ciphertext: encApproval.secretValueCiphertext,
|
||||||
|
iv: encApproval.secretValueIV,
|
||||||
|
tag: encApproval.secretValueTag
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ciphertext: encApproval.secretCommentCiphertext || "",
|
||||||
|
iv: encApproval.secretCommentIV || "",
|
||||||
|
tag: encApproval.secretCommentTag || ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
key
|
||||||
|
);
|
||||||
|
|
||||||
|
const decryptedSecret: DecryptedSecretApprovals = {
|
||||||
|
decrypted: {
|
||||||
|
secretKey,
|
||||||
|
secretValue,
|
||||||
|
secretComment,
|
||||||
|
id: encApproval.id
|
||||||
|
},
|
||||||
|
original: encApproval
|
||||||
|
};
|
||||||
|
|
||||||
|
decryptedSecrets.push(DecryptedSecretApprovalsSchema.parse(decryptedSecret));
|
||||||
|
});
|
||||||
|
|
||||||
|
return decryptedSecrets;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decryptIntegrationAuths = (
|
||||||
|
encryptedIntegrationAuths: TIntegrationAuths[],
|
||||||
|
privateKey: string,
|
||||||
|
latestKey: TLatestKey
|
||||||
|
) => {
|
||||||
|
const key = decryptAsymmetric({
|
||||||
|
ciphertext: latestKey.encryptedKey,
|
||||||
|
nonce: latestKey.nonce,
|
||||||
|
publicKey: latestKey.sender.publicKey,
|
||||||
|
privateKey
|
||||||
|
});
|
||||||
|
|
||||||
|
const decryptedIntegrationAuths: DecryptedIntegrationAuths[] = [];
|
||||||
|
|
||||||
|
encryptedIntegrationAuths.forEach((encAuth) => {
|
||||||
|
const [access, accessId, refresh] = getDecryptedValues(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
ciphertext: encAuth.accessCiphertext || "",
|
||||||
|
iv: encAuth.accessIV || "",
|
||||||
|
tag: encAuth.accessTag || ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ciphertext: encAuth.accessIdCiphertext || "",
|
||||||
|
iv: encAuth.accessIdIV || "",
|
||||||
|
tag: encAuth.accessIdTag || ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ciphertext: encAuth.refreshCiphertext || "",
|
||||||
|
iv: encAuth.refreshIV || "",
|
||||||
|
tag: encAuth.refreshTag || ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
key
|
||||||
|
);
|
||||||
|
|
||||||
|
decryptedIntegrationAuths.push({
|
||||||
|
decrypted: {
|
||||||
|
id: encAuth.id,
|
||||||
|
access,
|
||||||
|
accessId,
|
||||||
|
refresh
|
||||||
|
},
|
||||||
|
original: encAuth
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return decryptedIntegrationAuths;
|
||||||
|
};
|
@ -1,4 +1,12 @@
|
|||||||
|
import argon2 from "argon2";
|
||||||
|
import crypto from "crypto";
|
||||||
import jsrp from "jsrp";
|
import jsrp from "jsrp";
|
||||||
|
import nacl from "tweetnacl";
|
||||||
|
import tweetnacl from "tweetnacl-util";
|
||||||
|
|
||||||
|
import { TUserEncryptionKeys } from "@app/db/schemas";
|
||||||
|
|
||||||
|
import { decryptSymmetric, encryptAsymmetric, encryptSymmetric } from "./encryption";
|
||||||
|
|
||||||
export const generateSrpServerKey = async (salt: string, verifier: string) => {
|
export const generateSrpServerKey = async (salt: string, verifier: string) => {
|
||||||
// eslint-disable-next-line new-cap
|
// eslint-disable-next-line new-cap
|
||||||
@ -24,3 +32,99 @@ export const srpCheckClientProof = async (
|
|||||||
server.setClientPublicKey(clientPublicKey);
|
server.setClientPublicKey(clientPublicKey);
|
||||||
return server.checkClientProof(clientProof);
|
return server.checkClientProof(clientProof);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ghost user related:
|
||||||
|
// This functionality is intended for ghost user logic. This happens on the frontend when a user is being created.
|
||||||
|
// We replicate the same functionality on the backend when creating a ghost user.
|
||||||
|
export const generateUserSrpKeys = async (email: string, password: string) => {
|
||||||
|
const pair = nacl.box.keyPair();
|
||||||
|
const secretKeyUint8Array = pair.secretKey;
|
||||||
|
const publicKeyUint8Array = pair.publicKey;
|
||||||
|
const privateKey = tweetnacl.encodeBase64(secretKeyUint8Array);
|
||||||
|
const publicKey = tweetnacl.encodeBase64(publicKeyUint8Array);
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
const client = new jsrp.client();
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
client.init({ username: email, password }, () => resolve(null));
|
||||||
|
});
|
||||||
|
const { salt, verifier } = await new Promise<{ salt: string; verifier: string }>((resolve, reject) => {
|
||||||
|
client.createVerifier((err, res) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
return resolve(res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const derivedKey = await argon2.hash(password, {
|
||||||
|
salt: Buffer.from(salt),
|
||||||
|
memoryCost: 65536,
|
||||||
|
timeCost: 3,
|
||||||
|
parallelism: 1,
|
||||||
|
hashLength: 32,
|
||||||
|
type: argon2.argon2id,
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
if (!derivedKey) throw new Error("Failed to derive key from password");
|
||||||
|
|
||||||
|
const key = crypto.randomBytes(32);
|
||||||
|
|
||||||
|
// create encrypted private key by encrypting the private
|
||||||
|
// key with the symmetric key [key]
|
||||||
|
const {
|
||||||
|
ciphertext: encryptedPrivateKey,
|
||||||
|
iv: encryptedPrivateKeyIV,
|
||||||
|
tag: encryptedPrivateKeyTag
|
||||||
|
} = encryptSymmetric(privateKey, key.toString("base64"));
|
||||||
|
|
||||||
|
// create the protected key by encrypting the symmetric key
|
||||||
|
// [key] with the derived key
|
||||||
|
const {
|
||||||
|
ciphertext: protectedKey,
|
||||||
|
iv: protectedKeyIV,
|
||||||
|
tag: protectedKeyTag
|
||||||
|
} = encryptSymmetric(key.toString("hex"), derivedKey.toString("base64"));
|
||||||
|
|
||||||
|
return {
|
||||||
|
protectedKey,
|
||||||
|
plainPrivateKey: privateKey,
|
||||||
|
protectedKeyIV,
|
||||||
|
protectedKeyTag,
|
||||||
|
publicKey,
|
||||||
|
encryptedPrivateKey,
|
||||||
|
encryptedPrivateKeyIV,
|
||||||
|
encryptedPrivateKeyTag,
|
||||||
|
salt,
|
||||||
|
verifier
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserPrivateKey = async (password: string, user: TUserEncryptionKeys) => {
|
||||||
|
const derivedKey = await argon2.hash(password, {
|
||||||
|
salt: Buffer.from(user.salt),
|
||||||
|
memoryCost: 65536,
|
||||||
|
timeCost: 3,
|
||||||
|
parallelism: 1,
|
||||||
|
hashLength: 32,
|
||||||
|
type: argon2.argon2id,
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
if (!derivedKey) throw new Error("Failed to derive key from password");
|
||||||
|
const key = decryptSymmetric({
|
||||||
|
ciphertext: user.protectedKey!,
|
||||||
|
iv: user.protectedKeyIV!,
|
||||||
|
tag: user.protectedKeyTag!,
|
||||||
|
key: derivedKey.toString("base64")
|
||||||
|
});
|
||||||
|
const privateKey = decryptSymmetric({
|
||||||
|
ciphertext: user.encryptedPrivateKey,
|
||||||
|
iv: user.iv,
|
||||||
|
tag: user.tag,
|
||||||
|
key
|
||||||
|
});
|
||||||
|
return privateKey;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildUserProjectKey = async (privateKey: string, publickey: string) => {
|
||||||
|
const randomBytes = crypto.randomBytes(16).toString("hex");
|
||||||
|
const { nonce, ciphertext } = encryptAsymmetric(randomBytes, publickey, privateKey);
|
||||||
|
return { nonce, ciphertext };
|
||||||
|
};
|
||||||
|
@ -58,3 +58,35 @@ export class BadRequestError extends Error {
|
|||||||
this.error = error;
|
this.error = error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ScimRequestError extends Error {
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
schemas: string[];
|
||||||
|
|
||||||
|
detail: string;
|
||||||
|
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
error: unknown;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
name,
|
||||||
|
error,
|
||||||
|
detail,
|
||||||
|
status
|
||||||
|
}: {
|
||||||
|
message?: string;
|
||||||
|
name?: string;
|
||||||
|
error?: unknown;
|
||||||
|
detail: string;
|
||||||
|
status: number;
|
||||||
|
}) {
|
||||||
|
super(detail ?? "The request is invalid");
|
||||||
|
this.name = name || "ScimRequestError";
|
||||||
|
this.schemas = ["urn:ietf:params:scim:api:messages:2.0:Error"];
|
||||||
|
this.error = error;
|
||||||
|
this.detail = detail;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Job, JobsOptions, Queue, QueueOptions, RepeatOptions, Worker, WorkerListener } from "bullmq";
|
import { Job, JobsOptions, Queue, QueueOptions, RepeatOptions, Worker, WorkerListener } from "bullmq";
|
||||||
import Redis from "ioredis";
|
import Redis from "ioredis";
|
||||||
|
|
||||||
|
import { SecretKeyEncoding } from "@app/db/schemas";
|
||||||
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
|
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
|
||||||
import {
|
import {
|
||||||
TScanFullRepoEventPayload,
|
TScanFullRepoEventPayload,
|
||||||
@ -15,7 +16,8 @@ export enum QueueName {
|
|||||||
IntegrationSync = "sync-integrations",
|
IntegrationSync = "sync-integrations",
|
||||||
SecretWebhook = "secret-webhook",
|
SecretWebhook = "secret-webhook",
|
||||||
SecretFullRepoScan = "secret-full-repo-scan",
|
SecretFullRepoScan = "secret-full-repo-scan",
|
||||||
SecretPushEventScan = "secret-push-event-scan"
|
SecretPushEventScan = "secret-push-event-scan",
|
||||||
|
UpgradeProjectToGhost = "upgrade-project-to-ghost"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum QueueJobs {
|
export enum QueueJobs {
|
||||||
@ -25,7 +27,8 @@ export enum QueueJobs {
|
|||||||
AuditLogPrune = "audit-log-prune-job",
|
AuditLogPrune = "audit-log-prune-job",
|
||||||
SecWebhook = "secret-webhook-trigger",
|
SecWebhook = "secret-webhook-trigger",
|
||||||
IntegrationSync = "secret-integration-pull",
|
IntegrationSync = "secret-integration-pull",
|
||||||
SecretScan = "secret-scan"
|
SecretScan = "secret-scan",
|
||||||
|
UpgradeProjectToGhost = "upgrade-project-to-ghost-job"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TQueueJobTypes = {
|
export type TQueueJobTypes = {
|
||||||
@ -64,6 +67,20 @@ export type TQueueJobTypes = {
|
|||||||
payload: TScanFullRepoEventPayload;
|
payload: TScanFullRepoEventPayload;
|
||||||
};
|
};
|
||||||
[QueueName.SecretPushEventScan]: { name: QueueJobs.SecretScan; payload: TScanPushEventPayload };
|
[QueueName.SecretPushEventScan]: { name: QueueJobs.SecretScan; payload: TScanPushEventPayload };
|
||||||
|
|
||||||
|
[QueueName.UpgradeProjectToGhost]: {
|
||||||
|
name: QueueJobs.UpgradeProjectToGhost;
|
||||||
|
payload: {
|
||||||
|
projectId: string;
|
||||||
|
startedByUserId: string;
|
||||||
|
encryptedPrivateKey: {
|
||||||
|
encryptedKey: string;
|
||||||
|
encryptedKeyIv: string;
|
||||||
|
encryptedKeyTag: string;
|
||||||
|
keyEncoding: SecretKeyEncoding;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>;
|
export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>;
|
||||||
|
@ -37,7 +37,7 @@ type TMain = {
|
|||||||
export const main = async ({ db, smtp, logger, queue }: TMain) => {
|
export const main = async ({ db, smtp, logger, queue }: TMain) => {
|
||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
const server = fasitfy({
|
const server = fasitfy({
|
||||||
logger,
|
logger: appCfg.NODE_ENV === "test" ? false : logger,
|
||||||
trustProxy: true,
|
trustProxy: true,
|
||||||
connectionTimeout: 30 * 1000,
|
connectionTimeout: 30 * 1000,
|
||||||
ignoreTrailingSlash: true
|
ignoreTrailingSlash: true
|
||||||
|
@ -63,6 +63,11 @@ export const injectAuditLogInfo = fp(async (server: FastifyZodProvider) => {
|
|||||||
identityId: req.auth.identityId
|
identityId: req.auth.identityId
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
} else if (req.auth.actor === ActorType.SCIM_CLIENT) {
|
||||||
|
payload.actor = {
|
||||||
|
type: ActorType.SCIM_CLIENT,
|
||||||
|
metadata: {}
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
throw new BadRequestError({ message: "Missing logic for other actor" });
|
throw new BadRequestError({ message: "Missing logic for other actor" });
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ import fp from "fastify-plugin";
|
|||||||
import jwt, { JwtPayload } from "jsonwebtoken";
|
import jwt, { JwtPayload } from "jsonwebtoken";
|
||||||
|
|
||||||
import { TServiceTokens, TUsers } from "@app/db/schemas";
|
import { TServiceTokens, TUsers } from "@app/db/schemas";
|
||||||
|
import { TScimTokenJwtPayload } from "@app/ee/services/scim/scim-types";
|
||||||
import { getConfig } from "@app/lib/config/env";
|
import { getConfig } from "@app/lib/config/env";
|
||||||
import { UnauthorizedError } from "@app/lib/errors";
|
import { UnauthorizedError } from "@app/lib/errors";
|
||||||
import { ActorType, AuthMode, AuthModeJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type";
|
import { ActorType, AuthMode, AuthModeJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type";
|
||||||
@ -35,6 +36,12 @@ export type TAuthMode =
|
|||||||
actor: ActorType.IDENTITY;
|
actor: ActorType.IDENTITY;
|
||||||
identityId: string;
|
identityId: string;
|
||||||
identityName: string;
|
identityName: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
authMode: AuthMode.SCIM_TOKEN;
|
||||||
|
actor: ActorType.SCIM_CLIENT;
|
||||||
|
scimTokenId: string;
|
||||||
|
orgId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractAuth = async (req: FastifyRequest, jwtSecret: string) => {
|
const extractAuth = async (req: FastifyRequest, jwtSecret: string) => {
|
||||||
@ -55,6 +62,7 @@ const extractAuth = async (req: FastifyRequest, jwtSecret: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const decodedToken = jwt.verify(authTokenValue, jwtSecret) as JwtPayload;
|
const decodedToken = jwt.verify(authTokenValue, jwtSecret) as JwtPayload;
|
||||||
|
|
||||||
switch (decodedToken.authTokenType) {
|
switch (decodedToken.authTokenType) {
|
||||||
case AuthTokenType.ACCESS_TOKEN:
|
case AuthTokenType.ACCESS_TOKEN:
|
||||||
return {
|
return {
|
||||||
@ -70,6 +78,12 @@ const extractAuth = async (req: FastifyRequest, jwtSecret: string) => {
|
|||||||
token: decodedToken as TIdentityAccessTokenJwtPayload,
|
token: decodedToken as TIdentityAccessTokenJwtPayload,
|
||||||
actor: ActorType.IDENTITY
|
actor: ActorType.IDENTITY
|
||||||
} as const;
|
} as const;
|
||||||
|
case AuthTokenType.SCIM_TOKEN:
|
||||||
|
return {
|
||||||
|
authMode: AuthMode.SCIM_TOKEN,
|
||||||
|
token: decodedToken as TScimTokenJwtPayload,
|
||||||
|
actor: ActorType.SCIM_CLIENT
|
||||||
|
} as const;
|
||||||
default:
|
default:
|
||||||
return { authMode: null, token: null } as const;
|
return { authMode: null, token: null } as const;
|
||||||
}
|
}
|
||||||
@ -113,6 +127,11 @@ export const injectIdentity = fp(async (server: FastifyZodProvider) => {
|
|||||||
req.auth = { authMode: AuthMode.API_KEY as const, userId: user.id, actor, user };
|
req.auth = { authMode: AuthMode.API_KEY as const, userId: user.id, actor, user };
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case AuthMode.SCIM_TOKEN: {
|
||||||
|
const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token);
|
||||||
|
req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId };
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new UnauthorizedError({ name: "Unknown token strategy" });
|
throw new UnauthorizedError({ name: "Unknown token strategy" });
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,8 @@ export const injectPermission = fp(async (server) => {
|
|||||||
req.permission = { type: ActorType.IDENTITY, id: req.auth.identityId };
|
req.permission = { type: ActorType.IDENTITY, id: req.auth.identityId };
|
||||||
} else if (req.auth.actor === ActorType.SERVICE) {
|
} else if (req.auth.actor === ActorType.SERVICE) {
|
||||||
req.permission = { type: ActorType.SERVICE, id: req.auth.serviceTokenId };
|
req.permission = { type: ActorType.SERVICE, id: req.auth.serviceTokenId };
|
||||||
|
} else if (req.auth.actor === ActorType.SCIM_CLIENT) {
|
||||||
|
req.permission = { type: ActorType.SCIM_CLIENT, id: req.auth.scimTokenId, orgId: req.auth.orgId };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -2,7 +2,13 @@ import { ForbiddenError } from "@casl/ability";
|
|||||||
import fastifyPlugin from "fastify-plugin";
|
import fastifyPlugin from "fastify-plugin";
|
||||||
import { ZodError } from "zod";
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
import { BadRequestError, DatabaseError, InternalServerError, UnauthorizedError } from "@app/lib/errors";
|
import {
|
||||||
|
BadRequestError,
|
||||||
|
DatabaseError,
|
||||||
|
InternalServerError,
|
||||||
|
ScimRequestError,
|
||||||
|
UnauthorizedError
|
||||||
|
} from "@app/lib/errors";
|
||||||
|
|
||||||
export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider) => {
|
export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider) => {
|
||||||
server.setErrorHandler((error, req, res) => {
|
server.setErrorHandler((error, req, res) => {
|
||||||
@ -21,6 +27,12 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
|
|||||||
error: "PermissionDenied",
|
error: "PermissionDenied",
|
||||||
message: `You are not allowed to ${error.action} on ${error.subjectType}`
|
message: `You are not allowed to ${error.action} on ${error.subjectType}`
|
||||||
});
|
});
|
||||||
|
} else if (error instanceof ScimRequestError) {
|
||||||
|
void res.status(error.status).send({
|
||||||
|
schemas: error.schemas,
|
||||||
|
status: error.status,
|
||||||
|
detail: error.detail
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
void res.send(error);
|
void res.send(error);
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,8 @@ import { permissionDALFactory } from "@app/ee/services/permission/permission-dal
|
|||||||
import { permissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { permissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
import { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal";
|
import { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal";
|
||||||
import { samlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service";
|
import { samlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service";
|
||||||
|
import { scimDALFactory } from "@app/ee/services/scim/scim-dal";
|
||||||
|
import { scimServiceFactory } from "@app/ee/services/scim/scim-service";
|
||||||
import { secretApprovalPolicyApproverDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-approver-dal";
|
import { secretApprovalPolicyApproverDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-approver-dal";
|
||||||
import { secretApprovalPolicyDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-dal";
|
import { secretApprovalPolicyDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-dal";
|
||||||
import { secretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
|
import { secretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
|
||||||
@ -63,6 +65,7 @@ import { orgRoleDALFactory } from "@app/services/org/org-role-dal";
|
|||||||
import { orgRoleServiceFactory } from "@app/services/org/org-role-service";
|
import { orgRoleServiceFactory } from "@app/services/org/org-role-service";
|
||||||
import { orgServiceFactory } from "@app/services/org/org-service";
|
import { orgServiceFactory } from "@app/services/org/org-service";
|
||||||
import { projectDALFactory } from "@app/services/project/project-dal";
|
import { projectDALFactory } from "@app/services/project/project-dal";
|
||||||
|
import { projectQueueFactory } from "@app/services/project/project-queue";
|
||||||
import { projectServiceFactory } from "@app/services/project/project-service";
|
import { projectServiceFactory } from "@app/services/project/project-service";
|
||||||
import { projectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
|
import { projectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
|
||||||
import { projectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
import { projectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
||||||
@ -155,6 +158,7 @@ export const registerRoutes = async (
|
|||||||
|
|
||||||
const auditLogDAL = auditLogDALFactory(db);
|
const auditLogDAL = auditLogDALFactory(db);
|
||||||
const trustedIpDAL = trustedIpDALFactory(db);
|
const trustedIpDAL = trustedIpDALFactory(db);
|
||||||
|
const scimDAL = scimDALFactory(db);
|
||||||
|
|
||||||
// ee db layer ops
|
// ee db layer ops
|
||||||
const permissionDAL = permissionDALFactory(db);
|
const permissionDAL = permissionDALFactory(db);
|
||||||
@ -188,6 +192,7 @@ export const registerRoutes = async (
|
|||||||
trustedIpDAL,
|
trustedIpDAL,
|
||||||
permissionService
|
permissionService
|
||||||
});
|
});
|
||||||
|
|
||||||
const auditLogQueue = auditLogQueueServiceFactory({
|
const auditLogQueue = auditLogQueueServiceFactory({
|
||||||
auditLogDAL,
|
auditLogDAL,
|
||||||
queueService,
|
queueService,
|
||||||
@ -210,6 +215,16 @@ export const registerRoutes = async (
|
|||||||
samlConfigDAL,
|
samlConfigDAL,
|
||||||
licenseService
|
licenseService
|
||||||
});
|
});
|
||||||
|
const scimService = scimServiceFactory({
|
||||||
|
licenseService,
|
||||||
|
scimDAL,
|
||||||
|
userDAL,
|
||||||
|
orgDAL,
|
||||||
|
projectDAL,
|
||||||
|
projectMembershipDAL,
|
||||||
|
permissionService,
|
||||||
|
smtpService
|
||||||
|
});
|
||||||
|
|
||||||
const telemetryService = telemetryServiceFactory();
|
const telemetryService = telemetryServiceFactory();
|
||||||
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL });
|
const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL });
|
||||||
@ -266,19 +281,13 @@ export const registerRoutes = async (
|
|||||||
secretScanningDAL,
|
secretScanningDAL,
|
||||||
secretScanningQueue
|
secretScanningQueue
|
||||||
});
|
});
|
||||||
const projectService = projectServiceFactory({
|
const projectBotService = projectBotServiceFactory({ permissionService, projectBotDAL, projectDAL });
|
||||||
permissionService,
|
|
||||||
projectDAL,
|
|
||||||
secretBlindIndexDAL,
|
|
||||||
projectEnvDAL,
|
|
||||||
projectMembershipDAL,
|
|
||||||
folderDAL,
|
|
||||||
licenseService
|
|
||||||
});
|
|
||||||
const projectMembershipService = projectMembershipServiceFactory({
|
const projectMembershipService = projectMembershipServiceFactory({
|
||||||
projectMembershipDAL,
|
projectMembershipDAL,
|
||||||
projectDAL,
|
projectDAL,
|
||||||
permissionService,
|
permissionService,
|
||||||
|
projectBotDAL,
|
||||||
orgDAL,
|
orgDAL,
|
||||||
userDAL,
|
userDAL,
|
||||||
smtpService,
|
smtpService,
|
||||||
@ -286,6 +295,46 @@ export const registerRoutes = async (
|
|||||||
projectRoleDAL,
|
projectRoleDAL,
|
||||||
licenseService
|
licenseService
|
||||||
});
|
});
|
||||||
|
const projectKeyService = projectKeyServiceFactory({
|
||||||
|
permissionService,
|
||||||
|
projectKeyDAL,
|
||||||
|
projectMembershipDAL
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectQueueService = projectQueueFactory({
|
||||||
|
queueService,
|
||||||
|
secretDAL,
|
||||||
|
folderDAL,
|
||||||
|
projectDAL,
|
||||||
|
orgDAL,
|
||||||
|
integrationAuthDAL,
|
||||||
|
orgService,
|
||||||
|
projectEnvDAL,
|
||||||
|
userDAL,
|
||||||
|
secretVersionDAL,
|
||||||
|
projectKeyDAL,
|
||||||
|
projectBotDAL,
|
||||||
|
projectMembershipDAL,
|
||||||
|
secretApprovalRequestDAL,
|
||||||
|
secretApprovalSecretDAL: sarSecretDAL
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectService = projectServiceFactory({
|
||||||
|
permissionService,
|
||||||
|
projectDAL,
|
||||||
|
projectQueue: projectQueueService,
|
||||||
|
secretBlindIndexDAL,
|
||||||
|
identityProjectDAL,
|
||||||
|
identityOrgMembershipDAL,
|
||||||
|
projectBotDAL,
|
||||||
|
projectKeyDAL,
|
||||||
|
userDAL,
|
||||||
|
projectEnvDAL,
|
||||||
|
orgService,
|
||||||
|
projectMembershipDAL,
|
||||||
|
folderDAL,
|
||||||
|
licenseService
|
||||||
|
});
|
||||||
const projectEnvService = projectEnvServiceFactory({
|
const projectEnvService = projectEnvServiceFactory({
|
||||||
permissionService,
|
permissionService,
|
||||||
projectEnvDAL,
|
projectEnvDAL,
|
||||||
@ -293,11 +342,7 @@ export const registerRoutes = async (
|
|||||||
projectDAL,
|
projectDAL,
|
||||||
folderDAL
|
folderDAL
|
||||||
});
|
});
|
||||||
const projectKeyService = projectKeyServiceFactory({
|
|
||||||
permissionService,
|
|
||||||
projectKeyDAL,
|
|
||||||
projectMembershipDAL
|
|
||||||
});
|
|
||||||
const projectRoleService = projectRoleServiceFactory({ permissionService, projectRoleDAL });
|
const projectRoleService = projectRoleServiceFactory({ permissionService, projectRoleDAL });
|
||||||
|
|
||||||
const snapshotService = secretSnapshotServiceFactory({
|
const snapshotService = secretSnapshotServiceFactory({
|
||||||
@ -332,9 +377,9 @@ export const registerRoutes = async (
|
|||||||
folderDAL,
|
folderDAL,
|
||||||
permissionService,
|
permissionService,
|
||||||
secretImportDAL,
|
secretImportDAL,
|
||||||
|
projectDAL,
|
||||||
secretDAL
|
secretDAL
|
||||||
});
|
});
|
||||||
const projectBotService = projectBotServiceFactory({ permissionService, projectBotDAL });
|
|
||||||
const integrationAuthService = integrationAuthServiceFactory({
|
const integrationAuthService = integrationAuthServiceFactory({
|
||||||
integrationAuthDAL,
|
integrationAuthDAL,
|
||||||
integrationDAL,
|
integrationDAL,
|
||||||
@ -368,6 +413,7 @@ export const registerRoutes = async (
|
|||||||
secretVersionTagDAL,
|
secretVersionTagDAL,
|
||||||
secretBlindIndexDAL,
|
secretBlindIndexDAL,
|
||||||
permissionService,
|
permissionService,
|
||||||
|
projectDAL,
|
||||||
secretDAL,
|
secretDAL,
|
||||||
secretTagDAL,
|
secretTagDAL,
|
||||||
snapshotService,
|
snapshotService,
|
||||||
@ -381,6 +427,7 @@ export const registerRoutes = async (
|
|||||||
secretTagDAL,
|
secretTagDAL,
|
||||||
secretApprovalRequestSecretDAL: sarSecretDAL,
|
secretApprovalRequestSecretDAL: sarSecretDAL,
|
||||||
secretApprovalRequestReviewerDAL: sarReviewerDAL,
|
secretApprovalRequestReviewerDAL: sarReviewerDAL,
|
||||||
|
projectDAL,
|
||||||
secretVersionDAL,
|
secretVersionDAL,
|
||||||
secretBlindIndexDAL,
|
secretBlindIndexDAL,
|
||||||
secretApprovalRequestDAL,
|
secretApprovalRequestDAL,
|
||||||
@ -486,6 +533,7 @@ export const registerRoutes = async (
|
|||||||
secretScanning: secretScanningService,
|
secretScanning: secretScanningService,
|
||||||
license: licenseService,
|
license: licenseService,
|
||||||
trustedIp: trustedIpService,
|
trustedIp: trustedIpService,
|
||||||
|
scim: scimService,
|
||||||
secretBlindIndex: secretBlindIndexService,
|
secretBlindIndex: secretBlindIndexService,
|
||||||
telemetry: telemetryService
|
telemetry: telemetryService
|
||||||
});
|
});
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { SuperAdminSchema, UsersSchema } from "@app/db/schemas";
|
import { OrganizationsSchema, SuperAdminSchema, UsersSchema } from "@app/db/schemas";
|
||||||
import { getConfig } from "@app/lib/config/env";
|
import { getConfig } from "@app/lib/config/env";
|
||||||
import { UnauthorizedError } from "@app/lib/errors";
|
import { UnauthorizedError } from "@app/lib/errors";
|
||||||
import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin";
|
import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin";
|
||||||
@ -72,6 +72,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
|
|||||||
200: z.object({
|
200: z.object({
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
user: UsersSchema,
|
user: UsersSchema,
|
||||||
|
organization: OrganizationsSchema,
|
||||||
token: z.string(),
|
token: z.string(),
|
||||||
new: z.string()
|
new: z.string()
|
||||||
})
|
})
|
||||||
@ -82,7 +83,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
|
|||||||
const serverCfg = await getServerCfg();
|
const serverCfg = await getServerCfg();
|
||||||
if (serverCfg.initialized)
|
if (serverCfg.initialized)
|
||||||
throw new UnauthorizedError({ name: "Admin sign up", message: "Admin has been created" });
|
throw new UnauthorizedError({ name: "Admin sign up", message: "Admin has been created" });
|
||||||
const { user, token } = await server.services.superAdmin.adminSignUp({
|
const { user, token, organization } = await server.services.superAdmin.adminSignUp({
|
||||||
...req.body,
|
...req.body,
|
||||||
ip: req.realIp,
|
ip: req.realIp,
|
||||||
userAgent: req.headers["user-agent"] || ""
|
userAgent: req.headers["user-agent"] || ""
|
||||||
@ -109,6 +110,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
|
|||||||
message: "Successfully set up admin account",
|
message: "Successfully set up admin account",
|
||||||
user: user.user,
|
user: user.user,
|
||||||
token: token.access,
|
token: token.access,
|
||||||
|
organization,
|
||||||
new: "123"
|
new: "123"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
|
|||||||
server.route({
|
server.route({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/",
|
url: "/",
|
||||||
onRequest: verifyAuth([AuthMode.JWT]),
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
schema: {
|
schema: {
|
||||||
description: "Create identity",
|
description: "Create identity",
|
||||||
security: [
|
security: [
|
||||||
@ -56,7 +56,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
|
|||||||
server.route({
|
server.route({
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
url: "/:identityId",
|
url: "/:identityId",
|
||||||
onRequest: verifyAuth([AuthMode.JWT]),
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
schema: {
|
schema: {
|
||||||
description: "Update identity",
|
description: "Update identity",
|
||||||
security: [
|
security: [
|
||||||
@ -105,7 +105,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
|
|||||||
server.route({
|
server.route({
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
url: "/:identityId",
|
url: "/:identityId",
|
||||||
onRequest: verifyAuth([AuthMode.JWT]),
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
schema: {
|
schema: {
|
||||||
description: "Delete identity",
|
description: "Delete identity",
|
||||||
security: [
|
security: [
|
||||||
|
@ -48,6 +48,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
|
|||||||
await projectRouter.register(registerProjectMembershipRouter);
|
await projectRouter.register(registerProjectMembershipRouter);
|
||||||
await projectRouter.register(registerSecretTagRouter);
|
await projectRouter.register(registerSecretTagRouter);
|
||||||
},
|
},
|
||||||
|
|
||||||
{ prefix: "/workspace" }
|
{ prefix: "/workspace" }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -93,7 +93,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
|||||||
.trim()
|
.trim()
|
||||||
.regex(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens")
|
.regex(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens")
|
||||||
.optional(),
|
.optional(),
|
||||||
authEnforced: z.boolean().optional()
|
authEnforced: z.boolean().optional(),
|
||||||
|
scimEnabled: z.boolean().optional()
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { OrgMembershipsSchema, ProjectMembershipsSchema, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas";
|
import {
|
||||||
|
OrgMembershipsSchema,
|
||||||
|
ProjectMembershipRole,
|
||||||
|
ProjectMembershipsSchema,
|
||||||
|
UserEncryptionKeysSchema,
|
||||||
|
UsersSchema
|
||||||
|
} from "@app/db/schemas";
|
||||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
import { AuthMode } from "@app/services/auth/auth-type";
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
@ -80,7 +86,10 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
|
|||||||
actor: req.permission.type,
|
actor: req.permission.type,
|
||||||
actorOrgId: req.permission.orgId,
|
actorOrgId: req.permission.orgId,
|
||||||
projectId: req.params.workspaceId,
|
projectId: req.params.workspaceId,
|
||||||
members: req.body.members
|
members: req.body.members.map((member) => ({
|
||||||
|
...member,
|
||||||
|
projectRole: ProjectMembershipRole.Member
|
||||||
|
}))
|
||||||
});
|
});
|
||||||
|
|
||||||
await server.services.auditLog.createAuditLog({
|
await server.services.auditLog.createAuditLog({
|
||||||
|
@ -2,13 +2,11 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
IntegrationsSchema,
|
IntegrationsSchema,
|
||||||
ProjectKeysSchema,
|
|
||||||
ProjectMembershipsSchema,
|
ProjectMembershipsSchema,
|
||||||
ProjectsSchema,
|
ProjectsSchema,
|
||||||
UserEncryptionKeysSchema,
|
UserEncryptionKeysSchema,
|
||||||
UsersSchema
|
UsersSchema
|
||||||
} from "@app/db/schemas";
|
} from "@app/db/schemas";
|
||||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
import { AuthMode } from "@app/services/auth/auth-type";
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
|
|
||||||
@ -119,7 +117,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT]),
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.SERVICE_TOKEN, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
const workspace = await server.services.project.getAProject({
|
const workspace = await server.services.project.getAProject({
|
||||||
actorId: req.permission.id,
|
actorId: req.permission.id,
|
||||||
@ -171,7 +169,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT]),
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
const workspace = await server.services.project.deleteProject({
|
const workspace = await server.services.project.deleteProject({
|
||||||
actorId: req.permission.id,
|
actorId: req.permission.id,
|
||||||
@ -216,6 +214,41 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/:workspaceId",
|
||||||
|
method: "PATCH",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
workspaceId: z.string().trim()
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
name: z.string().trim().optional(),
|
||||||
|
autoCapitalization: z.boolean().optional()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
workspace: ProjectsSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const workspace = await server.services.project.updateProject({
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
projectId: req.params.workspaceId,
|
||||||
|
update: {
|
||||||
|
name: req.body.name,
|
||||||
|
autoCapitalization: req.body.autoCapitalization
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
workspace
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
server.route({
|
server.route({
|
||||||
url: "/:workspaceId/auto-capitalization",
|
url: "/:workspaceId/auto-capitalization",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -249,48 +282,6 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.route({
|
|
||||||
url: "/:workspaceId/invite-signup",
|
|
||||||
method: "POST",
|
|
||||||
schema: {
|
|
||||||
params: z.object({
|
|
||||||
workspaceId: z.string().trim()
|
|
||||||
}),
|
|
||||||
body: z.object({
|
|
||||||
email: z.string().trim()
|
|
||||||
}),
|
|
||||||
response: {
|
|
||||||
200: z.object({
|
|
||||||
invitee: UsersSchema,
|
|
||||||
latestKey: ProjectKeysSchema.optional()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onRequest: verifyAuth([AuthMode.JWT]),
|
|
||||||
handler: async (req) => {
|
|
||||||
const { invitee, latestKey } = await server.services.projectMembership.inviteUserToProject({
|
|
||||||
actorId: req.permission.id,
|
|
||||||
actor: req.permission.type,
|
|
||||||
actorOrgId: req.permission.orgId,
|
|
||||||
projectId: req.params.workspaceId,
|
|
||||||
email: req.body.email
|
|
||||||
});
|
|
||||||
|
|
||||||
await server.services.auditLog.createAuditLog({
|
|
||||||
...req.auditLogInfo,
|
|
||||||
projectId: req.params.workspaceId,
|
|
||||||
event: {
|
|
||||||
type: EventType.ADD_WORKSPACE_MEMBER,
|
|
||||||
metadata: {
|
|
||||||
userId: invitee.id,
|
|
||||||
email: invitee.email
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { invitee, latestKey };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
server.route({
|
server.route({
|
||||||
url: "/:workspaceId/integrations",
|
url: "/:workspaceId/integrations",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
@ -2,6 +2,7 @@ import { registerIdentityOrgRouter } from "./identity-org-router";
|
|||||||
import { registerIdentityProjectRouter } from "./identity-project-router";
|
import { registerIdentityProjectRouter } from "./identity-project-router";
|
||||||
import { registerMfaRouter } from "./mfa-router";
|
import { registerMfaRouter } from "./mfa-router";
|
||||||
import { registerOrgRouter } from "./organization-router";
|
import { registerOrgRouter } from "./organization-router";
|
||||||
|
import { registerProjectMembershipRouter } from "./project-membership-router";
|
||||||
import { registerProjectRouter } from "./project-router";
|
import { registerProjectRouter } from "./project-router";
|
||||||
import { registerServiceTokenRouter } from "./service-token-router";
|
import { registerServiceTokenRouter } from "./service-token-router";
|
||||||
import { registerUserRouter } from "./user-router";
|
import { registerUserRouter } from "./user-router";
|
||||||
@ -21,6 +22,7 @@ export const registerV2Routes = async (server: FastifyZodProvider) => {
|
|||||||
async (projectServer) => {
|
async (projectServer) => {
|
||||||
await projectServer.register(registerProjectRouter);
|
await projectServer.register(registerProjectRouter);
|
||||||
await projectServer.register(registerIdentityProjectRouter);
|
await projectServer.register(registerIdentityProjectRouter);
|
||||||
|
await projectServer.register(registerProjectMembershipRouter);
|
||||||
},
|
},
|
||||||
{ prefix: "/workspace" }
|
{ prefix: "/workspace" }
|
||||||
);
|
);
|
||||||
|
95
backend/src/server/routes/v2/project-membership-router.ts
Normal file
95
backend/src/server/routes/v2/project-membership-router.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { ProjectMembershipsSchema } from "@app/db/schemas";
|
||||||
|
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||||
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
|
|
||||||
|
export const registerProjectMembershipRouter = async (server: FastifyZodProvider) => {
|
||||||
|
server.route({
|
||||||
|
method: "POST",
|
||||||
|
url: "/:projectId/memberships",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
projectId: z.string().describe("The ID of the project.")
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
emails: z.string().email().array().describe("Emails of the users to add to the project.")
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
memberships: ProjectMembershipsSchema.array()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const memberships = await server.services.projectMembership.addUsersToProjectNonE2EE({
|
||||||
|
projectId: req.params.projectId,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actor: req.permission.type,
|
||||||
|
emails: req.body.emails
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.services.auditLog.createAuditLog({
|
||||||
|
projectId: req.params.projectId,
|
||||||
|
...req.auditLogInfo,
|
||||||
|
event: {
|
||||||
|
type: EventType.ADD_BATCH_WORKSPACE_MEMBER,
|
||||||
|
metadata: memberships.map(({ userId, id }) => ({
|
||||||
|
userId: userId || "",
|
||||||
|
membershipId: id,
|
||||||
|
email: ""
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { memberships };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "DELETE",
|
||||||
|
url: "/:projectId/memberships",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
projectId: z.string().describe("The ID of the project.")
|
||||||
|
}),
|
||||||
|
|
||||||
|
body: z.object({
|
||||||
|
emails: z.string().email().array().describe("Emails of the users to remove from the project.")
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
memberships: ProjectMembershipsSchema.array()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const memberships = await server.services.projectMembership.deleteProjectMemberships({
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
projectId: req.params.projectId,
|
||||||
|
emails: req.body.emails
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const membership of memberships) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await server.services.auditLog.createAuditLog({
|
||||||
|
...req.auditLogInfo,
|
||||||
|
projectId: req.params.projectId,
|
||||||
|
event: {
|
||||||
|
type: EventType.REMOVE_WORKSPACE_MEMBER,
|
||||||
|
metadata: {
|
||||||
|
userId: membership.userId,
|
||||||
|
email: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { memberships };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
@ -1,11 +1,21 @@
|
|||||||
|
import slugify from "@sindresorhus/slugify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { ProjectKeysSchema } from "@app/db/schemas";
|
import { ProjectKeysSchema, ProjectsSchema } from "@app/db/schemas";
|
||||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||||
|
import { authRateLimit } from "@app/server/config/rateLimiter";
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
import { AuthMode } from "@app/services/auth/auth-type";
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
|
|
||||||
|
const projectWithEnv = ProjectsSchema.merge(
|
||||||
|
z.object({
|
||||||
|
_id: z.string(),
|
||||||
|
environments: z.object({ name: z.string(), slug: z.string(), id: z.string() }).array()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||||
|
/* Get project key */
|
||||||
server.route({
|
server.route({
|
||||||
url: "/:workspaceId/encrypted-key",
|
url: "/:workspaceId/encrypted-key",
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@ -34,8 +44,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
const key = await server.services.projectKey.getLatestProjectKey({
|
const key = await server.services.projectKey.getLatestProjectKey({
|
||||||
actor: req.permission.type,
|
actor: req.permission.type,
|
||||||
actorId: req.permission.id,
|
actorId: req.permission.id,
|
||||||
projectId: req.params.workspaceId,
|
actorOrgId: req.permission.orgId,
|
||||||
actorOrgId: req.permission.orgId
|
projectId: req.params.workspaceId
|
||||||
});
|
});
|
||||||
|
|
||||||
await server.services.auditLog.createAuditLog({
|
await server.services.auditLog.createAuditLog({
|
||||||
@ -52,4 +62,97 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* Start upgrade of a project */
|
||||||
|
server.route({
|
||||||
|
url: "/:projectId/upgrade",
|
||||||
|
method: "POST",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
projectId: z.string().trim()
|
||||||
|
}),
|
||||||
|
|
||||||
|
body: z.object({
|
||||||
|
userPrivateKey: z.string().trim()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.void()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
|
||||||
|
handler: async (req) => {
|
||||||
|
await server.services.project.upgradeProject({
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actor: req.permission.type,
|
||||||
|
projectId: req.params.projectId,
|
||||||
|
userPrivateKey: req.body.userPrivateKey
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Get upgrade status of project */
|
||||||
|
server.route({
|
||||||
|
url: "/:projectId/upgrade/status",
|
||||||
|
method: "GET",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
projectId: z.string().trim()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
status: z.string().nullable()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const status = await server.services.project.getProjectUpgradeStatus({
|
||||||
|
projectId: req.params.projectId,
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Create new project */
|
||||||
|
server.route({
|
||||||
|
method: "POST",
|
||||||
|
url: "/",
|
||||||
|
config: {
|
||||||
|
rateLimit: authRateLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
body: z.object({
|
||||||
|
projectName: z.string().trim(),
|
||||||
|
slug: z
|
||||||
|
.string()
|
||||||
|
.min(5)
|
||||||
|
.max(36)
|
||||||
|
.refine((v) => slugify(v) === v, {
|
||||||
|
message: "Slug must be a valid slug"
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
organizationId: z.string().trim()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
project: projectWithEnv
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const project = await server.services.project.createProject({
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actor: req.permission.type,
|
||||||
|
orgId: req.body.organizationId,
|
||||||
|
workspaceName: req.body.projectName,
|
||||||
|
slug: req.body.slug
|
||||||
|
});
|
||||||
|
|
||||||
|
return { project };
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
@ -197,7 +197,7 @@ export const registerUserRouter = async (server: FastifyZodProvider) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT]),
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY]),
|
||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
const user = await server.services.user.getMe(req.permission.id);
|
const user = await server.services.user.getMe(req.permission.id);
|
||||||
return { user };
|
return { user };
|
||||||
|
@ -1092,7 +1092,6 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
|||||||
secrets: z
|
secrets: z
|
||||||
.object({
|
.object({
|
||||||
secretName: z.string().trim(),
|
secretName: z.string().trim(),
|
||||||
type: z.nativeEnum(SecretType).default(SecretType.Shared),
|
|
||||||
secretKeyCiphertext: z.string().trim(),
|
secretKeyCiphertext: z.string().trim(),
|
||||||
secretKeyIV: z.string().trim(),
|
secretKeyIV: z.string().trim(),
|
||||||
secretKeyTag: z.string().trim(),
|
secretKeyTag: z.string().trim(),
|
||||||
@ -1139,7 +1138,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
|
|||||||
projectId,
|
projectId,
|
||||||
policy,
|
policy,
|
||||||
data: {
|
data: {
|
||||||
[CommitType.Create]: inputSecrets.filter(({ type }) => type === "shared")
|
[CommitType.Create]: inputSecrets
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -156,7 +156,8 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => {
|
|||||||
const { user, accessToken, refreshToken } = await server.services.signup.completeAccountInvite({
|
const { user, accessToken, refreshToken } = await server.services.signup.completeAccountInvite({
|
||||||
...req.body,
|
...req.body,
|
||||||
ip: req.realIp,
|
ip: req.realIp,
|
||||||
userAgent
|
userAgent,
|
||||||
|
authorization: req.headers.authorization as string
|
||||||
});
|
});
|
||||||
|
|
||||||
void server.services.telemetry.sendLoopsEvent(user.email, user.firstName || "", user.lastName || "");
|
void server.services.telemetry.sendLoopsEvent(user.email, user.firstName || "", user.lastName || "");
|
||||||
|
@ -275,7 +275,7 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
|
|||||||
if (isOauthSignUpDisabled) throw new BadRequestError({ message: "User signup disabled", name: "Oauth 2 login" });
|
if (isOauthSignUpDisabled) throw new BadRequestError({ message: "User signup disabled", name: "Oauth 2 login" });
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
user = await userDAL.create({ email, firstName, lastName, authMethods: [authMethod] });
|
user = await userDAL.create({ email, firstName, lastName, authMethods: [authMethod], isGhost: false });
|
||||||
}
|
}
|
||||||
const isLinkingRequired = !user?.authMethods?.includes(authMethod);
|
const isLinkingRequired = !user?.authMethods?.includes(authMethod);
|
||||||
const isUserCompleted = user.isAccepted;
|
const isUserCompleted = user.isAccepted;
|
||||||
|
@ -50,7 +50,7 @@ export const authSignupServiceFactory = ({
|
|||||||
throw new Error("Failed to send verification code for complete account");
|
throw new Error("Failed to send verification code for complete account");
|
||||||
}
|
}
|
||||||
if (!user) {
|
if (!user) {
|
||||||
user = await userDAL.create({ authMethods: [AuthMethod.EMAIL], email });
|
user = await userDAL.create({ authMethods: [AuthMethod.EMAIL], email, isGhost: false });
|
||||||
}
|
}
|
||||||
if (!user) throw new Error("Failed to create user");
|
if (!user) throw new Error("Failed to create user");
|
||||||
|
|
||||||
@ -212,13 +212,16 @@ export const authSignupServiceFactory = ({
|
|||||||
protectedKeyTag,
|
protectedKeyTag,
|
||||||
encryptedPrivateKey,
|
encryptedPrivateKey,
|
||||||
encryptedPrivateKeyIV,
|
encryptedPrivateKeyIV,
|
||||||
encryptedPrivateKeyTag
|
encryptedPrivateKeyTag,
|
||||||
|
authorization
|
||||||
}: TCompleteAccountInviteDTO) => {
|
}: TCompleteAccountInviteDTO) => {
|
||||||
const user = await userDAL.findUserByEmail(email);
|
const user = await userDAL.findUserByEmail(email);
|
||||||
if (!user || (user && user.isAccepted)) {
|
if (!user || (user && user.isAccepted)) {
|
||||||
throw new Error("Failed to complete account for complete user");
|
throw new Error("Failed to complete account for complete user");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
validateSignUpAuthorization(authorization, user.id);
|
||||||
|
|
||||||
const [orgMembership] = await orgDAL.findMembership({
|
const [orgMembership] = await orgDAL.findMembership({
|
||||||
inviteEmail: email,
|
inviteEmail: email,
|
||||||
status: OrgMembershipStatus.Invited
|
status: OrgMembershipStatus.Invited
|
||||||
|
@ -34,4 +34,5 @@ export type TCompleteAccountInviteDTO = {
|
|||||||
verifier: string;
|
verifier: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
userAgent: string;
|
userAgent: string;
|
||||||
|
authorization: string;
|
||||||
};
|
};
|
||||||
|
@ -17,21 +17,24 @@ export enum AuthTokenType {
|
|||||||
API_KEY = "apiKey",
|
API_KEY = "apiKey",
|
||||||
SERVICE_ACCESS_TOKEN = "serviceAccessToken",
|
SERVICE_ACCESS_TOKEN = "serviceAccessToken",
|
||||||
SERVICE_REFRESH_TOKEN = "serviceRefreshToken",
|
SERVICE_REFRESH_TOKEN = "serviceRefreshToken",
|
||||||
IDENTITY_ACCESS_TOKEN = "identityAccessToken"
|
IDENTITY_ACCESS_TOKEN = "identityAccessToken",
|
||||||
|
SCIM_TOKEN = "scimToken"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AuthMode {
|
export enum AuthMode {
|
||||||
JWT = "jwt",
|
JWT = "jwt",
|
||||||
SERVICE_TOKEN = "serviceToken",
|
SERVICE_TOKEN = "serviceToken",
|
||||||
API_KEY = "apiKey",
|
API_KEY = "apiKey",
|
||||||
IDENTITY_ACCESS_TOKEN = "identityAccessToken"
|
IDENTITY_ACCESS_TOKEN = "identityAccessToken",
|
||||||
|
SCIM_TOKEN = "scimToken"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ActorType { // would extend to AWS, Azure, ...
|
export enum ActorType { // would extend to AWS, Azure, ...
|
||||||
USER = "user", // userIdentity
|
USER = "user", // userIdentity
|
||||||
SERVICE = "service",
|
SERVICE = "service",
|
||||||
IDENTITY = "identity",
|
IDENTITY = "identity",
|
||||||
Machine = "machine"
|
Machine = "machine",
|
||||||
|
SCIM_CLIENT = "scimClient"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AuthModeJwtTokenPayload = {
|
export type AuthModeJwtTokenPayload = {
|
||||||
|
@ -1,10 +1,35 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
import { TDbClient } from "@app/db";
|
import { TDbClient } from "@app/db";
|
||||||
import { TableName } from "@app/db/schemas";
|
import { TableName, TIntegrationAuths, TIntegrationAuthsUpdate } from "@app/db/schemas";
|
||||||
|
import { BadRequestError, DatabaseError } from "@app/lib/errors";
|
||||||
import { ormify } from "@app/lib/knex";
|
import { ormify } from "@app/lib/knex";
|
||||||
|
|
||||||
export type TIntegrationAuthDALFactory = ReturnType<typeof integrationAuthDALFactory>;
|
export type TIntegrationAuthDALFactory = ReturnType<typeof integrationAuthDALFactory>;
|
||||||
|
|
||||||
export const integrationAuthDALFactory = (db: TDbClient) => {
|
export const integrationAuthDALFactory = (db: TDbClient) => {
|
||||||
const integrationAuthOrm = ormify(db, TableName.IntegrationAuth);
|
const integrationAuthOrm = ormify(db, TableName.IntegrationAuth);
|
||||||
return integrationAuthOrm;
|
|
||||||
|
const bulkUpdate = async (
|
||||||
|
data: Array<{ filter: Partial<TIntegrationAuths>; data: TIntegrationAuthsUpdate }>,
|
||||||
|
tx?: Knex
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const integrationAuths = await Promise.all(
|
||||||
|
data.map(async ({ filter, data: updateData }) => {
|
||||||
|
const [doc] = await (tx || db)(TableName.IntegrationAuth).where(filter).update(updateData).returning("*");
|
||||||
|
if (!doc) throw new BadRequestError({ message: "Failed to update document" });
|
||||||
|
return doc;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return integrationAuths;
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "bulk update secret" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...integrationAuthOrm,
|
||||||
|
bulkUpdate
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
@ -76,7 +76,8 @@ export const orgDALFactory = (db: TDbClient) => {
|
|||||||
db.ref("lastName").withSchema(TableName.Users),
|
db.ref("lastName").withSchema(TableName.Users),
|
||||||
db.ref("id").withSchema(TableName.Users).as("userId"),
|
db.ref("id").withSchema(TableName.Users).as("userId"),
|
||||||
db.ref("publicKey").withSchema(TableName.UserEncryptionKey)
|
db.ref("publicKey").withSchema(TableName.UserEncryptionKey)
|
||||||
);
|
)
|
||||||
|
.where({ isGhost: false }); // MAKE SURE USER IS NOT A GHOST USER
|
||||||
return members.map(({ email, firstName, lastName, userId, publicKey, ...data }) => ({
|
return members.map(({ email, firstName, lastName, userId, publicKey, ...data }) => ({
|
||||||
...data,
|
...data,
|
||||||
user: { email, firstName, lastName, id: userId, publicKey }
|
user: { email, firstName, lastName, id: userId, publicKey }
|
||||||
@ -86,6 +87,79 @@ export const orgDALFactory = (db: TDbClient) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findOrgMembersByEmail = async (orgId: string, emails: string[]) => {
|
||||||
|
try {
|
||||||
|
const members = await db(TableName.OrgMembership)
|
||||||
|
.where({ orgId })
|
||||||
|
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
|
||||||
|
.leftJoin<TUserEncryptionKeys>(
|
||||||
|
TableName.UserEncryptionKey,
|
||||||
|
`${TableName.UserEncryptionKey}.userId`,
|
||||||
|
`${TableName.Users}.id`
|
||||||
|
)
|
||||||
|
.select(
|
||||||
|
db.ref("id").withSchema(TableName.OrgMembership),
|
||||||
|
db.ref("inviteEmail").withSchema(TableName.OrgMembership),
|
||||||
|
db.ref("orgId").withSchema(TableName.OrgMembership),
|
||||||
|
db.ref("role").withSchema(TableName.OrgMembership),
|
||||||
|
db.ref("roleId").withSchema(TableName.OrgMembership),
|
||||||
|
db.ref("status").withSchema(TableName.OrgMembership),
|
||||||
|
db.ref("email").withSchema(TableName.Users),
|
||||||
|
db.ref("firstName").withSchema(TableName.Users),
|
||||||
|
db.ref("lastName").withSchema(TableName.Users),
|
||||||
|
db.ref("id").withSchema(TableName.Users).as("userId"),
|
||||||
|
db.ref("publicKey").withSchema(TableName.UserEncryptionKey)
|
||||||
|
)
|
||||||
|
.where({ isGhost: false })
|
||||||
|
.whereIn("email", emails);
|
||||||
|
return members.map(({ email, firstName, lastName, userId, publicKey, ...data }) => ({
|
||||||
|
...data,
|
||||||
|
user: { email, firstName, lastName, id: userId, publicKey }
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find all org members" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const findOrgGhostUser = async (orgId: string) => {
|
||||||
|
try {
|
||||||
|
const member = await db(TableName.OrgMembership)
|
||||||
|
.where({ orgId })
|
||||||
|
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
|
||||||
|
.leftJoin(TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id`)
|
||||||
|
.select(
|
||||||
|
db.ref("id").withSchema(TableName.OrgMembership),
|
||||||
|
db.ref("orgId").withSchema(TableName.OrgMembership),
|
||||||
|
db.ref("role").withSchema(TableName.OrgMembership),
|
||||||
|
db.ref("roleId").withSchema(TableName.OrgMembership),
|
||||||
|
db.ref("status").withSchema(TableName.OrgMembership),
|
||||||
|
db.ref("email").withSchema(TableName.Users),
|
||||||
|
db.ref("id").withSchema(TableName.Users).as("userId"),
|
||||||
|
db.ref("publicKey").withSchema(TableName.UserEncryptionKey)
|
||||||
|
)
|
||||||
|
.where({ isGhost: true })
|
||||||
|
.first();
|
||||||
|
return member;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ghostUserExists = async (orgId: string) => {
|
||||||
|
try {
|
||||||
|
const member = await db(TableName.OrgMembership)
|
||||||
|
.where({ orgId })
|
||||||
|
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
|
||||||
|
.leftJoin(TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id`)
|
||||||
|
.select(db.ref("id").withSchema(TableName.Users).as("userId"))
|
||||||
|
.where({ isGhost: true })
|
||||||
|
.first();
|
||||||
|
return Boolean(member);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const create = async (dto: TOrganizationsInsert, tx?: Knex) => {
|
const create = async (dto: TOrganizationsInsert, tx?: Knex) => {
|
||||||
try {
|
try {
|
||||||
const [organization] = await (tx || db)(TableName.Organization).insert(dto).returning("*");
|
const [organization] = await (tx || db)(TableName.Organization).insert(dto).returning("*");
|
||||||
@ -165,7 +239,14 @@ export const orgDALFactory = (db: TDbClient) => {
|
|||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
.where(buildFindFilter(filter))
|
.where(buildFindFilter(filter))
|
||||||
.join(TableName.Users, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`)
|
.join(TableName.Users, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`)
|
||||||
.select(selectAllTableCols(TableName.OrgMembership), db.ref("email").withSchema(TableName.Users));
|
.join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`)
|
||||||
|
.select(
|
||||||
|
selectAllTableCols(TableName.OrgMembership),
|
||||||
|
db.ref("email").withSchema(TableName.Users),
|
||||||
|
db.ref("firstName").withSchema(TableName.Users),
|
||||||
|
db.ref("lastName").withSchema(TableName.Users),
|
||||||
|
db.ref("scimEnabled").withSchema(TableName.Organization)
|
||||||
|
);
|
||||||
if (limit) void query.limit(limit);
|
if (limit) void query.limit(limit);
|
||||||
if (offset) void query.offset(offset);
|
if (offset) void query.offset(offset);
|
||||||
if (sort) {
|
if (sort) {
|
||||||
@ -184,6 +265,9 @@ export const orgDALFactory = (db: TDbClient) => {
|
|||||||
findAllOrgMembers,
|
findAllOrgMembers,
|
||||||
findOrgById,
|
findOrgById,
|
||||||
findAllOrgsByUserId,
|
findAllOrgsByUserId,
|
||||||
|
ghostUserExists,
|
||||||
|
findOrgMembersByEmail,
|
||||||
|
findOrgGhostUser,
|
||||||
create,
|
create,
|
||||||
updateById,
|
updateById,
|
||||||
deleteById,
|
deleteById,
|
||||||
|
41
backend/src/services/org/org-fns.ts
Normal file
41
backend/src/services/org/org-fns.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { TOrgDALFactory } from "@app/services/org/org-dal";
|
||||||
|
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||||
|
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
|
||||||
|
|
||||||
|
type TDeleteOrgMembership = {
|
||||||
|
orgMembershipId: string;
|
||||||
|
orgId: string;
|
||||||
|
orgDAL: Pick<TOrgDALFactory, "findMembership" | "deleteMembershipById" | "transaction">;
|
||||||
|
projectDAL: Pick<TProjectDALFactory, "find">;
|
||||||
|
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "delete">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteOrgMembership = async ({
|
||||||
|
orgMembershipId,
|
||||||
|
orgId,
|
||||||
|
orgDAL,
|
||||||
|
projectDAL,
|
||||||
|
projectMembershipDAL
|
||||||
|
}: TDeleteOrgMembership) => {
|
||||||
|
const membership = await orgDAL.transaction(async (tx) => {
|
||||||
|
// delete org membership
|
||||||
|
const orgMembership = await orgDAL.deleteMembershipById(orgMembershipId, orgId, tx);
|
||||||
|
|
||||||
|
const projects = await projectDAL.find({ orgId }, { tx });
|
||||||
|
|
||||||
|
// delete associated project memberships
|
||||||
|
await projectMembershipDAL.delete(
|
||||||
|
{
|
||||||
|
$in: {
|
||||||
|
projectId: projects.map((project) => project.id)
|
||||||
|
},
|
||||||
|
userId: orgMembership.userId as string
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
return orgMembership;
|
||||||
|
});
|
||||||
|
|
||||||
|
return membership;
|
||||||
|
};
|
@ -1,6 +1,8 @@
|
|||||||
import { ForbiddenError } from "@casl/ability";
|
import { ForbiddenError } from "@casl/ability";
|
||||||
import slugify from "@sindresorhus/slugify";
|
import slugify from "@sindresorhus/slugify";
|
||||||
|
import crypto from "crypto";
|
||||||
import jwt from "jsonwebtoken";
|
import jwt from "jsonwebtoken";
|
||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas";
|
import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas";
|
||||||
import { TProjects } from "@app/db/schemas/projects";
|
import { TProjects } from "@app/db/schemas/projects";
|
||||||
@ -11,6 +13,7 @@ import { TSamlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-
|
|||||||
import { getConfig } from "@app/lib/config/env";
|
import { getConfig } from "@app/lib/config/env";
|
||||||
import { generateAsymmetricKeyPair } from "@app/lib/crypto";
|
import { generateAsymmetricKeyPair } from "@app/lib/crypto";
|
||||||
import { generateSymmetricKey, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
import { generateSymmetricKey, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||||
|
import { generateUserSrpKeys } from "@app/lib/crypto/srp";
|
||||||
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
|
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
|
||||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||||
import { isDisposableEmail } from "@app/lib/validator";
|
import { isDisposableEmail } from "@app/lib/validator";
|
||||||
@ -28,6 +31,7 @@ import { TOrgRoleDALFactory } from "./org-role-dal";
|
|||||||
import {
|
import {
|
||||||
TDeleteOrgMembershipDTO,
|
TDeleteOrgMembershipDTO,
|
||||||
TFindAllWorkspacesDTO,
|
TFindAllWorkspacesDTO,
|
||||||
|
TFindOrgMembersByEmailDTO,
|
||||||
TInviteUserToOrgDTO,
|
TInviteUserToOrgDTO,
|
||||||
TUpdateOrgDTO,
|
TUpdateOrgDTO,
|
||||||
TUpdateOrgMembershipDTO,
|
TUpdateOrgMembershipDTO,
|
||||||
@ -93,6 +97,15 @@ export const orgServiceFactory = ({
|
|||||||
return members;
|
return members;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findOrgMembersByEmail = async ({ actor, actorId, orgId, emails }: TFindOrgMembersByEmailDTO) => {
|
||||||
|
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member);
|
||||||
|
|
||||||
|
const members = await orgDAL.findOrgMembersByEmail(orgId, emails);
|
||||||
|
|
||||||
|
return members;
|
||||||
|
};
|
||||||
|
|
||||||
const findAllWorkspaces = async ({ actor, actorId, actorOrgId, orgId }: TFindAllWorkspacesDTO) => {
|
const findAllWorkspaces = async ({ actor, actorId, actorOrgId, orgId }: TFindAllWorkspacesDTO) => {
|
||||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
|
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
|
||||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace);
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace);
|
||||||
@ -118,6 +131,54 @@ export const orgServiceFactory = ({
|
|||||||
return workspaces.filter((workspace) => organizationWorkspaceIds.has(workspace.id));
|
return workspaces.filter((workspace) => organizationWorkspaceIds.has(workspace.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addGhostUser = async (orgId: string, tx?: Knex) => {
|
||||||
|
const email = `sudo-${alphaNumericNanoId(16)}-${orgId}@infisical.com`; // We add a nanoid because the email is unique. And we have to create a new ghost user each time, so we can have access to the private key.
|
||||||
|
const password = crypto.randomBytes(128).toString("hex");
|
||||||
|
|
||||||
|
const user = await userDAL.create(
|
||||||
|
{
|
||||||
|
isGhost: true,
|
||||||
|
authMethods: [AuthMethod.EMAIL],
|
||||||
|
email,
|
||||||
|
isAccepted: true
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
const encKeys = await generateUserSrpKeys(email, password);
|
||||||
|
|
||||||
|
await userDAL.upsertUserEncryptionKey(
|
||||||
|
user.id,
|
||||||
|
{
|
||||||
|
encryptionVersion: 2,
|
||||||
|
protectedKey: encKeys.protectedKey,
|
||||||
|
protectedKeyIV: encKeys.protectedKeyIV,
|
||||||
|
protectedKeyTag: encKeys.protectedKeyTag,
|
||||||
|
publicKey: encKeys.publicKey,
|
||||||
|
encryptedPrivateKey: encKeys.encryptedPrivateKey,
|
||||||
|
iv: encKeys.encryptedPrivateKeyIV,
|
||||||
|
tag: encKeys.encryptedPrivateKeyTag,
|
||||||
|
salt: encKeys.salt,
|
||||||
|
verifier: encKeys.verifier
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
const createMembershipData = {
|
||||||
|
orgId,
|
||||||
|
userId: user.id,
|
||||||
|
role: OrgMembershipRole.Admin,
|
||||||
|
status: OrgMembershipStatus.Accepted
|
||||||
|
};
|
||||||
|
|
||||||
|
await orgDAL.createMembership(createMembershipData, tx);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
keys: encKeys
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Update organization details
|
* Update organization details
|
||||||
* */
|
* */
|
||||||
@ -126,16 +187,32 @@ export const orgServiceFactory = ({
|
|||||||
actorId,
|
actorId,
|
||||||
actorOrgId,
|
actorOrgId,
|
||||||
orgId,
|
orgId,
|
||||||
data: { name, slug, authEnforced }
|
data: { name, slug, authEnforced, scimEnabled }
|
||||||
}: TUpdateOrgDTO) => {
|
}: TUpdateOrgDTO) => {
|
||||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
|
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
|
||||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings);
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings);
|
||||||
|
|
||||||
|
const plan = await licenseService.getPlan(orgId);
|
||||||
|
|
||||||
if (authEnforced !== undefined) {
|
if (authEnforced !== undefined) {
|
||||||
|
if (!plan?.samlSSO)
|
||||||
|
throw new BadRequestError({
|
||||||
|
message:
|
||||||
|
"Failed to enforce/un-enforce SAML SSO due to plan restriction. Upgrade plan to enforce/un-enforce SAML SSO."
|
||||||
|
});
|
||||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authEnforced) {
|
if (scimEnabled !== undefined) {
|
||||||
|
if (!plan?.scim)
|
||||||
|
throw new BadRequestError({
|
||||||
|
message:
|
||||||
|
"Failed to enable/disable SCIM provisioning due to plan restriction. Upgrade plan to enable/disable SCIM provisioning."
|
||||||
|
});
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authEnforced || scimEnabled) {
|
||||||
const samlCfg = await samlConfigDAL.findEnforceableSamlCfg(orgId);
|
const samlCfg = await samlConfigDAL.findEnforceableSamlCfg(orgId);
|
||||||
if (!samlCfg)
|
if (!samlCfg)
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
@ -147,7 +224,8 @@ export const orgServiceFactory = ({
|
|||||||
const org = await orgDAL.updateById(orgId, {
|
const org = await orgDAL.updateById(orgId, {
|
||||||
name,
|
name,
|
||||||
slug: slug ? slugify(slug) : undefined,
|
slug: slug ? slugify(slug) : undefined,
|
||||||
authEnforced
|
authEnforced,
|
||||||
|
scimEnabled
|
||||||
});
|
});
|
||||||
if (!org) throw new BadRequestError({ name: "Org not found", message: "Organization not found" });
|
if (!org) throw new BadRequestError({ name: "Org not found", message: "Organization not found" });
|
||||||
return org;
|
return org;
|
||||||
@ -321,7 +399,8 @@ export const orgServiceFactory = ({
|
|||||||
{
|
{
|
||||||
email: inviteeEmail,
|
email: inviteeEmail,
|
||||||
isAccepted: false,
|
isAccepted: false,
|
||||||
authMethods: [AuthMethod.EMAIL]
|
authMethods: [AuthMethod.EMAIL],
|
||||||
|
isGhost: false
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
@ -470,10 +549,12 @@ export const orgServiceFactory = ({
|
|||||||
inviteUserToOrganization,
|
inviteUserToOrganization,
|
||||||
verifyUserToOrg,
|
verifyUserToOrg,
|
||||||
updateOrg,
|
updateOrg,
|
||||||
|
findOrgMembersByEmail,
|
||||||
createOrganization,
|
createOrganization,
|
||||||
deleteOrganizationById,
|
deleteOrganizationById,
|
||||||
deleteOrgMembership,
|
deleteOrgMembership,
|
||||||
findAllWorkspaces,
|
findAllWorkspaces,
|
||||||
|
addGhostUser,
|
||||||
updateOrgMembership,
|
updateOrgMembership,
|
||||||
// incident contacts
|
// incident contacts
|
||||||
findIncidentContacts,
|
findIncidentContacts,
|
||||||
|
@ -30,6 +30,13 @@ export type TVerifyUserToOrgDTO = {
|
|||||||
code: string;
|
code: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TFindOrgMembersByEmailDTO = {
|
||||||
|
actor: ActorType;
|
||||||
|
actorId: string;
|
||||||
|
orgId: string;
|
||||||
|
emails: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type TFindAllWorkspacesDTO = {
|
export type TFindAllWorkspacesDTO = {
|
||||||
actor: ActorType;
|
actor: ActorType;
|
||||||
actorId: string;
|
actorId: string;
|
||||||
@ -38,5 +45,5 @@ export type TFindAllWorkspacesDTO = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type TUpdateOrgDTO = {
|
export type TUpdateOrgDTO = {
|
||||||
data: Partial<{ name: string; slug: string; authEnforced: boolean }>;
|
data: Partial<{ name: string; slug: string; authEnforced: boolean; scimEnabled: boolean }>;
|
||||||
} & TOrgPermission;
|
} & TOrgPermission;
|
||||||
|
@ -27,5 +27,19 @@ export const projectBotDALFactory = (db: TDbClient) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { ...projectBotOrm, findOne };
|
const findProjectByBotId = async (botId: string) => {
|
||||||
|
try {
|
||||||
|
const project = await db(TableName.ProjectBot)
|
||||||
|
.where({ [`${TableName.ProjectBot}.id` as "id"]: botId })
|
||||||
|
.join(TableName.Project, `${TableName.ProjectBot}.projectId`, `${TableName.Project}.id`)
|
||||||
|
.select(selectAllTableCols(TableName.Project))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return project || null;
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find project by bot id" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...projectBotOrm, findOne, findProjectByBotId };
|
||||||
};
|
};
|
||||||
|
@ -1,88 +1,80 @@
|
|||||||
import { ForbiddenError } from "@casl/ability";
|
import { ForbiddenError } from "@casl/ability";
|
||||||
|
|
||||||
import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas";
|
import { ProjectVersion, SecretKeyEncoding } from "@app/db/schemas";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||||
import { getConfig } from "@app/lib/config/env";
|
import { decryptAsymmetric, generateAsymmetricKeyPair } from "@app/lib/crypto";
|
||||||
import {
|
import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||||
decryptAsymmetric,
|
|
||||||
decryptSymmetric,
|
|
||||||
decryptSymmetric128BitHexKeyUTF8,
|
|
||||||
encryptSymmetric,
|
|
||||||
encryptSymmetric128BitHexKeyUTF8,
|
|
||||||
generateAsymmetricKeyPair
|
|
||||||
} from "@app/lib/crypto";
|
|
||||||
import { BadRequestError } from "@app/lib/errors";
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
import { TProjectPermission } from "@app/lib/types";
|
|
||||||
|
|
||||||
|
import { TProjectDALFactory } from "../project/project-dal";
|
||||||
import { TProjectBotDALFactory } from "./project-bot-dal";
|
import { TProjectBotDALFactory } from "./project-bot-dal";
|
||||||
import { TSetActiveStateDTO } from "./project-bot-types";
|
import { TFindBotByProjectIdDTO, TGetPrivateKeyDTO, TSetActiveStateDTO } from "./project-bot-types";
|
||||||
|
|
||||||
type TProjectBotServiceFactoryDep = {
|
type TProjectBotServiceFactoryDep = {
|
||||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||||
|
projectDAL: Pick<TProjectDALFactory, "findById">;
|
||||||
projectBotDAL: TProjectBotDALFactory;
|
projectBotDAL: TProjectBotDALFactory;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TProjectBotServiceFactory = ReturnType<typeof projectBotServiceFactory>;
|
export type TProjectBotServiceFactory = ReturnType<typeof projectBotServiceFactory>;
|
||||||
|
|
||||||
export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: TProjectBotServiceFactoryDep) => {
|
export const projectBotServiceFactory = ({
|
||||||
const getBotKey = async (projectId: string) => {
|
projectBotDAL,
|
||||||
const appCfg = getConfig();
|
projectDAL,
|
||||||
const encryptionKey = appCfg.ENCRYPTION_KEY;
|
permissionService
|
||||||
const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY;
|
}: TProjectBotServiceFactoryDep) => {
|
||||||
|
const getBotPrivateKey = ({ bot }: TGetPrivateKeyDTO) =>
|
||||||
|
infisicalSymmetricDecrypt({
|
||||||
|
keyEncoding: bot.keyEncoding as SecretKeyEncoding,
|
||||||
|
iv: bot.iv,
|
||||||
|
tag: bot.tag,
|
||||||
|
ciphertext: bot.encryptedPrivateKey
|
||||||
|
});
|
||||||
|
|
||||||
|
const getBotKey = async (projectId: string) => {
|
||||||
const bot = await projectBotDAL.findOne({ projectId });
|
const bot = await projectBotDAL.findOne({ projectId });
|
||||||
if (!bot) throw new BadRequestError({ message: "failed to find bot key" });
|
if (!bot) throw new BadRequestError({ message: "failed to find bot key" });
|
||||||
if (!bot.isActive) throw new BadRequestError({ message: "Bot is not active" });
|
if (!bot.isActive) throw new BadRequestError({ message: "Bot is not active" });
|
||||||
if (!bot.encryptedProjectKeyNonce || !bot.encryptedProjectKey)
|
if (!bot.encryptedProjectKeyNonce || !bot.encryptedProjectKey)
|
||||||
throw new BadRequestError({ message: "Encryption key missing" });
|
throw new BadRequestError({ message: "Encryption key missing" });
|
||||||
|
|
||||||
if (rootEncryptionKey && (bot.keyEncoding as SecretKeyEncoding) === SecretKeyEncoding.BASE64) {
|
const botPrivateKey = getBotPrivateKey({ bot });
|
||||||
const privateKeyBot = decryptSymmetric({
|
|
||||||
iv: bot.iv,
|
|
||||||
tag: bot.tag,
|
|
||||||
ciphertext: bot.encryptedPrivateKey,
|
|
||||||
key: rootEncryptionKey
|
|
||||||
});
|
|
||||||
return decryptAsymmetric({
|
|
||||||
ciphertext: bot.encryptedProjectKey,
|
|
||||||
privateKey: privateKeyBot,
|
|
||||||
nonce: bot.encryptedProjectKeyNonce,
|
|
||||||
publicKey: bot.sender.publicKey
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (encryptionKey && (bot.keyEncoding as SecretKeyEncoding) === SecretKeyEncoding.UTF8) {
|
|
||||||
const privateKeyBot = decryptSymmetric128BitHexKeyUTF8({
|
|
||||||
iv: bot.iv,
|
|
||||||
tag: bot.tag,
|
|
||||||
ciphertext: bot.encryptedPrivateKey,
|
|
||||||
key: encryptionKey
|
|
||||||
});
|
|
||||||
return decryptAsymmetric({
|
|
||||||
ciphertext: bot.encryptedProjectKey,
|
|
||||||
privateKey: privateKeyBot,
|
|
||||||
nonce: bot.encryptedProjectKeyNonce,
|
|
||||||
publicKey: bot.sender.publicKey
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new BadRequestError({
|
return decryptAsymmetric({
|
||||||
message: "Failed to obtain bot copy of workspace key needed for operation"
|
ciphertext: bot.encryptedProjectKey,
|
||||||
|
privateKey: botPrivateKey,
|
||||||
|
nonce: bot.encryptedProjectKeyNonce,
|
||||||
|
publicKey: bot.sender.publicKey
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const findBotByProjectId = async ({ actorId, actor, actorOrgId, projectId }: TProjectPermission) => {
|
const findBotByProjectId = async ({
|
||||||
|
actorId,
|
||||||
|
actor,
|
||||||
|
projectId,
|
||||||
|
actorOrgId,
|
||||||
|
privateKey,
|
||||||
|
botKey,
|
||||||
|
publicKey
|
||||||
|
}: TFindBotByProjectIdDTO) => {
|
||||||
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
|
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
|
||||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
|
||||||
const appCfg = getConfig();
|
|
||||||
|
|
||||||
const bot = await projectBotDAL.transaction(async (tx) => {
|
const bot = await projectBotDAL.transaction(async (tx) => {
|
||||||
const doc = await projectBotDAL.findOne({ projectId }, tx);
|
const doc = await projectBotDAL.findOne({ projectId }, tx);
|
||||||
if (doc) return doc;
|
if (doc) return doc;
|
||||||
|
|
||||||
const { publicKey, privateKey } = generateAsymmetricKeyPair();
|
const keys = privateKey && publicKey ? { privateKey, publicKey } : generateAsymmetricKeyPair();
|
||||||
if (appCfg.ROOT_ENCRYPTION_KEY) {
|
|
||||||
const { iv, tag, ciphertext } = encryptSymmetric(privateKey, appCfg.ROOT_ENCRYPTION_KEY);
|
const { iv, tag, ciphertext, encoding, algorithm } = infisicalSymmetricEncypt(keys.privateKey);
|
||||||
|
|
||||||
|
const project = await projectDAL.findById(projectId, tx);
|
||||||
|
|
||||||
|
if (project.version === ProjectVersion.V2) {
|
||||||
|
throw new BadRequestError({ message: "Failed to create bot, project is upgraded." });
|
||||||
|
}
|
||||||
|
|
||||||
return projectBotDAL.create(
|
return projectBotDAL.create(
|
||||||
{
|
{
|
||||||
name: "Infisical Bot",
|
name: "Infisical Bot",
|
||||||
@ -91,35 +83,29 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T
|
|||||||
iv,
|
iv,
|
||||||
encryptedPrivateKey: ciphertext,
|
encryptedPrivateKey: ciphertext,
|
||||||
isActive: false,
|
isActive: false,
|
||||||
publicKey,
|
publicKey: keys.publicKey,
|
||||||
algorithm: SecretEncryptionAlgo.AES_256_GCM,
|
algorithm,
|
||||||
keyEncoding: SecretKeyEncoding.BASE64
|
keyEncoding: encoding,
|
||||||
|
...(botKey && {
|
||||||
|
encryptedProjectKey: botKey.encryptedKey,
|
||||||
|
encryptedProjectKeyNonce: botKey.nonce
|
||||||
|
})
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
}
|
|
||||||
if (appCfg.ENCRYPTION_KEY) {
|
|
||||||
const { iv, tag, ciphertext } = encryptSymmetric128BitHexKeyUTF8(privateKey, appCfg.ENCRYPTION_KEY);
|
|
||||||
return projectBotDAL.create(
|
|
||||||
{
|
|
||||||
name: "Infisical Bot",
|
|
||||||
projectId,
|
|
||||||
tag,
|
|
||||||
iv,
|
|
||||||
encryptedPrivateKey: ciphertext,
|
|
||||||
isActive: false,
|
|
||||||
publicKey,
|
|
||||||
algorithm: SecretEncryptionAlgo.AES_256_GCM,
|
|
||||||
keyEncoding: SecretKeyEncoding.UTF8
|
|
||||||
},
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new BadRequestError({ message: "Failed to create bot due to missing encryption key" });
|
|
||||||
});
|
});
|
||||||
return bot;
|
return bot;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findProjectByBotId = async (botId: string) => {
|
||||||
|
try {
|
||||||
|
const bot = await projectBotDAL.findProjectByBotId(botId);
|
||||||
|
return bot;
|
||||||
|
} catch (e) {
|
||||||
|
throw new BadRequestError({ message: "Failed to find bot by ID" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const setBotActiveState = async ({ actor, botId, botKey, actorId, actorOrgId, isActive }: TSetActiveStateDTO) => {
|
const setBotActiveState = async ({ actor, botId, botKey, actorId, actorOrgId, isActive }: TSetActiveStateDTO) => {
|
||||||
const bot = await projectBotDAL.findById(botId);
|
const bot = await projectBotDAL.findById(botId);
|
||||||
if (!bot) throw new BadRequestError({ message: "Bot not found" });
|
if (!bot) throw new BadRequestError({ message: "Bot not found" });
|
||||||
@ -127,6 +113,16 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T
|
|||||||
const { permission } = await permissionService.getProjectPermission(actor, actorId, bot.projectId, actorOrgId);
|
const { permission } = await permissionService.getProjectPermission(actor, actorId, bot.projectId, actorOrgId);
|
||||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations);
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations);
|
||||||
|
|
||||||
|
const project = await projectBotDAL.findProjectByBotId(botId);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new BadRequestError({ message: "Failed to find project by bot ID" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.version === ProjectVersion.V2) {
|
||||||
|
throw new BadRequestError({ message: "Failed to set bot active for upgraded project. Bot is already active" });
|
||||||
|
}
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
if (!botKey?.nonce || !botKey?.encryptedKey) {
|
if (!botKey?.nonce || !botKey?.encryptedKey) {
|
||||||
throw new BadRequestError({ message: "Failed to set bot active - missing bot key" });
|
throw new BadRequestError({ message: "Failed to set bot active - missing bot key" });
|
||||||
@ -153,6 +149,8 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T
|
|||||||
return {
|
return {
|
||||||
findBotByProjectId,
|
findBotByProjectId,
|
||||||
setBotActiveState,
|
setBotActiveState,
|
||||||
|
getBotPrivateKey,
|
||||||
|
findProjectByBotId,
|
||||||
getBotKey
|
getBotKey
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { TProjectBots } from "@app/db/schemas";
|
||||||
import { TProjectPermission } from "@app/lib/types";
|
import { TProjectPermission } from "@app/lib/types";
|
||||||
|
|
||||||
export type TSetActiveStateDTO = {
|
export type TSetActiveStateDTO = {
|
||||||
@ -8,3 +9,16 @@ export type TSetActiveStateDTO = {
|
|||||||
};
|
};
|
||||||
botId: string;
|
botId: string;
|
||||||
} & Omit<TProjectPermission, "projectId">;
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TFindBotByProjectIdDTO = {
|
||||||
|
privateKey?: string;
|
||||||
|
publicKey?: string;
|
||||||
|
botKey?: {
|
||||||
|
nonce: string;
|
||||||
|
encryptedKey: string;
|
||||||
|
};
|
||||||
|
} & TProjectPermission;
|
||||||
|
|
||||||
|
export type TGetPrivateKeyDTO = {
|
||||||
|
bot: TProjectBots;
|
||||||
|
};
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
import { TDbClient } from "@app/db";
|
import { TDbClient } from "@app/db";
|
||||||
import { TableName, TProjectKeys } from "@app/db/schemas";
|
import { TableName, TProjectKeys } from "@app/db/schemas";
|
||||||
import { DatabaseError } from "@app/lib/errors";
|
import { DatabaseError } from "@app/lib/errors";
|
||||||
@ -10,10 +12,11 @@ export const projectKeyDALFactory = (db: TDbClient) => {
|
|||||||
|
|
||||||
const findLatestProjectKey = async (
|
const findLatestProjectKey = async (
|
||||||
userId: string,
|
userId: string,
|
||||||
projectId: string
|
projectId: string,
|
||||||
|
tx?: Knex
|
||||||
): Promise<(TProjectKeys & { sender: { publicKey: string } }) | undefined> => {
|
): Promise<(TProjectKeys & { sender: { publicKey: string } }) | undefined> => {
|
||||||
try {
|
try {
|
||||||
const projectKey = await db(TableName.ProjectKeys)
|
const projectKey = await (tx || db)(TableName.ProjectKeys)
|
||||||
.join(TableName.Users, `${TableName.ProjectKeys}.senderId`, `${TableName.Users}.id`)
|
.join(TableName.Users, `${TableName.ProjectKeys}.senderId`, `${TableName.Users}.id`)
|
||||||
.join(TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id`)
|
.join(TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id`)
|
||||||
.where({ projectId, receiverId: userId })
|
.where({ projectId, receiverId: userId })
|
||||||
@ -29,9 +32,9 @@ export const projectKeyDALFactory = (db: TDbClient) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const findAllProjectUserPubKeys = async (projectId: string) => {
|
const findAllProjectUserPubKeys = async (projectId: string, tx?: Knex) => {
|
||||||
try {
|
try {
|
||||||
const pubKeys = await db(TableName.ProjectMembership)
|
const pubKeys = await (tx || db)(TableName.ProjectMembership)
|
||||||
.where({ projectId })
|
.where({ projectId })
|
||||||
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
|
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
|
||||||
.join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`)
|
.join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`)
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { TDbClient } from "@app/db";
|
import { TDbClient } from "@app/db";
|
||||||
import { TableName, TUserEncryptionKeys } from "@app/db/schemas";
|
import { TableName, TUserEncryptionKeys } from "@app/db/schemas";
|
||||||
import { DatabaseError } from "@app/lib/errors";
|
import { DatabaseError } from "@app/lib/errors";
|
||||||
import { ormify } from "@app/lib/knex";
|
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||||
|
|
||||||
export type TProjectMembershipDALFactory = ReturnType<typeof projectMembershipDALFactory>;
|
export type TProjectMembershipDALFactory = ReturnType<typeof projectMembershipDALFactory>;
|
||||||
|
|
||||||
@ -24,20 +24,63 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
|
|||||||
db.ref("projectId").withSchema(TableName.ProjectMembership),
|
db.ref("projectId").withSchema(TableName.ProjectMembership),
|
||||||
db.ref("role").withSchema(TableName.ProjectMembership),
|
db.ref("role").withSchema(TableName.ProjectMembership),
|
||||||
db.ref("roleId").withSchema(TableName.ProjectMembership),
|
db.ref("roleId").withSchema(TableName.ProjectMembership),
|
||||||
|
db.ref("isGhost").withSchema(TableName.Users),
|
||||||
db.ref("email").withSchema(TableName.Users),
|
db.ref("email").withSchema(TableName.Users),
|
||||||
db.ref("publicKey").withSchema(TableName.UserEncryptionKey),
|
db.ref("publicKey").withSchema(TableName.UserEncryptionKey),
|
||||||
db.ref("firstName").withSchema(TableName.Users),
|
db.ref("firstName").withSchema(TableName.Users),
|
||||||
db.ref("lastName").withSchema(TableName.Users),
|
db.ref("lastName").withSchema(TableName.Users),
|
||||||
db.ref("id").withSchema(TableName.Users).as("userId")
|
db.ref("id").withSchema(TableName.Users).as("userId")
|
||||||
);
|
)
|
||||||
return members.map(({ email, firstName, lastName, publicKey, ...data }) => ({
|
.where({ isGhost: false });
|
||||||
|
return members.map(({ email, firstName, lastName, publicKey, isGhost, ...data }) => ({
|
||||||
...data,
|
...data,
|
||||||
user: { email, firstName, lastName, id: data.userId, publicKey }
|
user: { email, firstName, lastName, id: data.userId, publicKey, isGhost }
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new DatabaseError({ error, name: "Find all project members" });
|
throw new DatabaseError({ error, name: "Find all project members" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { ...projectMemberOrm, findAllProjectMembers };
|
const findProjectGhostUser = async (projectId: string) => {
|
||||||
|
try {
|
||||||
|
const ghostUser = await db(TableName.ProjectMembership)
|
||||||
|
.where({ projectId })
|
||||||
|
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
|
||||||
|
.select(selectAllTableCols(TableName.Users))
|
||||||
|
.where({ isGhost: true })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return ghostUser;
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find project top-level user" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const findMembershipsByEmail = async (projectId: string, emails: string[]) => {
|
||||||
|
try {
|
||||||
|
const members = await db(TableName.ProjectMembership)
|
||||||
|
.where({ projectId })
|
||||||
|
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
|
||||||
|
.join<TUserEncryptionKeys>(
|
||||||
|
TableName.UserEncryptionKey,
|
||||||
|
`${TableName.UserEncryptionKey}.userId`,
|
||||||
|
`${TableName.Users}.id`
|
||||||
|
)
|
||||||
|
.select(
|
||||||
|
selectAllTableCols(TableName.ProjectMembership),
|
||||||
|
db.ref("id").withSchema(TableName.Users).as("userId"),
|
||||||
|
db.ref("email").withSchema(TableName.Users)
|
||||||
|
)
|
||||||
|
.whereIn("email", emails)
|
||||||
|
.where({ isGhost: false });
|
||||||
|
return members.map(({ userId, email, ...data }) => ({
|
||||||
|
...data,
|
||||||
|
user: { id: userId, email }
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find members by email" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...projectMemberOrm, findAllProjectMembers, findProjectGhostUser, findMembershipsByEmail };
|
||||||
};
|
};
|
||||||
|
@ -1,15 +1,28 @@
|
|||||||
|
/* eslint-disable no-await-in-loop */
|
||||||
import { ForbiddenError } from "@casl/ability";
|
import { ForbiddenError } from "@casl/ability";
|
||||||
|
|
||||||
import { OrgMembershipStatus, ProjectMembershipRole, TableName } from "@app/db/schemas";
|
import {
|
||||||
|
OrgMembershipStatus,
|
||||||
|
ProjectMembershipRole,
|
||||||
|
ProjectVersion,
|
||||||
|
SecretKeyEncoding,
|
||||||
|
TableName,
|
||||||
|
TProjectMemberships,
|
||||||
|
TUsers
|
||||||
|
} from "@app/db/schemas";
|
||||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||||
import { getConfig } from "@app/lib/config/env";
|
import { getConfig } from "@app/lib/config/env";
|
||||||
|
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
|
||||||
import { BadRequestError } from "@app/lib/errors";
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
import { groupBy } from "@app/lib/fn";
|
import { groupBy } from "@app/lib/fn";
|
||||||
|
|
||||||
|
import { ActorType } from "../auth/auth-type";
|
||||||
import { TOrgDALFactory } from "../org/org-dal";
|
import { TOrgDALFactory } from "../org/org-dal";
|
||||||
import { TProjectDALFactory } from "../project/project-dal";
|
import { TProjectDALFactory } from "../project/project-dal";
|
||||||
|
import { assignWorkspaceKeysToMembers } from "../project/project-fns";
|
||||||
|
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
|
||||||
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
|
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
|
||||||
import { TProjectRoleDALFactory } from "../project-role/project-role-dal";
|
import { TProjectRoleDALFactory } from "../project-role/project-role-dal";
|
||||||
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||||
@ -17,7 +30,9 @@ import { TUserDALFactory } from "../user/user-dal";
|
|||||||
import { TProjectMembershipDALFactory } from "./project-membership-dal";
|
import { TProjectMembershipDALFactory } from "./project-membership-dal";
|
||||||
import {
|
import {
|
||||||
TAddUsersToWorkspaceDTO,
|
TAddUsersToWorkspaceDTO,
|
||||||
TDeleteProjectMembershipDTO,
|
TAddUsersToWorkspaceNonE2EEDTO,
|
||||||
|
TDeleteProjectMembershipOldDTO,
|
||||||
|
TDeleteProjectMembershipsDTO,
|
||||||
TGetProjectMembershipDTO,
|
TGetProjectMembershipDTO,
|
||||||
TInviteUserToProjectDTO,
|
TInviteUserToProjectDTO,
|
||||||
TUpdateProjectMembershipDTO
|
TUpdateProjectMembershipDTO
|
||||||
@ -26,11 +41,12 @@ import {
|
|||||||
type TProjectMembershipServiceFactoryDep = {
|
type TProjectMembershipServiceFactoryDep = {
|
||||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||||
smtpService: TSmtpService;
|
smtpService: TSmtpService;
|
||||||
|
projectBotDAL: TProjectBotDALFactory;
|
||||||
projectMembershipDAL: TProjectMembershipDALFactory;
|
projectMembershipDAL: TProjectMembershipDALFactory;
|
||||||
userDAL: Pick<TUserDALFactory, "findById" | "findOne">;
|
userDAL: Pick<TUserDALFactory, "findById" | "findOne" | "findUserByProjectMembershipId" | "find">;
|
||||||
projectRoleDAL: Pick<TProjectRoleDALFactory, "findOne">;
|
projectRoleDAL: Pick<TProjectRoleDALFactory, "findOne">;
|
||||||
orgDAL: Pick<TOrgDALFactory, "findMembership">;
|
orgDAL: Pick<TOrgDALFactory, "findMembership" | "findOrgMembersByEmail">;
|
||||||
projectDAL: Pick<TProjectDALFactory, "findById">;
|
projectDAL: Pick<TProjectDALFactory, "findById" | "findProjectGhostUser" | "transaction">;
|
||||||
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany">;
|
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany">;
|
||||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||||
};
|
};
|
||||||
@ -42,6 +58,7 @@ export const projectMembershipServiceFactory = ({
|
|||||||
projectMembershipDAL,
|
projectMembershipDAL,
|
||||||
smtpService,
|
smtpService,
|
||||||
projectRoleDAL,
|
projectRoleDAL,
|
||||||
|
projectBotDAL,
|
||||||
orgDAL,
|
orgDAL,
|
||||||
userDAL,
|
userDAL,
|
||||||
projectDAL,
|
projectDAL,
|
||||||
@ -55,64 +72,90 @@ export const projectMembershipServiceFactory = ({
|
|||||||
return projectMembershipDAL.findAllProjectMembers(projectId);
|
return projectMembershipDAL.findAllProjectMembers(projectId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const inviteUserToProject = async ({ actorId, actor, actorOrgId, projectId, email }: TInviteUserToProjectDTO) => {
|
const inviteUserToProject = async ({ actorId, actor, actorOrgId, projectId, emails }: TInviteUserToProjectDTO) => {
|
||||||
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
|
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
|
||||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member);
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member);
|
||||||
|
|
||||||
const invitee = await userDAL.findOne({ email });
|
const invitees: TUsers[] = [];
|
||||||
if (!invitee || !invitee.isAccepted)
|
|
||||||
|
const project = await projectDAL.findById(projectId);
|
||||||
|
const users = await userDAL.find({
|
||||||
|
$in: { email: emails }
|
||||||
|
});
|
||||||
|
|
||||||
|
await projectDAL.transaction(async (tx) => {
|
||||||
|
for (const invitee of users) {
|
||||||
|
if (!invitee.isAccepted)
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
message: "Faield to validate invitee",
|
message: "Failed to validate invitee",
|
||||||
name: "Invite user to project"
|
name: "Invite user to project"
|
||||||
});
|
});
|
||||||
|
|
||||||
const inviteeMembership = await projectMembershipDAL.findOne({
|
const inviteeMembership = await projectMembershipDAL.findOne(
|
||||||
|
{
|
||||||
userId: invitee.id,
|
userId: invitee.id,
|
||||||
projectId
|
projectId
|
||||||
});
|
},
|
||||||
if (inviteeMembership)
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
if (inviteeMembership) {
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
message: "Existing member of project",
|
message: "Existing member of project",
|
||||||
name: "Invite user to project"
|
name: "Invite user to project"
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const project = await projectDAL.findById(projectId);
|
|
||||||
const inviteeMembershipOrg = await orgDAL.findMembership({
|
const inviteeMembershipOrg = await orgDAL.findMembership({
|
||||||
userId: invitee.id,
|
userId: invitee.id,
|
||||||
orgId: project.orgId,
|
orgId: project.orgId,
|
||||||
status: OrgMembershipStatus.Accepted
|
status: OrgMembershipStatus.Accepted
|
||||||
});
|
});
|
||||||
if (!inviteeMembershipOrg)
|
|
||||||
|
if (!inviteeMembershipOrg) {
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
message: "Failed to validate invitee org membership",
|
message: "Failed to validate invitee org membership",
|
||||||
name: "Invite user to project"
|
name: "Invite user to project"
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const latestKey = await projectKeyDAL.findLatestProjectKey(actorId, projectId);
|
await projectMembershipDAL.create(
|
||||||
await projectMembershipDAL.create({
|
{
|
||||||
userId: invitee.id,
|
userId: invitee.id,
|
||||||
projectId,
|
projectId,
|
||||||
role: ProjectMembershipRole.Member
|
role: ProjectMembershipRole.Member
|
||||||
});
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
invitees.push(invitee);
|
||||||
|
}
|
||||||
|
|
||||||
const sender = await userDAL.findById(actorId);
|
|
||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
await smtpService.sendMail({
|
await smtpService.sendMail({
|
||||||
template: SmtpTemplates.WorkspaceInvite,
|
template: SmtpTemplates.WorkspaceInvite,
|
||||||
subjectLine: "Infisical workspace invitation",
|
subjectLine: "Infisical workspace invitation",
|
||||||
recipients: [invitee.email],
|
recipients: invitees.map((i) => i.email),
|
||||||
substitutions: {
|
substitutions: {
|
||||||
inviterFirstName: sender.firstName,
|
|
||||||
inviterEmail: sender.email,
|
|
||||||
workspaceName: project.name,
|
workspaceName: project.name,
|
||||||
callback_url: `${appCfg.SITE_URL}/login`
|
callback_url: `${appCfg.SITE_URL}/login`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return { invitee, latestKey };
|
const latestKey = await projectKeyDAL.findLatestProjectKey(actorId, projectId);
|
||||||
|
|
||||||
|
return { invitees, latestKey };
|
||||||
};
|
};
|
||||||
|
|
||||||
const addUsersToProject = async ({ projectId, actorId, actor, actorOrgId, members }: TAddUsersToWorkspaceDTO) => {
|
const addUsersToProject = async ({
|
||||||
|
projectId,
|
||||||
|
actorId,
|
||||||
|
actor,
|
||||||
|
actorOrgId,
|
||||||
|
members,
|
||||||
|
sendEmails = true
|
||||||
|
}: TAddUsersToWorkspaceDTO) => {
|
||||||
const project = await projectDAL.findById(projectId);
|
const project = await projectDAL.findById(projectId);
|
||||||
if (!project) throw new BadRequestError({ message: "Project not found" });
|
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||||
|
|
||||||
@ -134,11 +177,16 @@ export const projectMembershipServiceFactory = ({
|
|||||||
|
|
||||||
await projectMembershipDAL.transaction(async (tx) => {
|
await projectMembershipDAL.transaction(async (tx) => {
|
||||||
await projectMembershipDAL.insertMany(
|
await projectMembershipDAL.insertMany(
|
||||||
orgMembers.map(({ userId }) => ({
|
orgMembers.map(({ userId, id: membershipId }) => {
|
||||||
|
const role =
|
||||||
|
members.find((i) => i.orgMembershipId === membershipId)?.projectRole || ProjectMembershipRole.Member;
|
||||||
|
|
||||||
|
return {
|
||||||
projectId,
|
projectId,
|
||||||
userId: userId as string,
|
userId: userId as string,
|
||||||
role: ProjectMembershipRole.Member
|
role
|
||||||
})),
|
};
|
||||||
|
}),
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId);
|
const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId);
|
||||||
@ -153,22 +201,132 @@ export const projectMembershipServiceFactory = ({
|
|||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const sender = await userDAL.findById(actorId);
|
|
||||||
|
if (sendEmails) {
|
||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
await smtpService.sendMail({
|
await smtpService.sendMail({
|
||||||
template: SmtpTemplates.WorkspaceInvite,
|
template: SmtpTemplates.WorkspaceInvite,
|
||||||
subjectLine: "Infisical workspace invitation",
|
subjectLine: "Infisical workspace invitation",
|
||||||
recipients: orgMembers.map(({ email }) => email).filter(Boolean),
|
recipients: orgMembers.map(({ email }) => email).filter(Boolean),
|
||||||
substitutions: {
|
substitutions: {
|
||||||
inviterFirstName: sender.firstName,
|
|
||||||
inviterEmail: sender.email,
|
|
||||||
workspaceName: project.name,
|
workspaceName: project.name,
|
||||||
callback_url: `${appCfg.SITE_URL}/login`
|
callback_url: `${appCfg.SITE_URL}/login`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
return orgMembers;
|
return orgMembers;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addUsersToProjectNonE2EE = async ({
|
||||||
|
projectId,
|
||||||
|
actorId,
|
||||||
|
actor,
|
||||||
|
emails,
|
||||||
|
sendEmails = true
|
||||||
|
}: TAddUsersToWorkspaceNonE2EEDTO) => {
|
||||||
|
const project = await projectDAL.findById(projectId);
|
||||||
|
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||||
|
|
||||||
|
if (project.version === ProjectVersion.V1) {
|
||||||
|
throw new BadRequestError({ message: "Please upgrade your project on your dashboard" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member);
|
||||||
|
|
||||||
|
const orgMembers = await orgDAL.findOrgMembersByEmail(project.orgId, emails);
|
||||||
|
|
||||||
|
if (orgMembers.length !== emails.length) throw new BadRequestError({ message: "Some users are not part of org" });
|
||||||
|
|
||||||
|
const existingMembers = await projectMembershipDAL.find({
|
||||||
|
projectId,
|
||||||
|
$in: { userId: orgMembers.map(({ user }) => user.id).filter(Boolean) }
|
||||||
|
});
|
||||||
|
if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" });
|
||||||
|
|
||||||
|
const ghostUser = await projectDAL.findProjectGhostUser(projectId);
|
||||||
|
|
||||||
|
if (!ghostUser) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Failed to find sudo user"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId);
|
||||||
|
|
||||||
|
if (!ghostUserLatestKey) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Failed to find sudo user latest key"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bot = await projectBotDAL.findOne({ projectId });
|
||||||
|
|
||||||
|
if (!bot) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Failed to find bot"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const botPrivateKey = infisicalSymmetricDecrypt({
|
||||||
|
keyEncoding: bot.keyEncoding as SecretKeyEncoding,
|
||||||
|
iv: bot.iv,
|
||||||
|
tag: bot.tag,
|
||||||
|
ciphertext: bot.encryptedPrivateKey
|
||||||
|
});
|
||||||
|
|
||||||
|
const newWsMembers = assignWorkspaceKeysToMembers({
|
||||||
|
decryptKey: ghostUserLatestKey,
|
||||||
|
userPrivateKey: botPrivateKey,
|
||||||
|
members: orgMembers.map((membership) => ({
|
||||||
|
orgMembershipId: membership.id,
|
||||||
|
projectMembershipRole: ProjectMembershipRole.Member,
|
||||||
|
userPublicKey: membership.user.publicKey
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
const members: TProjectMemberships[] = [];
|
||||||
|
|
||||||
|
await projectMembershipDAL.transaction(async (tx) => {
|
||||||
|
const result = await projectMembershipDAL.insertMany(
|
||||||
|
orgMembers.map(({ user }) => ({
|
||||||
|
projectId,
|
||||||
|
userId: user.id,
|
||||||
|
role: ProjectMembershipRole.Member
|
||||||
|
})),
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
members.push(...result);
|
||||||
|
|
||||||
|
const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId);
|
||||||
|
await projectKeyDAL.insertMany(
|
||||||
|
orgMembers.map(({ user, id }) => ({
|
||||||
|
encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey,
|
||||||
|
nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce,
|
||||||
|
senderId: ghostUser.id,
|
||||||
|
receiverId: user.id,
|
||||||
|
projectId
|
||||||
|
})),
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sendEmails) {
|
||||||
|
const appCfg = getConfig();
|
||||||
|
await smtpService.sendMail({
|
||||||
|
template: SmtpTemplates.WorkspaceInvite,
|
||||||
|
subjectLine: "Infisical workspace invitation",
|
||||||
|
recipients: orgMembers.map(({ user }) => user.email).filter(Boolean),
|
||||||
|
substitutions: {
|
||||||
|
workspaceName: project.name,
|
||||||
|
callback_url: `${appCfg.SITE_URL}/login`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return members;
|
||||||
|
};
|
||||||
|
|
||||||
const updateProjectMembership = async ({
|
const updateProjectMembership = async ({
|
||||||
actorId,
|
actorId,
|
||||||
actor,
|
actor,
|
||||||
@ -180,6 +338,15 @@ export const projectMembershipServiceFactory = ({
|
|||||||
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
|
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
|
||||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member);
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member);
|
||||||
|
|
||||||
|
const membershipUser = await userDAL.findUserByProjectMembershipId(membershipId);
|
||||||
|
|
||||||
|
if (membershipUser?.isGhost) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Unauthorized member update",
|
||||||
|
name: "Update project membership"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const isCustomRole = !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole);
|
const isCustomRole = !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole);
|
||||||
if (isCustomRole) {
|
if (isCustomRole) {
|
||||||
const customRole = await projectRoleDAL.findOne({ slug: role, projectId });
|
const customRole = await projectRoleDAL.findOne({ slug: role, projectId });
|
||||||
@ -205,16 +372,26 @@ export const projectMembershipServiceFactory = ({
|
|||||||
return membership;
|
return membership;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// This is old and should be removed later. Its not used anywhere, but it is exposed in our API. So to avoid breaking changes, we are keeping it for now.
|
||||||
const deleteProjectMembership = async ({
|
const deleteProjectMembership = async ({
|
||||||
actorId,
|
actorId,
|
||||||
actor,
|
actor,
|
||||||
actorOrgId,
|
actorOrgId,
|
||||||
projectId,
|
projectId,
|
||||||
membershipId
|
membershipId
|
||||||
}: TDeleteProjectMembershipDTO) => {
|
}: TDeleteProjectMembershipOldDTO) => {
|
||||||
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
|
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
|
||||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Member);
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Member);
|
||||||
|
|
||||||
|
const member = await userDAL.findUserByProjectMembershipId(membershipId);
|
||||||
|
|
||||||
|
if (member?.isGhost) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Unauthorized member delete",
|
||||||
|
name: "Delete project membership"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const membership = await projectMembershipDAL.transaction(async (tx) => {
|
const membership = await projectMembershipDAL.transaction(async (tx) => {
|
||||||
const [deletedMembership] = await projectMembershipDAL.delete({ projectId, id: membershipId }, tx);
|
const [deletedMembership] = await projectMembershipDAL.delete({ projectId, id: membershipId }, tx);
|
||||||
await projectKeyDAL.delete({ receiverId: deletedMembership.userId, projectId }, tx);
|
await projectKeyDAL.delete({ receiverId: deletedMembership.userId, projectId }, tx);
|
||||||
@ -223,11 +400,78 @@ export const projectMembershipServiceFactory = ({
|
|||||||
return membership;
|
return membership;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteProjectMemberships = async ({
|
||||||
|
actorId,
|
||||||
|
actor,
|
||||||
|
actorOrgId,
|
||||||
|
projectId,
|
||||||
|
emails
|
||||||
|
}: TDeleteProjectMembershipsDTO) => {
|
||||||
|
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Member);
|
||||||
|
|
||||||
|
const project = await projectDAL.findById(projectId);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Project not found",
|
||||||
|
name: "Delete project membership"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.version === ProjectVersion.V1) {
|
||||||
|
throw new BadRequestError({ message: "Please upgrade your project on your dashboard" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectMembers = await projectMembershipDAL.findMembershipsByEmail(projectId, emails);
|
||||||
|
|
||||||
|
if (projectMembers.length !== emails.length) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Some users are not part of project",
|
||||||
|
name: "Delete project membership"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actor === ActorType.USER && projectMembers.some(({ user }) => user.id === actorId)) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Cannot remove yourself from project",
|
||||||
|
name: "Delete project membership"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberships = await projectMembershipDAL.transaction(async (tx) => {
|
||||||
|
const deletedMemberships = await projectMembershipDAL.delete(
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
$in: {
|
||||||
|
id: projectMembers.map(({ id }) => id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
await projectKeyDAL.delete(
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
$in: {
|
||||||
|
receiverId: projectMembers.map(({ user }) => user.id).filter(Boolean)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
return deletedMemberships;
|
||||||
|
});
|
||||||
|
return memberships;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getProjectMemberships,
|
getProjectMemberships,
|
||||||
inviteUserToProject,
|
inviteUserToProject,
|
||||||
updateProjectMembership,
|
updateProjectMembership,
|
||||||
deleteProjectMembership,
|
addUsersToProjectNonE2EE,
|
||||||
|
deleteProjectMemberships,
|
||||||
|
deleteProjectMembership, // TODO: Remove this
|
||||||
addUsersToProject
|
addUsersToProject
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
|
import { ProjectMembershipRole } from "@app/db/schemas";
|
||||||
import { TProjectPermission } from "@app/lib/types";
|
import { TProjectPermission } from "@app/lib/types";
|
||||||
|
|
||||||
export type TGetProjectMembershipDTO = TProjectPermission;
|
export type TGetProjectMembershipDTO = TProjectPermission;
|
||||||
|
|
||||||
export type TInviteUserToProjectDTO = {
|
export type TInviteUserToProjectDTO = {
|
||||||
email: string;
|
emails: string[];
|
||||||
} & TProjectPermission;
|
} & TProjectPermission;
|
||||||
|
|
||||||
export type TUpdateProjectMembershipDTO = {
|
export type TUpdateProjectMembershipDTO = {
|
||||||
@ -11,14 +12,25 @@ export type TUpdateProjectMembershipDTO = {
|
|||||||
role: string;
|
role: string;
|
||||||
} & TProjectPermission;
|
} & TProjectPermission;
|
||||||
|
|
||||||
export type TDeleteProjectMembershipDTO = {
|
export type TDeleteProjectMembershipOldDTO = {
|
||||||
membershipId: string;
|
membershipId: string;
|
||||||
} & TProjectPermission;
|
} & TProjectPermission;
|
||||||
|
|
||||||
|
export type TDeleteProjectMembershipsDTO = {
|
||||||
|
emails: string[];
|
||||||
|
} & TProjectPermission;
|
||||||
|
|
||||||
export type TAddUsersToWorkspaceDTO = {
|
export type TAddUsersToWorkspaceDTO = {
|
||||||
|
sendEmails?: boolean;
|
||||||
members: {
|
members: {
|
||||||
orgMembershipId: string;
|
orgMembershipId: string;
|
||||||
workspaceEncryptedKey: string;
|
workspaceEncryptedKey: string;
|
||||||
workspaceEncryptedNonce: string;
|
workspaceEncryptedNonce: string;
|
||||||
|
projectRole: ProjectMembershipRole;
|
||||||
}[];
|
}[];
|
||||||
} & TProjectPermission;
|
} & TProjectPermission;
|
||||||
|
|
||||||
|
export type TAddUsersToWorkspaceNonE2EEDTO = {
|
||||||
|
sendEmails?: boolean;
|
||||||
|
emails: string[];
|
||||||
|
} & TProjectPermission;
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
import { TDbClient } from "@app/db";
|
import { TDbClient } from "@app/db";
|
||||||
import { ProjectsSchema, TableName } from "@app/db/schemas";
|
import { ProjectsSchema, ProjectUpgradeStatus, ProjectVersion, TableName, TProjectsUpdate } from "@app/db/schemas";
|
||||||
import { DatabaseError } from "@app/lib/errors";
|
import { BadRequestError, DatabaseError } from "@app/lib/errors";
|
||||||
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
|
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
|
||||||
|
|
||||||
export type TProjectDALFactory = ReturnType<typeof projectDALFactory>;
|
export type TProjectDALFactory = ReturnType<typeof projectDALFactory>;
|
||||||
@ -52,6 +54,32 @@ export const projectDALFactory = (db: TDbClient) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findProjectGhostUser = async (projectId: string) => {
|
||||||
|
try {
|
||||||
|
const ghostUser = await db(TableName.ProjectMembership)
|
||||||
|
.where({ projectId })
|
||||||
|
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
|
||||||
|
.select(selectAllTableCols(TableName.Users))
|
||||||
|
.where({ isGhost: true })
|
||||||
|
.first();
|
||||||
|
return ghostUser;
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find project top-level user" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setProjectUpgradeStatus = async (projectId: string, status: ProjectUpgradeStatus | null, tx?: Knex) => {
|
||||||
|
try {
|
||||||
|
const data: TProjectsUpdate = {
|
||||||
|
upgradeStatus: status
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
await (tx || db)(TableName.Project).where({ id: projectId }).update(data);
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Set project upgrade status" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const findAllProjectsByIdentity = async (identityId: string) => {
|
const findAllProjectsByIdentity = async (identityId: string) => {
|
||||||
try {
|
try {
|
||||||
const workspaces = await db(TableName.IdentityProjectMembership)
|
const workspaces = await db(TableName.IdentityProjectMembership)
|
||||||
@ -132,10 +160,25 @@ export const projectDALFactory = (db: TDbClient) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkProjectUpgradeStatus = async (projectId: string) => {
|
||||||
|
const project = await projectOrm.findById(projectId);
|
||||||
|
const upgradeInProgress =
|
||||||
|
project.upgradeStatus === ProjectUpgradeStatus.InProgress && project.version === ProjectVersion.V1;
|
||||||
|
|
||||||
|
if (upgradeInProgress) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Project is currently being upgraded, and secrets cannot be written. Please try again"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...projectOrm,
|
...projectOrm,
|
||||||
findAllProjects,
|
findAllProjects,
|
||||||
|
setProjectUpgradeStatus,
|
||||||
findAllProjectsByIdentity,
|
findAllProjectsByIdentity,
|
||||||
findProjectById
|
findProjectGhostUser,
|
||||||
|
findProjectById,
|
||||||
|
checkProjectUpgradeStatus
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
50
backend/src/services/project/project-fns.ts
Normal file
50
backend/src/services/project/project-fns.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto";
|
||||||
|
|
||||||
|
import { AddUserToWsDTO } from "./project-types";
|
||||||
|
|
||||||
|
export const assignWorkspaceKeysToMembers = ({ members, decryptKey, userPrivateKey }: AddUserToWsDTO) => {
|
||||||
|
const plaintextProjectKey = decryptAsymmetric({
|
||||||
|
ciphertext: decryptKey.encryptedKey,
|
||||||
|
nonce: decryptKey.nonce,
|
||||||
|
publicKey: decryptKey.sender.publicKey,
|
||||||
|
privateKey: userPrivateKey
|
||||||
|
});
|
||||||
|
|
||||||
|
const newWsMembers = members.map(({ orgMembershipId, userPublicKey, projectMembershipRole }) => {
|
||||||
|
const { ciphertext: inviteeCipherText, nonce: inviteeNonce } = encryptAsymmetric(
|
||||||
|
plaintextProjectKey,
|
||||||
|
userPublicKey,
|
||||||
|
userPrivateKey
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
orgMembershipId,
|
||||||
|
projectRole: projectMembershipRole,
|
||||||
|
workspaceEncryptedKey: inviteeCipherText,
|
||||||
|
workspaceEncryptedNonce: inviteeNonce
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return newWsMembers;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TCreateProjectKeyDTO = {
|
||||||
|
publicKey: string;
|
||||||
|
privateKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createProjectKey = ({ publicKey, privateKey }: TCreateProjectKeyDTO) => {
|
||||||
|
// 3. Create a random key that we'll use as the project key.
|
||||||
|
const randomBytes = crypto.randomBytes(16).toString("hex");
|
||||||
|
|
||||||
|
// 4. Encrypt the project key with the users key pair.
|
||||||
|
const { ciphertext: encryptedProjectKey, nonce: encryptedProjectKeyIv } = encryptAsymmetric(
|
||||||
|
randomBytes,
|
||||||
|
publicKey,
|
||||||
|
privateKey
|
||||||
|
);
|
||||||
|
|
||||||
|
return { key: encryptedProjectKey, iv: encryptedProjectKeyIv };
|
||||||
|
};
|
541
backend/src/services/project/project-queue.ts
Normal file
541
backend/src/services/project/project-queue.ts
Normal file
@ -0,0 +1,541 @@
|
|||||||
|
/* eslint-disable no-await-in-loop */
|
||||||
|
import {
|
||||||
|
IntegrationAuthsSchema,
|
||||||
|
ProjectMembershipRole,
|
||||||
|
ProjectUpgradeStatus,
|
||||||
|
ProjectVersion,
|
||||||
|
SecretApprovalRequestsSecretsSchema,
|
||||||
|
SecretKeyEncoding,
|
||||||
|
SecretsSchema,
|
||||||
|
SecretVersionsSchema,
|
||||||
|
TIntegrationAuths,
|
||||||
|
TSecretApprovalRequestsSecrets,
|
||||||
|
TSecrets,
|
||||||
|
TSecretVersions
|
||||||
|
} from "@app/db/schemas";
|
||||||
|
import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal";
|
||||||
|
import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal";
|
||||||
|
import { RequestState } from "@app/ee/services/secret-approval-request/secret-approval-request-types";
|
||||||
|
import {
|
||||||
|
decryptIntegrationAuths,
|
||||||
|
decryptSecretApprovals,
|
||||||
|
decryptSecrets,
|
||||||
|
decryptSecretVersions
|
||||||
|
} from "@app/lib/crypto";
|
||||||
|
import {
|
||||||
|
decryptAsymmetric,
|
||||||
|
encryptSymmetric128BitHexKeyUTF8,
|
||||||
|
infisicalSymmetricDecrypt,
|
||||||
|
infisicalSymmetricEncypt
|
||||||
|
} from "@app/lib/crypto/encryption";
|
||||||
|
import { logger } from "@app/lib/logger";
|
||||||
|
import { QueueJobs, QueueName, TQueueJobTypes, TQueueServiceFactory } from "@app/queue";
|
||||||
|
|
||||||
|
import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth-dal";
|
||||||
|
import { TOrgDALFactory } from "../org/org-dal";
|
||||||
|
import { TOrgServiceFactory } from "../org/org-service";
|
||||||
|
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
|
||||||
|
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||||
|
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
|
||||||
|
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
||||||
|
import { TSecretDALFactory } from "../secret/secret-dal";
|
||||||
|
import { TSecretVersionDALFactory } from "../secret/secret-version-dal";
|
||||||
|
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||||
|
import { TUserDALFactory } from "../user/user-dal";
|
||||||
|
import { TProjectDALFactory } from "./project-dal";
|
||||||
|
import { assignWorkspaceKeysToMembers, createProjectKey } from "./project-fns";
|
||||||
|
|
||||||
|
export type TProjectQueueFactory = ReturnType<typeof projectQueueFactory>;
|
||||||
|
|
||||||
|
type TProjectQueueFactoryDep = {
|
||||||
|
queueService: TQueueServiceFactory;
|
||||||
|
secretVersionDAL: Pick<TSecretVersionDALFactory, "find" | "bulkUpdateNoVersionIncrement" | "delete">;
|
||||||
|
folderDAL: Pick<TSecretFolderDALFactory, "find">;
|
||||||
|
secretDAL: Pick<TSecretDALFactory, "find" | "bulkUpdateNoVersionIncrement">;
|
||||||
|
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "find" | "create" | "delete" | "insertMany">;
|
||||||
|
secretApprovalRequestDAL: Pick<TSecretApprovalRequestDALFactory, "find">;
|
||||||
|
secretApprovalSecretDAL: Pick<TSecretApprovalRequestSecretDALFactory, "find" | "bulkUpdateNoVersionIncrement">;
|
||||||
|
projectBotDAL: Pick<TProjectBotDALFactory, "findOne" | "delete" | "create">;
|
||||||
|
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
|
||||||
|
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create">;
|
||||||
|
integrationAuthDAL: TIntegrationAuthDALFactory;
|
||||||
|
userDAL: Pick<TUserDALFactory, "findUserEncKeyByUserId">;
|
||||||
|
|
||||||
|
projectEnvDAL: Pick<TProjectEnvDALFactory, "find">;
|
||||||
|
projectDAL: Pick<TProjectDALFactory, "findOne" | "transaction" | "updateById" | "setProjectUpgradeStatus" | "find">;
|
||||||
|
orgDAL: Pick<TOrgDALFactory, "findMembership">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const projectQueueFactory = ({
|
||||||
|
queueService,
|
||||||
|
secretDAL,
|
||||||
|
folderDAL,
|
||||||
|
userDAL,
|
||||||
|
secretVersionDAL,
|
||||||
|
integrationAuthDAL,
|
||||||
|
secretApprovalRequestDAL,
|
||||||
|
secretApprovalSecretDAL,
|
||||||
|
projectKeyDAL,
|
||||||
|
projectBotDAL,
|
||||||
|
projectEnvDAL,
|
||||||
|
orgDAL,
|
||||||
|
projectDAL,
|
||||||
|
orgService,
|
||||||
|
projectMembershipDAL
|
||||||
|
}: TProjectQueueFactoryDep) => {
|
||||||
|
const upgradeProject = async (dto: TQueueJobTypes["upgrade-project-to-ghost"]["payload"]) => {
|
||||||
|
await queueService.queue(QueueName.UpgradeProjectToGhost, QueueJobs.UpgradeProjectToGhost, dto, {
|
||||||
|
attempts: 1,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: {
|
||||||
|
count: 5 // keep the most recent jobs
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
queueService.start(QueueName.UpgradeProjectToGhost, async ({ data }) => {
|
||||||
|
try {
|
||||||
|
const [project] = await projectDAL.find({
|
||||||
|
id: data.projectId,
|
||||||
|
version: ProjectVersion.V1
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldProjectKey = await projectKeyDAL.findLatestProjectKey(data.startedByUserId, data.projectId);
|
||||||
|
|
||||||
|
if (!project || !oldProjectKey) {
|
||||||
|
throw new Error("Project or project key not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.upgradeStatus !== ProjectUpgradeStatus.Failed && project.upgradeStatus !== null) {
|
||||||
|
throw new Error("Project upgrade status is not valid");
|
||||||
|
}
|
||||||
|
|
||||||
|
await projectDAL.setProjectUpgradeStatus(data.projectId, ProjectUpgradeStatus.InProgress); // Set the status to in progress. This is important to prevent multiple upgrades at the same time.
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-promise-executor-return
|
||||||
|
// await new Promise((resolve) => setTimeout(resolve, 50_000));
|
||||||
|
|
||||||
|
const userPrivateKey = infisicalSymmetricDecrypt({
|
||||||
|
keyEncoding: data.encryptedPrivateKey.keyEncoding,
|
||||||
|
ciphertext: data.encryptedPrivateKey.encryptedKey,
|
||||||
|
iv: data.encryptedPrivateKey.encryptedKeyIv,
|
||||||
|
tag: data.encryptedPrivateKey.encryptedKeyTag
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectEnvs = await projectEnvDAL.find({
|
||||||
|
projectId: project.id
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectFolders = await folderDAL.find({
|
||||||
|
$in: {
|
||||||
|
envId: projectEnvs.map((env) => env.id)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all the secrets within the project (as encrypted)
|
||||||
|
const projectIntegrationAuths = await integrationAuthDAL.find({
|
||||||
|
projectId: project.id
|
||||||
|
});
|
||||||
|
const secrets: TSecrets[] = [];
|
||||||
|
const secretVersions: TSecretVersions[] = [];
|
||||||
|
const approvalSecrets: TSecretApprovalRequestsSecrets[] = [];
|
||||||
|
const folderSecretVersionIdsToDelete: string[] = [];
|
||||||
|
|
||||||
|
for (const folder of projectFolders) {
|
||||||
|
const folderSecrets = await secretDAL.find({ folderId: folder.id });
|
||||||
|
|
||||||
|
const folderSecretVersions = await secretVersionDAL.find(
|
||||||
|
{
|
||||||
|
folderId: folder.id
|
||||||
|
},
|
||||||
|
// Only get the latest 700 secret versions for each folder.
|
||||||
|
{
|
||||||
|
limit: 1000,
|
||||||
|
sort: [["createdAt", "desc"]]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const deletedSecretVersions = await secretVersionDAL.find(
|
||||||
|
{
|
||||||
|
folderId: folder.id
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Get all the secret versions that are not the latest 700
|
||||||
|
offset: 1000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
folderSecretVersionIdsToDelete.push(...deletedSecretVersions.map((el) => el.id));
|
||||||
|
|
||||||
|
const approvalRequests = await secretApprovalRequestDAL.find({
|
||||||
|
status: RequestState.Open,
|
||||||
|
folderId: folder.id
|
||||||
|
});
|
||||||
|
const secretApprovals = await secretApprovalSecretDAL.find({
|
||||||
|
$in: {
|
||||||
|
requestId: approvalRequests.map((el) => el.id)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
secrets.push(...folderSecrets);
|
||||||
|
secretVersions.push(...folderSecretVersions);
|
||||||
|
approvalSecrets.push(...secretApprovals);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedSecrets = decryptSecrets(secrets, userPrivateKey, oldProjectKey);
|
||||||
|
const decryptedSecretVersions = decryptSecretVersions(secretVersions, userPrivateKey, oldProjectKey);
|
||||||
|
const decryptedApprovalSecrets = decryptSecretApprovals(approvalSecrets, userPrivateKey, oldProjectKey);
|
||||||
|
const decryptedIntegrationAuths = decryptIntegrationAuths(projectIntegrationAuths, userPrivateKey, oldProjectKey);
|
||||||
|
|
||||||
|
// Get the existing bot and the existing project keys for the members of the project
|
||||||
|
const existingBot = await projectBotDAL.findOne({ projectId: project.id }).catch(() => null);
|
||||||
|
const existingProjectKeys = await projectKeyDAL.find({ projectId: project.id });
|
||||||
|
|
||||||
|
// TRANSACTION START
|
||||||
|
await projectDAL.transaction(async (tx) => {
|
||||||
|
await projectDAL.updateById(project.id, { version: ProjectVersion.V2 }, tx);
|
||||||
|
|
||||||
|
// Create a ghost user
|
||||||
|
const ghostUser = await orgService.addGhostUser(project.orgId, tx);
|
||||||
|
|
||||||
|
// Create a project key
|
||||||
|
const { key: newEncryptedProjectKey, iv: newEncryptedProjectKeyIv } = createProjectKey({
|
||||||
|
publicKey: ghostUser.keys.publicKey,
|
||||||
|
privateKey: ghostUser.keys.plainPrivateKey
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new project key for the GHOST
|
||||||
|
await projectKeyDAL.create(
|
||||||
|
{
|
||||||
|
projectId: project.id,
|
||||||
|
receiverId: ghostUser.user.id,
|
||||||
|
encryptedKey: newEncryptedProjectKey,
|
||||||
|
nonce: newEncryptedProjectKeyIv,
|
||||||
|
senderId: ghostUser.user.id
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a membership for the ghost user
|
||||||
|
await projectMembershipDAL.create(
|
||||||
|
{
|
||||||
|
projectId: project.id,
|
||||||
|
userId: ghostUser.user.id,
|
||||||
|
role: ProjectMembershipRole.Admin
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
// If a bot already exists, delete it
|
||||||
|
if (existingBot) {
|
||||||
|
await projectBotDAL.delete({ id: existingBot.id }, tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all the existing project keys
|
||||||
|
await projectKeyDAL.delete(
|
||||||
|
{
|
||||||
|
projectId: project.id,
|
||||||
|
$in: {
|
||||||
|
id: existingProjectKeys.map((key) => key.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.user.id, project.id, tx);
|
||||||
|
|
||||||
|
if (!ghostUserLatestKey) {
|
||||||
|
throw new Error("User latest key not found (V2 Upgrade)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newProjectMembers: {
|
||||||
|
encryptedKey: string;
|
||||||
|
nonce: string;
|
||||||
|
senderId: string;
|
||||||
|
receiverId: string;
|
||||||
|
projectId: string;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (const key of existingProjectKeys) {
|
||||||
|
const user = await userDAL.findUserEncKeyByUserId(key.receiverId);
|
||||||
|
const [orgMembership] = await orgDAL.findMembership({ userId: key.receiverId, orgId: project.orgId });
|
||||||
|
|
||||||
|
if (!user || !orgMembership) {
|
||||||
|
throw new Error(`User with ID ${key.receiverId} was not found during upgrade, or user is not in org.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newMember] = assignWorkspaceKeysToMembers({
|
||||||
|
decryptKey: ghostUserLatestKey,
|
||||||
|
userPrivateKey: ghostUser.keys.plainPrivateKey,
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
userPublicKey: user.publicKey,
|
||||||
|
orgMembershipId: orgMembership.id,
|
||||||
|
projectMembershipRole: ProjectMembershipRole.Admin
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
newProjectMembers.push({
|
||||||
|
encryptedKey: newMember.workspaceEncryptedKey,
|
||||||
|
nonce: newMember.workspaceEncryptedNonce,
|
||||||
|
senderId: ghostUser.user.id,
|
||||||
|
receiverId: user.id,
|
||||||
|
projectId: project.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create project keys for all the old members
|
||||||
|
await projectKeyDAL.insertMany(newProjectMembers, tx);
|
||||||
|
|
||||||
|
// Encrypt the bot private key (which is the same as the ghost user)
|
||||||
|
const { iv, tag, ciphertext, encoding, algorithm } = infisicalSymmetricEncypt(ghostUser.keys.plainPrivateKey);
|
||||||
|
|
||||||
|
// 5. Create a bot for the project
|
||||||
|
const newBot = await projectBotDAL.create(
|
||||||
|
{
|
||||||
|
name: "Infisical Bot (Ghost)",
|
||||||
|
projectId: project.id,
|
||||||
|
tag,
|
||||||
|
iv,
|
||||||
|
encryptedPrivateKey: ciphertext,
|
||||||
|
isActive: true,
|
||||||
|
publicKey: ghostUser.keys.publicKey,
|
||||||
|
senderId: ghostUser.user.id,
|
||||||
|
encryptedProjectKey: newEncryptedProjectKey,
|
||||||
|
encryptedProjectKeyNonce: newEncryptedProjectKeyIv,
|
||||||
|
algorithm,
|
||||||
|
keyEncoding: encoding
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
const botPrivateKey = infisicalSymmetricDecrypt({
|
||||||
|
keyEncoding: newBot.keyEncoding as SecretKeyEncoding,
|
||||||
|
iv: newBot.iv,
|
||||||
|
tag: newBot.tag,
|
||||||
|
ciphertext: newBot.encryptedPrivateKey
|
||||||
|
});
|
||||||
|
|
||||||
|
const botKey = decryptAsymmetric({
|
||||||
|
ciphertext: newBot.encryptedProjectKey!,
|
||||||
|
privateKey: botPrivateKey,
|
||||||
|
nonce: newBot.encryptedProjectKeyNonce!,
|
||||||
|
publicKey: ghostUser.keys.publicKey
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedSecrets: TSecrets[] = [];
|
||||||
|
const updatedSecretVersions: TSecretVersions[] = [];
|
||||||
|
const updatedSecretApprovals: TSecretApprovalRequestsSecrets[] = [];
|
||||||
|
const updatedIntegrationAuths: TIntegrationAuths[] = [];
|
||||||
|
for (const rawSecret of decryptedSecrets) {
|
||||||
|
const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecret.decrypted.secretKey, botKey);
|
||||||
|
const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecret.decrypted.secretValue || "", botKey);
|
||||||
|
const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(
|
||||||
|
rawSecret.decrypted.secretComment || "",
|
||||||
|
botKey
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload: TSecrets = {
|
||||||
|
...rawSecret.original,
|
||||||
|
keyEncoding: SecretKeyEncoding.UTF8,
|
||||||
|
|
||||||
|
secretKeyCiphertext: secretKeyEncrypted.ciphertext,
|
||||||
|
secretKeyIV: secretKeyEncrypted.iv,
|
||||||
|
secretKeyTag: secretKeyEncrypted.tag,
|
||||||
|
|
||||||
|
secretValueCiphertext: secretValueEncrypted.ciphertext,
|
||||||
|
secretValueIV: secretValueEncrypted.iv,
|
||||||
|
secretValueTag: secretValueEncrypted.tag,
|
||||||
|
|
||||||
|
secretCommentCiphertext: secretCommentEncrypted.ciphertext,
|
||||||
|
secretCommentIV: secretCommentEncrypted.iv,
|
||||||
|
secretCommentTag: secretCommentEncrypted.tag
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
if (!SecretsSchema.safeParse(payload).success) {
|
||||||
|
throw new Error(`Invalid secret payload: ${JSON.stringify(payload)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedSecrets.push(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rawSecretVersion of decryptedSecretVersions) {
|
||||||
|
const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecretVersion.decrypted.secretKey, botKey);
|
||||||
|
const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(
|
||||||
|
rawSecretVersion.decrypted.secretValue || "",
|
||||||
|
botKey
|
||||||
|
);
|
||||||
|
const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(
|
||||||
|
rawSecretVersion.decrypted.secretComment || "",
|
||||||
|
botKey
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload: TSecretVersions = {
|
||||||
|
...rawSecretVersion.original,
|
||||||
|
keyEncoding: SecretKeyEncoding.UTF8,
|
||||||
|
|
||||||
|
secretKeyCiphertext: secretKeyEncrypted.ciphertext,
|
||||||
|
secretKeyIV: secretKeyEncrypted.iv,
|
||||||
|
secretKeyTag: secretKeyEncrypted.tag,
|
||||||
|
|
||||||
|
secretValueCiphertext: secretValueEncrypted.ciphertext,
|
||||||
|
secretValueIV: secretValueEncrypted.iv,
|
||||||
|
secretValueTag: secretValueEncrypted.tag,
|
||||||
|
|
||||||
|
secretCommentCiphertext: secretCommentEncrypted.ciphertext,
|
||||||
|
secretCommentIV: secretCommentEncrypted.iv,
|
||||||
|
secretCommentTag: secretCommentEncrypted.tag
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
if (!SecretVersionsSchema.safeParse(payload).success) {
|
||||||
|
throw new Error(`Invalid secret version payload: ${JSON.stringify(payload)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedSecretVersions.push(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rawSecretApproval of decryptedApprovalSecrets) {
|
||||||
|
const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecretApproval.decrypted.secretKey, botKey);
|
||||||
|
const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(
|
||||||
|
rawSecretApproval.decrypted.secretValue || "",
|
||||||
|
botKey
|
||||||
|
);
|
||||||
|
const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(
|
||||||
|
rawSecretApproval.decrypted.secretComment || "",
|
||||||
|
botKey
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload: TSecretApprovalRequestsSecrets = {
|
||||||
|
...rawSecretApproval.original,
|
||||||
|
keyEncoding: SecretKeyEncoding.UTF8,
|
||||||
|
|
||||||
|
secretKeyCiphertext: secretKeyEncrypted.ciphertext,
|
||||||
|
secretKeyIV: secretKeyEncrypted.iv,
|
||||||
|
secretKeyTag: secretKeyEncrypted.tag,
|
||||||
|
|
||||||
|
secretValueCiphertext: secretValueEncrypted.ciphertext,
|
||||||
|
secretValueIV: secretValueEncrypted.iv,
|
||||||
|
secretValueTag: secretValueEncrypted.tag,
|
||||||
|
|
||||||
|
secretCommentCiphertext: secretCommentEncrypted.ciphertext,
|
||||||
|
secretCommentIV: secretCommentEncrypted.iv,
|
||||||
|
secretCommentTag: secretCommentEncrypted.tag
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
if (!SecretApprovalRequestsSecretsSchema.safeParse(payload).success) {
|
||||||
|
throw new Error(`Invalid secret approval payload: ${JSON.stringify(payload)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedSecretApprovals.push(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const integrationAuth of decryptedIntegrationAuths) {
|
||||||
|
const access = encryptSymmetric128BitHexKeyUTF8(integrationAuth.decrypted.access, botKey);
|
||||||
|
const accessId = encryptSymmetric128BitHexKeyUTF8(integrationAuth.decrypted.accessId, botKey);
|
||||||
|
const refresh = encryptSymmetric128BitHexKeyUTF8(integrationAuth.decrypted.refresh, botKey);
|
||||||
|
|
||||||
|
const payload: TIntegrationAuths = {
|
||||||
|
...integrationAuth.original,
|
||||||
|
keyEncoding: SecretKeyEncoding.UTF8,
|
||||||
|
|
||||||
|
accessCiphertext: access.ciphertext,
|
||||||
|
accessIV: access.iv,
|
||||||
|
accessTag: access.tag,
|
||||||
|
|
||||||
|
accessIdCiphertext: accessId.ciphertext,
|
||||||
|
accessIdIV: accessId.iv,
|
||||||
|
accessIdTag: accessId.tag,
|
||||||
|
|
||||||
|
refreshCiphertext: refresh.ciphertext,
|
||||||
|
refreshIV: refresh.iv,
|
||||||
|
refreshTag: refresh.tag
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
if (!IntegrationAuthsSchema.safeParse(payload).success) {
|
||||||
|
throw new Error(`Invalid integration auth payload: ${JSON.stringify(payload)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedIntegrationAuths.push(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedSecrets.length !== secrets.length) {
|
||||||
|
throw new Error("Failed to update some secrets");
|
||||||
|
}
|
||||||
|
if (updatedSecretVersions.length !== secretVersions.length) {
|
||||||
|
throw new Error("Failed to update some secret versions");
|
||||||
|
}
|
||||||
|
if (updatedSecretApprovals.length !== approvalSecrets.length) {
|
||||||
|
throw new Error("Failed to update some secret approvals");
|
||||||
|
}
|
||||||
|
if (updatedIntegrationAuths.length !== projectIntegrationAuths.length) {
|
||||||
|
throw new Error("Failed to update some integration auths");
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretUpdates = await secretDAL.bulkUpdateNoVersionIncrement(updatedSecrets, tx);
|
||||||
|
const secretVersionUpdates = await secretVersionDAL.bulkUpdateNoVersionIncrement(updatedSecretVersions, tx);
|
||||||
|
const secretApprovalUpdates = await secretApprovalSecretDAL.bulkUpdateNoVersionIncrement(
|
||||||
|
updatedSecretApprovals,
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
const integrationAuthUpdates = await integrationAuthDAL.bulkUpdate(
|
||||||
|
updatedIntegrationAuths.map((el) => ({
|
||||||
|
filter: { id: el.id },
|
||||||
|
data: {
|
||||||
|
...el,
|
||||||
|
id: undefined
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete all secret versions that are no longer needed. We only store the latest 100 versions for each secret.
|
||||||
|
await secretVersionDAL.delete(
|
||||||
|
{
|
||||||
|
$in: {
|
||||||
|
id: folderSecretVersionIdsToDelete
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
secretUpdates.length !== updatedSecrets.length ||
|
||||||
|
secretVersionUpdates.length !== updatedSecretVersions.length ||
|
||||||
|
secretApprovalUpdates.length !== updatedSecretApprovals.length ||
|
||||||
|
integrationAuthUpdates.length !== updatedIntegrationAuths.length
|
||||||
|
) {
|
||||||
|
throw new Error("Parts of the upgrade failed. Some secrets were not updated");
|
||||||
|
}
|
||||||
|
|
||||||
|
await projectDAL.setProjectUpgradeStatus(data.projectId, null, tx);
|
||||||
|
|
||||||
|
// await new Promise((resolve) => setTimeout(resolve, 15_000));
|
||||||
|
// throw new Error("Transaction was successful!");
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const [project] = await projectDAL
|
||||||
|
.find({
|
||||||
|
id: data.projectId,
|
||||||
|
version: ProjectVersion.V1
|
||||||
|
})
|
||||||
|
.catch(() => [null]);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
logger.error("Failed to upgrade project, because no project was found", data);
|
||||||
|
} else {
|
||||||
|
await projectDAL.setProjectUpgradeStatus(data.projectId, ProjectUpgradeStatus.Failed);
|
||||||
|
logger.error(err, "Failed to upgrade project");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
queueService.listen(QueueName.UpgradeProjectToGhost, "failed", (job, err) => {
|
||||||
|
logger.error(err, "Upgrade project failed", job?.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
upgradeProject
|
||||||
|
};
|
||||||
|
};
|
@ -1,22 +1,40 @@
|
|||||||
import { ForbiddenError } from "@casl/ability";
|
import { ForbiddenError } from "@casl/ability";
|
||||||
import slugify from "@sindresorhus/slugify";
|
import slugify from "@sindresorhus/slugify";
|
||||||
|
|
||||||
import { ProjectMembershipRole } from "@app/db/schemas";
|
import { ProjectMembershipRole, ProjectVersion } from "@app/db/schemas";
|
||||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||||
|
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||||
import { getConfig } from "@app/lib/config/env";
|
import { getConfig } from "@app/lib/config/env";
|
||||||
import { createSecretBlindIndex } from "@app/lib/crypto";
|
import { createSecretBlindIndex } from "@app/lib/crypto";
|
||||||
import { BadRequestError } from "@app/lib/errors";
|
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||||
|
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
|
||||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||||
|
import { TProjectPermission } from "@app/lib/types";
|
||||||
|
|
||||||
|
import { ActorType } from "../auth/auth-type";
|
||||||
|
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
|
||||||
|
import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal";
|
||||||
|
import { TOrgServiceFactory } from "../org/org-service";
|
||||||
|
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
|
||||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||||
|
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
|
||||||
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
||||||
import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal";
|
import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal";
|
||||||
import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||||
|
import { TUserDALFactory } from "../user/user-dal";
|
||||||
import { TProjectDALFactory } from "./project-dal";
|
import { TProjectDALFactory } from "./project-dal";
|
||||||
import { TCreateProjectDTO, TDeleteProjectDTO, TGetProjectDTO } from "./project-types";
|
import { assignWorkspaceKeysToMembers, createProjectKey } from "./project-fns";
|
||||||
|
import { TProjectQueueFactory } from "./project-queue";
|
||||||
|
import {
|
||||||
|
TCreateProjectDTO,
|
||||||
|
TDeleteProjectDTO,
|
||||||
|
TGetProjectDTO,
|
||||||
|
TUpdateProjectDTO,
|
||||||
|
TUpgradeProjectDTO
|
||||||
|
} from "./project-types";
|
||||||
|
|
||||||
export const DEFAULT_PROJECT_ENVS = [
|
export const DEFAULT_PROJECT_ENVS = [
|
||||||
{ name: "Development", slug: "dev" },
|
{ name: "Development", slug: "dev" },
|
||||||
@ -26,11 +44,18 @@ export const DEFAULT_PROJECT_ENVS = [
|
|||||||
|
|
||||||
type TProjectServiceFactoryDep = {
|
type TProjectServiceFactoryDep = {
|
||||||
projectDAL: TProjectDALFactory;
|
projectDAL: TProjectDALFactory;
|
||||||
folderDAL: Pick<TSecretFolderDALFactory, "insertMany">;
|
projectQueue: TProjectQueueFactory;
|
||||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "insertMany">;
|
userDAL: TUserDALFactory;
|
||||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create">;
|
folderDAL: TSecretFolderDALFactory;
|
||||||
|
projectEnvDAL: Pick<TProjectEnvDALFactory, "insertMany" | "find">;
|
||||||
|
identityOrgMembershipDAL: TIdentityOrgDALFactory;
|
||||||
|
identityProjectDAL: TIdentityProjectDALFactory;
|
||||||
|
projectKeyDAL: Pick<TProjectKeyDALFactory, "create" | "findLatestProjectKey" | "delete" | "find" | "insertMany">;
|
||||||
|
projectBotDAL: Pick<TProjectBotDALFactory, "create" | "findById" | "delete" | "findOne">;
|
||||||
|
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create" | "findProjectGhostUser" | "findOne">;
|
||||||
secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "create">;
|
secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "create">;
|
||||||
permissionService: TPermissionServiceFactory;
|
permissionService: TPermissionServiceFactory;
|
||||||
|
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
|
||||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -38,8 +63,15 @@ export type TProjectServiceFactory = ReturnType<typeof projectServiceFactory>;
|
|||||||
|
|
||||||
export const projectServiceFactory = ({
|
export const projectServiceFactory = ({
|
||||||
projectDAL,
|
projectDAL,
|
||||||
|
projectQueue,
|
||||||
|
projectKeyDAL,
|
||||||
permissionService,
|
permissionService,
|
||||||
|
userDAL,
|
||||||
folderDAL,
|
folderDAL,
|
||||||
|
orgService,
|
||||||
|
identityProjectDAL,
|
||||||
|
projectBotDAL,
|
||||||
|
identityOrgMembershipDAL,
|
||||||
secretBlindIndexDAL,
|
secretBlindIndexDAL,
|
||||||
projectMembershipDAL,
|
projectMembershipDAL,
|
||||||
projectEnvDAL,
|
projectEnvDAL,
|
||||||
@ -48,8 +80,13 @@ export const projectServiceFactory = ({
|
|||||||
/*
|
/*
|
||||||
* Create workspace. Make user the admin
|
* Create workspace. Make user the admin
|
||||||
* */
|
* */
|
||||||
const createProject = async ({ orgId, actor, actorId, actorOrgId, workspaceName }: TCreateProjectDTO) => {
|
const createProject = async ({ orgId, actor, actorId, actorOrgId, workspaceName, slug }: TCreateProjectDTO) => {
|
||||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
|
const { permission, membership: orgMembership } = await permissionService.getOrgPermission(
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
orgId,
|
||||||
|
actorOrgId
|
||||||
|
);
|
||||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace);
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace);
|
||||||
|
|
||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
@ -64,20 +101,28 @@ export const projectServiceFactory = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const newProject = projectDAL.transaction(async (tx) => {
|
const results = await projectDAL.transaction(async (tx) => {
|
||||||
|
const ghostUser = await orgService.addGhostUser(orgId, tx);
|
||||||
|
|
||||||
const project = await projectDAL.create(
|
const project = await projectDAL.create(
|
||||||
{ name: workspaceName, orgId, slug: slugify(`${workspaceName}-${alphaNumericNanoId(4)}`) },
|
{
|
||||||
|
name: workspaceName,
|
||||||
|
orgId,
|
||||||
|
slug: slug || slugify(`${workspaceName}-${alphaNumericNanoId(4)}`),
|
||||||
|
version: ProjectVersion.V2
|
||||||
|
},
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
// set user as admin member for proeject
|
// set ghost user as admin of project
|
||||||
await projectMembershipDAL.create(
|
await projectMembershipDAL.create(
|
||||||
{
|
{
|
||||||
userId: actorId,
|
userId: ghostUser.user.id,
|
||||||
role: ProjectMembershipRole.Admin,
|
role: ProjectMembershipRole.Admin,
|
||||||
projectId: project.id
|
projectId: project.id
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
|
|
||||||
// generate the blind index for project
|
// generate the blind index for project
|
||||||
await secretBlindIndexDAL.create(
|
await secretBlindIndexDAL.create(
|
||||||
{
|
{
|
||||||
@ -99,18 +144,165 @@ export const projectServiceFactory = ({
|
|||||||
envs.map(({ id }) => ({ name: ROOT_FOLDER_NAME, envId: id, version: 1 })),
|
envs.map(({ id }) => ({ name: ROOT_FOLDER_NAME, envId: id, version: 1 })),
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
// _id for backward compat
|
|
||||||
return { ...project, environments: envs, _id: project.id };
|
// 3. Create a random key that we'll use as the project key.
|
||||||
|
const { key: encryptedProjectKey, iv: encryptedProjectKeyIv } = createProjectKey({
|
||||||
|
publicKey: ghostUser.keys.publicKey,
|
||||||
|
privateKey: ghostUser.keys.plainPrivateKey
|
||||||
});
|
});
|
||||||
|
|
||||||
return newProject;
|
// 4. Save the project key for the ghost user.
|
||||||
|
await projectKeyDAL.create(
|
||||||
|
{
|
||||||
|
projectId: project.id,
|
||||||
|
receiverId: ghostUser.user.id,
|
||||||
|
encryptedKey: encryptedProjectKey,
|
||||||
|
nonce: encryptedProjectKeyIv,
|
||||||
|
senderId: ghostUser.user.id
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
const { iv, tag, ciphertext, encoding, algorithm } = infisicalSymmetricEncypt(ghostUser.keys.plainPrivateKey);
|
||||||
|
|
||||||
|
// 5. Create & a bot for the project
|
||||||
|
await projectBotDAL.create(
|
||||||
|
{
|
||||||
|
name: "Infisical Bot (Ghost)",
|
||||||
|
projectId: project.id,
|
||||||
|
tag,
|
||||||
|
iv,
|
||||||
|
encryptedProjectKey,
|
||||||
|
encryptedProjectKeyNonce: encryptedProjectKeyIv,
|
||||||
|
encryptedPrivateKey: ciphertext,
|
||||||
|
isActive: true,
|
||||||
|
publicKey: ghostUser.keys.publicKey,
|
||||||
|
senderId: ghostUser.user.id,
|
||||||
|
algorithm,
|
||||||
|
keyEncoding: encoding
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the ghost users latest key
|
||||||
|
const latestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.user.id, project.id, tx);
|
||||||
|
|
||||||
|
if (!latestKey) {
|
||||||
|
throw new Error("Latest key not found for user");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the project is being created by a user, add the user to the project as an admin
|
||||||
|
if (actor === ActorType.USER) {
|
||||||
|
// Find public key of user
|
||||||
|
const user = await userDAL.findUserEncKeyByUserId(actorId);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [projectAdmin] = assignWorkspaceKeysToMembers({
|
||||||
|
decryptKey: latestKey,
|
||||||
|
userPrivateKey: ghostUser.keys.plainPrivateKey,
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
userPublicKey: user.publicKey,
|
||||||
|
orgMembershipId: orgMembership.id,
|
||||||
|
projectMembershipRole: ProjectMembershipRole.Admin
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a membership for the user
|
||||||
|
await projectMembershipDAL.create(
|
||||||
|
{
|
||||||
|
projectId: project.id,
|
||||||
|
userId: user.id,
|
||||||
|
role: projectAdmin.projectRole
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a project key for the user
|
||||||
|
await projectKeyDAL.create(
|
||||||
|
{
|
||||||
|
encryptedKey: projectAdmin.workspaceEncryptedKey,
|
||||||
|
nonce: projectAdmin.workspaceEncryptedNonce,
|
||||||
|
senderId: ghostUser.user.id,
|
||||||
|
receiverId: user.id,
|
||||||
|
projectId: project.id
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the project is being created by an identity, add the identity to the project as an admin
|
||||||
|
else if (actor === ActorType.IDENTITY) {
|
||||||
|
// Find identity org membership
|
||||||
|
const identityOrgMembership = await identityOrgMembershipDAL.findOne(
|
||||||
|
{
|
||||||
|
identityId: actorId,
|
||||||
|
orgId: project.orgId
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
// If identity org membership not found, throw error
|
||||||
|
if (!identityOrgMembership) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Failed to find identity with id ${actorId}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the role permission for the identity
|
||||||
|
const { permission: rolePermission, role: customRole } = await permissionService.getOrgPermissionByRole(
|
||||||
|
ProjectMembershipRole.Admin,
|
||||||
|
orgId
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
await identityProjectDAL.create(
|
||||||
|
{
|
||||||
|
identityId: actorId,
|
||||||
|
projectId: project.id,
|
||||||
|
role: isCustomRole ? ProjectMembershipRole.Custom : ProjectMembershipRole.Admin,
|
||||||
|
roleId: customRole?.id
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
environments: envs,
|
||||||
|
_id: project.id
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteProject = async ({ actor, actorId, actorOrgId, projectId }: TDeleteProjectDTO) => {
|
const deleteProject = async ({ actor, actorId, actorOrgId, projectId }: TDeleteProjectDTO) => {
|
||||||
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
|
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
|
||||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project);
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project);
|
||||||
|
|
||||||
const deletedProject = await projectDAL.deleteById(projectId);
|
const deletedProject = await projectDAL.transaction(async (tx) => {
|
||||||
|
const project = await projectDAL.deleteById(projectId, tx);
|
||||||
|
const projectGhostUser = await projectMembershipDAL.findProjectGhostUser(projectId).catch(() => null);
|
||||||
|
|
||||||
|
// Delete the org membership for the ghost user if it's found.
|
||||||
|
if (projectGhostUser) {
|
||||||
|
await userDAL.deleteById(projectGhostUser.id, tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
return project;
|
||||||
|
});
|
||||||
|
|
||||||
return deletedProject;
|
return deletedProject;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -124,6 +316,17 @@ export const projectServiceFactory = ({
|
|||||||
return projectDAL.findProjectById(projectId);
|
return projectDAL.findProjectById(projectId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateProject = async ({ projectId, actor, actorId, actorOrgId, update }: TUpdateProjectDTO) => {
|
||||||
|
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings);
|
||||||
|
|
||||||
|
const updatedProject = await projectDAL.updateById(projectId, {
|
||||||
|
name: update.name,
|
||||||
|
autoCapitalization: update.autoCapitalization
|
||||||
|
});
|
||||||
|
return updatedProject;
|
||||||
|
};
|
||||||
|
|
||||||
const toggleAutoCapitalization = async ({
|
const toggleAutoCapitalization = async ({
|
||||||
projectId,
|
projectId,
|
||||||
actor,
|
actor,
|
||||||
@ -146,12 +349,55 @@ export const projectServiceFactory = ({
|
|||||||
return updatedProject;
|
return updatedProject;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const upgradeProject = async ({ projectId, actor, actorId, userPrivateKey }: TUpgradeProjectDTO) => {
|
||||||
|
const { permission, membership } = await permissionService.getProjectPermission(actor, actorId, projectId);
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project);
|
||||||
|
|
||||||
|
if (membership?.role !== ProjectMembershipRole.Admin) {
|
||||||
|
throw new ForbiddenRequestError({
|
||||||
|
message: "User must be admin"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedPrivateKey = infisicalSymmetricEncypt(userPrivateKey);
|
||||||
|
|
||||||
|
await projectQueue.upgradeProject({
|
||||||
|
projectId,
|
||||||
|
startedByUserId: actorId,
|
||||||
|
encryptedPrivateKey: {
|
||||||
|
encryptedKey: encryptedPrivateKey.ciphertext,
|
||||||
|
encryptedKeyIv: encryptedPrivateKey.iv,
|
||||||
|
encryptedKeyTag: encryptedPrivateKey.tag,
|
||||||
|
keyEncoding: encryptedPrivateKey.encoding
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProjectUpgradeStatus = async ({ projectId, actor, actorId }: TProjectPermission) => {
|
||||||
|
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets);
|
||||||
|
|
||||||
|
const project = await projectDAL.findProjectById(projectId);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Project with id ${projectId} not found`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return project.upgradeStatus || null;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createProject,
|
createProject,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
getProjects,
|
getProjects,
|
||||||
|
updateProject,
|
||||||
|
getProjectUpgradeStatus,
|
||||||
getAProject,
|
getAProject,
|
||||||
toggleAutoCapitalization,
|
toggleAutoCapitalization,
|
||||||
updateName
|
updateName,
|
||||||
|
upgradeProject
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -1,3 +1,6 @@
|
|||||||
|
import { ProjectMembershipRole, TProjectKeys } from "@app/db/schemas";
|
||||||
|
import { TProjectPermission } from "@app/lib/types";
|
||||||
|
|
||||||
import { ActorType } from "../auth/auth-type";
|
import { ActorType } from "../auth/auth-type";
|
||||||
|
|
||||||
export type TCreateProjectDTO = {
|
export type TCreateProjectDTO = {
|
||||||
@ -6,6 +9,7 @@ export type TCreateProjectDTO = {
|
|||||||
actorOrgId?: string;
|
actorOrgId?: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
workspaceName: string;
|
workspaceName: string;
|
||||||
|
slug?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TDeleteProjectDTO = {
|
export type TDeleteProjectDTO = {
|
||||||
@ -21,3 +25,24 @@ export type TGetProjectDTO = {
|
|||||||
actorOrgId?: string;
|
actorOrgId?: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TUpdateProjectDTO = {
|
||||||
|
update: {
|
||||||
|
name?: string;
|
||||||
|
autoCapitalization?: boolean;
|
||||||
|
};
|
||||||
|
} & TProjectPermission;
|
||||||
|
|
||||||
|
export type TUpgradeProjectDTO = {
|
||||||
|
userPrivateKey: string;
|
||||||
|
} & TProjectPermission;
|
||||||
|
|
||||||
|
export type AddUserToWsDTO = {
|
||||||
|
decryptKey: TProjectKeys & { sender: { publicKey: string } };
|
||||||
|
userPrivateKey: string;
|
||||||
|
members: {
|
||||||
|
orgMembershipId: string;
|
||||||
|
projectMembershipRole: ProjectMembershipRole;
|
||||||
|
userPublicKey: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
@ -41,13 +41,12 @@ export const fnSecretsFromImports = async ({
|
|||||||
environment: importEnv.slug,
|
environment: importEnv.slug,
|
||||||
environmentInfo: importEnv,
|
environmentInfo: importEnv,
|
||||||
folderId: importedFolders?.[i]?.id,
|
folderId: importedFolders?.[i]?.id,
|
||||||
secrets: importedFolders?.[i]?.id
|
// this will ensure for cases when secrets are empty. Could be due to missing folder for a path or when emtpy secrets inside a given path
|
||||||
? importedSecsGroupByFolderId[importedFolders?.[i]?.id as string].map((item) => ({
|
secrets: (importedSecsGroupByFolderId?.[importedFolders?.[i]?.id as string] || []).map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
environment: importEnv.slug,
|
environment: importEnv.slug,
|
||||||
workspace: "", // This field should not be used, it's only here to keep the older Python SDK versions backwards compatible with the new Postgres backend.
|
workspace: "", // This field should not be used, it's only here to keep the older Python SDK versions backwards compatible with the new Postgres backend.
|
||||||
_id: item.id // The old Python SDK depends on the _id field being returned. We return this to keep the older Python SDK versions backwards compatible with the new Postgres backend.
|
_id: item.id // The old Python SDK depends on the _id field being returned. We return this to keep the older Python SDK versions backwards compatible with the new Postgres backend.
|
||||||
}))
|
}))
|
||||||
: []
|
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
@ -4,6 +4,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio
|
|||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||||
import { BadRequestError } from "@app/lib/errors";
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
|
|
||||||
|
import { TProjectDALFactory } from "../project/project-dal";
|
||||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||||
import { TSecretDALFactory } from "../secret/secret-dal";
|
import { TSecretDALFactory } from "../secret/secret-dal";
|
||||||
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||||
@ -21,6 +22,7 @@ type TSecretImportServiceFactoryDep = {
|
|||||||
secretImportDAL: TSecretImportDALFactory;
|
secretImportDAL: TSecretImportDALFactory;
|
||||||
folderDAL: TSecretFolderDALFactory;
|
folderDAL: TSecretFolderDALFactory;
|
||||||
secretDAL: Pick<TSecretDALFactory, "find">;
|
secretDAL: Pick<TSecretDALFactory, "find">;
|
||||||
|
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus">;
|
||||||
projectEnvDAL: TProjectEnvDALFactory;
|
projectEnvDAL: TProjectEnvDALFactory;
|
||||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||||
};
|
};
|
||||||
@ -34,6 +36,7 @@ export const secretImportServiceFactory = ({
|
|||||||
projectEnvDAL,
|
projectEnvDAL,
|
||||||
permissionService,
|
permissionService,
|
||||||
folderDAL,
|
folderDAL,
|
||||||
|
projectDAL,
|
||||||
secretDAL
|
secretDAL
|
||||||
}: TSecretImportServiceFactoryDep) => {
|
}: TSecretImportServiceFactoryDep) => {
|
||||||
const createImport = async ({
|
const createImport = async ({
|
||||||
@ -62,6 +65,8 @@ export const secretImportServiceFactory = ({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await projectDAL.checkProjectUpgradeStatus(projectId);
|
||||||
|
|
||||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||||
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create import" });
|
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create import" });
|
||||||
|
|
||||||
|
@ -22,7 +22,11 @@ export const secretDALFactory = (db: TDbClient) => {
|
|||||||
|
|
||||||
// the idea is to use postgres specific function
|
// the idea is to use postgres specific function
|
||||||
// insert with id this will cause a conflict then merge the data
|
// insert with id this will cause a conflict then merge the data
|
||||||
const bulkUpdate = async (data: Array<{ filter: Partial<TSecrets>; data: TSecretsUpdate }>, tx?: Knex) => {
|
const bulkUpdate = async (
|
||||||
|
data: Array<{ filter: Partial<TSecrets>; data: TSecretsUpdate }>,
|
||||||
|
|
||||||
|
tx?: Knex
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const secs = await Promise.all(
|
const secs = await Promise.all(
|
||||||
data.map(async ({ filter, data: updateData }) => {
|
data.map(async ({ filter, data: updateData }) => {
|
||||||
@ -41,6 +45,35 @@ export const secretDALFactory = (db: TDbClient) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bulkUpdateNoVersionIncrement = async (data: TSecrets[], tx?: Knex) => {
|
||||||
|
try {
|
||||||
|
const existingSecrets = await secretOrm.find(
|
||||||
|
{
|
||||||
|
$in: {
|
||||||
|
id: data.map((el) => el.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ tx }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingSecrets.length !== data.length) {
|
||||||
|
throw new BadRequestError({ message: "Some of the secrets do not exist" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) return [];
|
||||||
|
|
||||||
|
const updatedSecrets = await (tx || db)(TableName.Secret)
|
||||||
|
.insert(data)
|
||||||
|
.onConflict("id") // this will cause a conflict then merge the data
|
||||||
|
.merge() // Merge the data with the existing data
|
||||||
|
.returning("*");
|
||||||
|
|
||||||
|
return updatedSecrets;
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "bulk update secret" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const deleteMany = async (
|
const deleteMany = async (
|
||||||
data: Array<{ blindIndex: string; type: SecretType }>,
|
data: Array<{ blindIndex: string; type: SecretType }>,
|
||||||
folderId: string,
|
folderId: string,
|
||||||
@ -57,6 +90,12 @@ export const secretDALFactory = (db: TDbClient) => {
|
|||||||
type: el.type,
|
type: el.type,
|
||||||
...(el.type === SecretType.Personal ? { userId } : {})
|
...(el.type === SecretType.Personal ? { userId } : {})
|
||||||
});
|
});
|
||||||
|
if (el.type === SecretType.Shared) {
|
||||||
|
void bd.orWhere({
|
||||||
|
secretBlindIndex: el.blindIndex,
|
||||||
|
type: SecretType.Personal
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.delete()
|
.delete()
|
||||||
@ -139,5 +178,13 @@ export const secretDALFactory = (db: TDbClient) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { ...secretOrm, update, bulkUpdate, deleteMany, findByFolderId, findByBlindIndexes };
|
return {
|
||||||
|
...secretOrm,
|
||||||
|
update,
|
||||||
|
bulkUpdate,
|
||||||
|
deleteMany,
|
||||||
|
bulkUpdateNoVersionIncrement,
|
||||||
|
findByFolderId,
|
||||||
|
findByBlindIndexes
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
@ -11,6 +11,7 @@ import { groupBy, pick } from "@app/lib/fn";
|
|||||||
import { logger } from "@app/lib/logger";
|
import { logger } from "@app/lib/logger";
|
||||||
|
|
||||||
import { ActorType } from "../auth/auth-type";
|
import { ActorType } from "../auth/auth-type";
|
||||||
|
import { TProjectDALFactory } from "../project/project-dal";
|
||||||
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
|
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
|
||||||
import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal";
|
import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal";
|
||||||
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
|
||||||
@ -49,6 +50,7 @@ type TSecretServiceFactoryDep = {
|
|||||||
secretTagDAL: TSecretTagDALFactory;
|
secretTagDAL: TSecretTagDALFactory;
|
||||||
secretVersionDAL: TSecretVersionDALFactory;
|
secretVersionDAL: TSecretVersionDALFactory;
|
||||||
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath">;
|
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath">;
|
||||||
|
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus">;
|
||||||
secretBlindIndexDAL: TSecretBlindIndexDALFactory;
|
secretBlindIndexDAL: TSecretBlindIndexDALFactory;
|
||||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||||
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
|
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
|
||||||
@ -68,6 +70,7 @@ export const secretServiceFactory = ({
|
|||||||
permissionService,
|
permissionService,
|
||||||
snapshotService,
|
snapshotService,
|
||||||
secretQueueService,
|
secretQueueService,
|
||||||
|
projectDAL,
|
||||||
projectBotService,
|
projectBotService,
|
||||||
secretImportDAL,
|
secretImportDAL,
|
||||||
secretVersionTagDAL
|
secretVersionTagDAL
|
||||||
@ -281,6 +284,8 @@ export const secretServiceFactory = ({
|
|||||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await projectDAL.checkProjectUpgradeStatus(projectId);
|
||||||
|
|
||||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||||
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
|
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
|
||||||
const folderId = folder.id;
|
const folderId = folder.id;
|
||||||
@ -359,6 +364,8 @@ export const secretServiceFactory = ({
|
|||||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await projectDAL.checkProjectUpgradeStatus(projectId);
|
||||||
|
|
||||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||||
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
|
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
|
||||||
const folderId = folder.id;
|
const folderId = folder.id;
|
||||||
@ -459,6 +466,8 @@ export const secretServiceFactory = ({
|
|||||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await projectDAL.checkProjectUpgradeStatus(projectId);
|
||||||
|
|
||||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||||
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
|
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
|
||||||
const folderId = folder.id;
|
const folderId = folder.id;
|
||||||
@ -650,6 +659,8 @@ export const secretServiceFactory = ({
|
|||||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await projectDAL.checkProjectUpgradeStatus(projectId);
|
||||||
|
|
||||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||||
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
|
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
|
||||||
const folderId = folder.id;
|
const folderId = folder.id;
|
||||||
@ -705,6 +716,8 @@ export const secretServiceFactory = ({
|
|||||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await projectDAL.checkProjectUpgradeStatus(projectId);
|
||||||
|
|
||||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||||
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
|
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
|
||||||
const folderId = folder.id;
|
const folderId = folder.id;
|
||||||
@ -776,6 +789,8 @@ export const secretServiceFactory = ({
|
|||||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await projectDAL.checkProjectUpgradeStatus(projectId);
|
||||||
|
|
||||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||||
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
|
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
|
||||||
const folderId = folder.id;
|
const folderId = folder.id;
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { Knex } from "knex";
|
import { Knex } from "knex";
|
||||||
|
|
||||||
import { TDbClient } from "@app/db";
|
import { TDbClient } from "@app/db";
|
||||||
import { TableName, TSecretVersions } from "@app/db/schemas";
|
import { TableName, TSecretVersions, TSecretVersionsUpdate } from "@app/db/schemas";
|
||||||
import { DatabaseError } from "@app/lib/errors";
|
import { BadRequestError, DatabaseError } from "@app/lib/errors";
|
||||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||||
|
|
||||||
export type TSecretVersionDALFactory = ReturnType<typeof secretVersionDALFactory>;
|
export type TSecretVersionDALFactory = ReturnType<typeof secretVersionDALFactory>;
|
||||||
@ -36,6 +36,57 @@ export const secretVersionDALFactory = (db: TDbClient) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bulkUpdate = async (
|
||||||
|
data: Array<{ filter: Partial<TSecretVersions>; data: TSecretVersionsUpdate }>,
|
||||||
|
tx?: Knex
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const secs = await Promise.all(
|
||||||
|
data.map(async ({ filter, data: updateData }) => {
|
||||||
|
const [doc] = await (tx || db)(TableName.SecretVersion)
|
||||||
|
.where(filter)
|
||||||
|
.update(updateData)
|
||||||
|
.increment("version", 1) // TODO: Is this really needed?
|
||||||
|
.returning("*");
|
||||||
|
if (!doc) throw new BadRequestError({ message: "Failed to update document" });
|
||||||
|
return doc;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return secs;
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "bulk update secret" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bulkUpdateNoVersionIncrement = async (data: TSecretVersions[], tx?: Knex) => {
|
||||||
|
try {
|
||||||
|
const existingSecretVersions = await secretVersionOrm.find(
|
||||||
|
{
|
||||||
|
$in: {
|
||||||
|
id: data.map((el) => el.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ tx }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingSecretVersions.length !== data.length) {
|
||||||
|
throw new BadRequestError({ message: "Some of the secret versions do not exist" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) return [];
|
||||||
|
|
||||||
|
const updatedSecretVersions = await (tx || db)(TableName.SecretVersion)
|
||||||
|
.insert(data)
|
||||||
|
.onConflict("id") // this will cause a conflict then merge the data
|
||||||
|
.merge() // Merge the data with the existing data
|
||||||
|
.returning("*");
|
||||||
|
|
||||||
|
return updatedSecretVersions;
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "bulk update secret" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const findLatestVersionMany = async (folderId: string, secretIds: string[], tx?: Knex) => {
|
const findLatestVersionMany = async (folderId: string, secretIds: string[], tx?: Knex) => {
|
||||||
try {
|
try {
|
||||||
const docs: Array<TSecretVersions & { max: number }> = await (tx || db)(TableName.SecretVersion)
|
const docs: Array<TSecretVersions & { max: number }> = await (tx || db)(TableName.SecretVersion)
|
||||||
@ -59,5 +110,11 @@ export const secretVersionDALFactory = (db: TDbClient) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { ...secretVersionOrm, findLatestVersionMany, findLatestVersionByFolderId };
|
return {
|
||||||
|
...secretVersionOrm,
|
||||||
|
findLatestVersionMany,
|
||||||
|
bulkUpdate,
|
||||||
|
findLatestVersionByFolderId,
|
||||||
|
bulkUpdateNoVersionIncrement
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
@ -25,7 +25,8 @@ export enum SmtpTemplates {
|
|||||||
OrgInvite = "organizationInvitation.handlebars",
|
OrgInvite = "organizationInvitation.handlebars",
|
||||||
ResetPassword = "passwordReset.handlebars",
|
ResetPassword = "passwordReset.handlebars",
|
||||||
SecretLeakIncident = "secretLeakIncident.handlebars",
|
SecretLeakIncident = "secretLeakIncident.handlebars",
|
||||||
WorkspaceInvite = "workspaceInvitation.handlebars"
|
WorkspaceInvite = "workspaceInvitation.handlebars",
|
||||||
|
ScimUserProvisioned = "scimUserProvisioned.handlebars"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SmtpHost {
|
export enum SmtpHost {
|
||||||
|
@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||||
|
<title>Organization Invitation</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h2>Join your organization on Infisical</h2>
|
||||||
|
<p>You've been invited to join the Infisical organization — {{organizationName}}</p>
|
||||||
|
<a href="{{callback_url}}">Join now</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>
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user