mirror of
https://github.com/Infisical/infisical.git
synced 2025-08-24 20:43:19 +00:00
Compare commits
110 Commits
feat/doc-a
...
secrets-mi
Author | SHA1 | Date | |
---|---|---|---|
|
43752e1888 | ||
|
bd72129d8c | ||
|
bf10b2f58a | ||
|
d24f5a57a8 | ||
|
166104e523 | ||
|
a7847f177c | ||
|
48e5f550e9 | ||
|
4a4a7fd325 | ||
|
91b8ed8015 | ||
|
6cf978b593 | ||
|
68fbb399fc | ||
|
97366f6e95 | ||
|
c83d4af7a3 | ||
|
c35c937c63 | ||
|
b10752acb5 | ||
|
eb9b75d930 | ||
|
273a7b9657 | ||
|
a3b6fa9a53 | ||
|
f60dd528e8 | ||
|
8ffef1da8e | ||
|
f352f98374 | ||
|
91a76f50ca | ||
|
ea4bb0a062 | ||
|
3d6be7b1b2 | ||
|
12558e8614 | ||
|
987f87e562 | ||
|
4d06d5cbb0 | ||
|
bad934de48 | ||
|
90b93fbd15 | ||
|
c2db2a0bc7 | ||
|
b0d24de008 | ||
|
0473fb0ddb | ||
|
4ccb5dc9b0 | ||
|
930425d5dc | ||
|
f77a53bd8e | ||
|
4bd61e5607 | ||
|
aa4dbfa073 | ||
|
b479406ba0 | ||
|
7cf9d933da | ||
|
ca2825ba95 | ||
|
b8fa4d5255 | ||
|
0d3cb2d41a | ||
|
e0d19d7b65 | ||
|
f5a0d8be78 | ||
|
c7ae7be493 | ||
|
18881749fd | ||
|
fa54c406dc | ||
|
1a2eef3ba6 | ||
|
0c562150f5 | ||
|
6fde132804 | ||
|
799721782a | ||
|
86d430f911 | ||
|
7c28ee844e | ||
|
d5390fcafc | ||
|
1b40f5d475 | ||
|
d71362ccc3 | ||
|
e4d90eb055 | ||
|
55607a4886 | ||
|
385c75c543 | ||
|
f16dca45d9 | ||
|
118c28df54 | ||
|
249b2933da | ||
|
272336092d | ||
|
6f05a6d82c | ||
|
84ebdb8503 | ||
|
b464941fbc | ||
|
77e8d8a86d | ||
|
c61dd1ee6e | ||
|
9db8573e72 | ||
|
ce8653e908 | ||
|
fd4cdc2769 | ||
|
90a1cc9330 | ||
|
78bfd0922a | ||
|
458dcd31c1 | ||
|
372537f0b6 | ||
|
e173ff3828 | ||
|
2baadf60d1 | ||
|
e13fc93bac | ||
|
6b14fbcce2 | ||
|
86fbe5cc24 | ||
|
3f7862a345 | ||
|
9661458469 | ||
|
c7c1eb0f5f | ||
|
a1e48a1795 | ||
|
d14e80b771 | ||
|
0264d37d9b | ||
|
11a1604e14 | ||
|
f788dee398 | ||
|
88120ed45e | ||
|
d6a377416d | ||
|
368e00ea71 | ||
|
23237dd055 | ||
|
e10aec3170 | ||
|
0b11dcd627 | ||
|
d1e8ae3c98 | ||
|
5c9243d691 | ||
|
35d1eabf49 | ||
|
b6902160ce | ||
|
fbfc51ee93 | ||
|
9d92ffce95 | ||
|
9193418f8b | ||
|
352ef050c3 | ||
|
b6b9fb6ef5 | ||
|
389e2e1fb7 | ||
|
88fcbcadd4 | ||
|
1b32de5c5b | ||
|
522795871e | ||
|
5c63955fde | ||
|
d7f3892b73 | ||
|
33af2fb2b8 |
15
.github/workflows/run-backend-tests.yml
vendored
15
.github/workflows/run-backend-tests.yml
vendored
@@ -16,6 +16,16 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
|
|
||||||
|
- name: Free up disk space
|
||||||
|
run: |
|
||||||
|
sudo rm -rf /usr/share/dotnet
|
||||||
|
sudo rm -rf /opt/ghc
|
||||||
|
sudo rm -rf "/usr/local/share/boost"
|
||||||
|
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||||
|
docker system prune -af
|
||||||
|
|
||||||
- name: ☁️ Checkout source
|
- name: ☁️ Checkout source
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
- uses: KengoTODA/actions-setup-docker-compose@v1
|
- uses: KengoTODA/actions-setup-docker-compose@v1
|
||||||
@@ -34,6 +44,8 @@ jobs:
|
|||||||
working-directory: backend
|
working-directory: backend
|
||||||
- name: Start postgres and redis
|
- name: Start postgres and redis
|
||||||
run: touch .env && docker compose -f docker-compose.dev.yml up -d db redis
|
run: touch .env && docker compose -f docker-compose.dev.yml up -d db redis
|
||||||
|
- name: Start Secret Rotation testing databases
|
||||||
|
run: docker compose -f docker-compose.e2e-dbs.yml up -d --wait --wait-timeout 300
|
||||||
- name: Run unit test
|
- name: Run unit test
|
||||||
run: npm run test:unit
|
run: npm run test:unit
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
@@ -41,6 +53,9 @@ jobs:
|
|||||||
run: npm run test:e2e
|
run: npm run test:e2e
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
env:
|
env:
|
||||||
|
E2E_TEST_ORACLE_DB_19_HOST: ${{ secrets.E2E_TEST_ORACLE_DB_19_HOST }}
|
||||||
|
E2E_TEST_ORACLE_DB_19_USERNAME: ${{ secrets.E2E_TEST_ORACLE_DB_19_USERNAME }}
|
||||||
|
E2E_TEST_ORACLE_DB_19_PASSWORD: ${{ secrets.E2E_TEST_ORACLE_DB_19_PASSWORD }}
|
||||||
REDIS_URL: redis://172.17.0.1:6379
|
REDIS_URL: redis://172.17.0.1:6379
|
||||||
DB_CONNECTION_URI: postgres://infisical:infisical@172.17.0.1:5432/infisical?sslmode=disable
|
DB_CONNECTION_URI: postgres://infisical:infisical@172.17.0.1:5432/infisical?sslmode=disable
|
||||||
AUTH_SECRET: something-random
|
AUTH_SECRET: something-random
|
||||||
|
@@ -50,3 +50,4 @@ docs/integrations/app-connections/zabbix.mdx:generic-api-key:91
|
|||||||
docs/integrations/app-connections/bitbucket.mdx:generic-api-key:123
|
docs/integrations/app-connections/bitbucket.mdx:generic-api-key:123
|
||||||
docs/integrations/app-connections/railway.mdx:generic-api-key:156
|
docs/integrations/app-connections/railway.mdx:generic-api-key:156
|
||||||
.github/workflows/validate-db-schemas.yml:generic-api-key:21
|
.github/workflows/validate-db-schemas.yml:generic-api-key:21
|
||||||
|
k8-operator/config/samples/universalAuthIdentitySecret.yaml:generic-api-key:8
|
||||||
|
@@ -1,34 +0,0 @@
|
|||||||
import { TQueueServiceFactory } from "@app/queue";
|
|
||||||
|
|
||||||
export const mockQueue = (): TQueueServiceFactory => {
|
|
||||||
const queues: Record<string, unknown> = {};
|
|
||||||
const workers: Record<string, unknown> = {};
|
|
||||||
const job: Record<string, unknown> = {};
|
|
||||||
const events: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
queue: async (name, jobData) => {
|
|
||||||
job[name] = jobData;
|
|
||||||
},
|
|
||||||
queuePg: async () => {},
|
|
||||||
schedulePg: async () => {},
|
|
||||||
initialize: async () => {},
|
|
||||||
shutdown: async () => undefined,
|
|
||||||
stopRepeatableJob: async () => true,
|
|
||||||
start: (name, jobFn) => {
|
|
||||||
queues[name] = jobFn;
|
|
||||||
workers[name] = jobFn;
|
|
||||||
},
|
|
||||||
startPg: async () => {},
|
|
||||||
listen: (name, event) => {
|
|
||||||
events[name] = event;
|
|
||||||
},
|
|
||||||
getRepeatableJobs: async () => [],
|
|
||||||
getDelayedJobs: async () => [],
|
|
||||||
clearQueue: async () => {},
|
|
||||||
stopJobById: async () => {},
|
|
||||||
stopJobByIdPg: async () => {},
|
|
||||||
stopRepeatableJobByJobId: async () => true,
|
|
||||||
stopRepeatableJobByKey: async () => true
|
|
||||||
};
|
|
||||||
};
|
|
726
backend/e2e-test/routes/v3/secret-rotations.spec.ts
Normal file
726
backend/e2e-test/routes/v3/secret-rotations.spec.ts
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
/* eslint-disable no-promise-executor-return */
|
||||||
|
/* eslint-disable no-await-in-loop */
|
||||||
|
import knex from "knex";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
import { seedData1 } from "@app/db/seed-data";
|
||||||
|
|
||||||
|
enum SecretRotationType {
|
||||||
|
OracleDb = "oracledb",
|
||||||
|
MySQL = "mysql",
|
||||||
|
Postgres = "postgres"
|
||||||
|
}
|
||||||
|
|
||||||
|
type TGenericSqlCredentials = {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
database: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TSecretMapping = {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TDatabaseUserCredentials = {
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSqlUsername = (username: string) => `${username}_${uuidv4().slice(0, 8).replace(/-/g, "").toUpperCase()}`;
|
||||||
|
|
||||||
|
const getSecretValue = async (secretKey: string) => {
|
||||||
|
const passwordSecret = await testServer.inject({
|
||||||
|
url: `/api/v3/secrets/raw/${secretKey}`,
|
||||||
|
method: "GET",
|
||||||
|
query: {
|
||||||
|
workspaceId: seedData1.projectV3.id,
|
||||||
|
environment: seedData1.environment.slug
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(passwordSecret.statusCode).toBe(200);
|
||||||
|
expect(passwordSecret.json().secret).toBeDefined();
|
||||||
|
|
||||||
|
const passwordSecretJson = JSON.parse(passwordSecret.payload);
|
||||||
|
|
||||||
|
return passwordSecretJson.secret.secretValue as string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSecretRotation = async (id: string, type: SecretRotationType) => {
|
||||||
|
const res = await testServer.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
query: {
|
||||||
|
deleteSecrets: "true",
|
||||||
|
revokeGeneratedCredentials: "true"
|
||||||
|
},
|
||||||
|
url: `/api/v2/secret-rotations/${type}-credentials/${id}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAppConnection = async (id: string, type: SecretRotationType) => {
|
||||||
|
const res = await testServer.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/api/v1/app-connections/${type}/${id}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createOracleDBAppConnection = async (credentials: TGenericSqlCredentials) => {
|
||||||
|
const createOracleDBAppConnectionReqBody = {
|
||||||
|
credentials: {
|
||||||
|
database: credentials.database,
|
||||||
|
host: credentials.host,
|
||||||
|
username: credentials.username,
|
||||||
|
password: credentials.password,
|
||||||
|
port: credentials.port,
|
||||||
|
sslEnabled: true,
|
||||||
|
sslRejectUnauthorized: true
|
||||||
|
},
|
||||||
|
name: `oracle-db-${uuidv4()}`,
|
||||||
|
description: "Test OracleDB App Connection",
|
||||||
|
gatewayId: null,
|
||||||
|
isPlatformManagedCredentials: false,
|
||||||
|
method: "username-and-password"
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await testServer.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/v1/app-connections/oracledb`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
},
|
||||||
|
body: createOracleDBAppConnectionReqBody
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = JSON.parse(res.payload);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(json.appConnection).toBeDefined();
|
||||||
|
|
||||||
|
return json.appConnection.id as string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMySQLAppConnection = async (credentials: TGenericSqlCredentials) => {
|
||||||
|
const createMySQLAppConnectionReqBody = {
|
||||||
|
name: `mysql-test-${uuidv4()}`,
|
||||||
|
description: "test-mysql",
|
||||||
|
gatewayId: null,
|
||||||
|
method: "username-and-password",
|
||||||
|
credentials: {
|
||||||
|
host: credentials.host,
|
||||||
|
port: credentials.port,
|
||||||
|
database: credentials.database,
|
||||||
|
username: credentials.username,
|
||||||
|
password: credentials.password,
|
||||||
|
sslEnabled: false,
|
||||||
|
sslRejectUnauthorized: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await testServer.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/v1/app-connections/mysql`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
},
|
||||||
|
body: createMySQLAppConnectionReqBody
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = JSON.parse(res.payload);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(json.appConnection).toBeDefined();
|
||||||
|
|
||||||
|
return json.appConnection.id as string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPostgresAppConnection = async (credentials: TGenericSqlCredentials) => {
|
||||||
|
const createPostgresAppConnectionReqBody = {
|
||||||
|
credentials: {
|
||||||
|
host: credentials.host,
|
||||||
|
port: credentials.port,
|
||||||
|
database: credentials.database,
|
||||||
|
username: credentials.username,
|
||||||
|
password: credentials.password,
|
||||||
|
sslEnabled: false,
|
||||||
|
sslRejectUnauthorized: true
|
||||||
|
},
|
||||||
|
name: `postgres-test-${uuidv4()}`,
|
||||||
|
description: "test-postgres",
|
||||||
|
gatewayId: null,
|
||||||
|
method: "username-and-password"
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await testServer.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/v1/app-connections/postgres`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
},
|
||||||
|
body: createPostgresAppConnectionReqBody
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = JSON.parse(res.payload);
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(json.appConnection).toBeDefined();
|
||||||
|
|
||||||
|
return json.appConnection.id as string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createOracleInfisicalUsers = async (
|
||||||
|
credentials: TGenericSqlCredentials,
|
||||||
|
userCredentials: TDatabaseUserCredentials[]
|
||||||
|
) => {
|
||||||
|
const client = knex({
|
||||||
|
client: "oracledb",
|
||||||
|
connection: {
|
||||||
|
database: credentials.database,
|
||||||
|
port: credentials.port,
|
||||||
|
host: credentials.host,
|
||||||
|
user: credentials.username,
|
||||||
|
password: credentials.password,
|
||||||
|
connectionTimeoutMillis: 10000,
|
||||||
|
ssl: {
|
||||||
|
// @ts-expect-error - this is a valid property for the ssl object
|
||||||
|
sslServerDNMatch: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const { username } of userCredentials) {
|
||||||
|
// check if user exists, and if it does, don't create it
|
||||||
|
const existingUser = await client.raw(`SELECT * FROM all_users WHERE username = '${username}'`);
|
||||||
|
|
||||||
|
if (!existingUser.length) {
|
||||||
|
await client.raw(`CREATE USER ${username} IDENTIFIED BY "temporary_password"`);
|
||||||
|
}
|
||||||
|
await client.raw(`GRANT ALL PRIVILEGES TO ${username} WITH ADMIN OPTION`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.destroy();
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMySQLInfisicalUsers = async (
|
||||||
|
credentials: TGenericSqlCredentials,
|
||||||
|
userCredentials: TDatabaseUserCredentials[]
|
||||||
|
) => {
|
||||||
|
const client = knex({
|
||||||
|
client: "mysql2",
|
||||||
|
connection: {
|
||||||
|
database: credentials.database,
|
||||||
|
port: credentials.port,
|
||||||
|
host: credentials.host,
|
||||||
|
user: credentials.username,
|
||||||
|
password: credentials.password,
|
||||||
|
connectionTimeoutMillis: 10000
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fix: Ensure root has GRANT OPTION privileges
|
||||||
|
try {
|
||||||
|
await client.raw("GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' WITH GRANT OPTION;");
|
||||||
|
await client.raw("FLUSH PRIVILEGES;");
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore if already has privileges
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const { username } of userCredentials) {
|
||||||
|
// check if user exists, and if it does, dont create it
|
||||||
|
|
||||||
|
const existingUser = await client.raw(`SELECT * FROM mysql.user WHERE user = '${username}'`);
|
||||||
|
|
||||||
|
if (!existingUser[0].length) {
|
||||||
|
await client.raw(`CREATE USER '${username}'@'%' IDENTIFIED BY 'temporary_password';`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.raw(`GRANT ALL PRIVILEGES ON \`${credentials.database}\`.* TO '${username}'@'%';`);
|
||||||
|
await client.raw("FLUSH PRIVILEGES;");
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.destroy();
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPostgresInfisicalUsers = async (
|
||||||
|
credentials: TGenericSqlCredentials,
|
||||||
|
userCredentials: TDatabaseUserCredentials[]
|
||||||
|
) => {
|
||||||
|
const client = knex({
|
||||||
|
client: "pg",
|
||||||
|
connection: {
|
||||||
|
database: credentials.database,
|
||||||
|
port: credentials.port,
|
||||||
|
host: credentials.host,
|
||||||
|
user: credentials.username,
|
||||||
|
password: credentials.password,
|
||||||
|
connectionTimeoutMillis: 10000
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const { username } of userCredentials) {
|
||||||
|
// check if user exists, and if it does, don't create it
|
||||||
|
const existingUser = await client.raw("SELECT * FROM pg_catalog.pg_user WHERE usename = ?", [username]);
|
||||||
|
|
||||||
|
if (!existingUser.rows.length) {
|
||||||
|
await client.raw(`CREATE USER "${username}" WITH PASSWORD 'temporary_password'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.raw("GRANT ALL PRIVILEGES ON DATABASE ?? TO ??", [credentials.database, username]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.destroy();
|
||||||
|
};
|
||||||
|
|
||||||
|
const createOracleDBSecretRotation = async (
|
||||||
|
appConnectionId: string,
|
||||||
|
credentials: TGenericSqlCredentials,
|
||||||
|
userCredentials: TDatabaseUserCredentials[],
|
||||||
|
secretMapping: TSecretMapping
|
||||||
|
) => {
|
||||||
|
const now = new Date();
|
||||||
|
const rotationTime = new Date(now.getTime() - 2 * 60 * 1000); // 2 minutes ago
|
||||||
|
|
||||||
|
await createOracleInfisicalUsers(credentials, userCredentials);
|
||||||
|
|
||||||
|
const createOracleDBSecretRotationReqBody = {
|
||||||
|
parameters: userCredentials.reduce(
|
||||||
|
(acc, user, index) => {
|
||||||
|
acc[`username${index + 1}`] = user.username;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>
|
||||||
|
),
|
||||||
|
secretsMapping: {
|
||||||
|
username: secretMapping.username,
|
||||||
|
password: secretMapping.password
|
||||||
|
},
|
||||||
|
name: `test-oracle-${uuidv4()}`,
|
||||||
|
description: "Test OracleDB Secret Rotation",
|
||||||
|
secretPath: "/",
|
||||||
|
isAutoRotationEnabled: true,
|
||||||
|
rotationInterval: 5, // 5 seconds for testing
|
||||||
|
rotateAtUtc: {
|
||||||
|
hours: rotationTime.getUTCHours(),
|
||||||
|
minutes: rotationTime.getUTCMinutes()
|
||||||
|
},
|
||||||
|
connectionId: appConnectionId,
|
||||||
|
environment: seedData1.environment.slug,
|
||||||
|
projectId: seedData1.projectV3.id
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await testServer.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/v2/secret-rotations/oracledb-credentials`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
},
|
||||||
|
body: createOracleDBSecretRotationReqBody
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().secretRotation).toBeDefined();
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMySQLSecretRotation = async (
|
||||||
|
appConnectionId: string,
|
||||||
|
credentials: TGenericSqlCredentials,
|
||||||
|
userCredentials: TDatabaseUserCredentials[],
|
||||||
|
secretMapping: TSecretMapping
|
||||||
|
) => {
|
||||||
|
const now = new Date();
|
||||||
|
const rotationTime = new Date(now.getTime() - 2 * 60 * 1000); // 2 minutes ago
|
||||||
|
|
||||||
|
await createMySQLInfisicalUsers(credentials, userCredentials);
|
||||||
|
|
||||||
|
const createMySQLSecretRotationReqBody = {
|
||||||
|
parameters: userCredentials.reduce(
|
||||||
|
(acc, user, index) => {
|
||||||
|
acc[`username${index + 1}`] = user.username;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>
|
||||||
|
),
|
||||||
|
secretsMapping: {
|
||||||
|
username: secretMapping.username,
|
||||||
|
password: secretMapping.password
|
||||||
|
},
|
||||||
|
name: `test-mysql-rotation-${uuidv4()}`,
|
||||||
|
description: "Test MySQL Secret Rotation",
|
||||||
|
secretPath: "/",
|
||||||
|
isAutoRotationEnabled: true,
|
||||||
|
rotationInterval: 5,
|
||||||
|
rotateAtUtc: {
|
||||||
|
hours: rotationTime.getUTCHours(),
|
||||||
|
minutes: rotationTime.getUTCMinutes()
|
||||||
|
},
|
||||||
|
connectionId: appConnectionId,
|
||||||
|
environment: seedData1.environment.slug,
|
||||||
|
projectId: seedData1.projectV3.id
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await testServer.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/v2/secret-rotations/mysql-credentials`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
},
|
||||||
|
body: createMySQLSecretRotationReqBody
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().secretRotation).toBeDefined();
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPostgresSecretRotation = async (
|
||||||
|
appConnectionId: string,
|
||||||
|
credentials: TGenericSqlCredentials,
|
||||||
|
userCredentials: TDatabaseUserCredentials[],
|
||||||
|
secretMapping: TSecretMapping
|
||||||
|
) => {
|
||||||
|
const now = new Date();
|
||||||
|
const rotationTime = new Date(now.getTime() - 2 * 60 * 1000); // 2 minutes ago
|
||||||
|
|
||||||
|
await createPostgresInfisicalUsers(credentials, userCredentials);
|
||||||
|
|
||||||
|
const createPostgresSecretRotationReqBody = {
|
||||||
|
parameters: userCredentials.reduce(
|
||||||
|
(acc, user, index) => {
|
||||||
|
acc[`username${index + 1}`] = user.username;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>
|
||||||
|
),
|
||||||
|
secretsMapping: {
|
||||||
|
username: secretMapping.username,
|
||||||
|
password: secretMapping.password
|
||||||
|
},
|
||||||
|
name: `test-postgres-rotation-${uuidv4()}`,
|
||||||
|
description: "Test Postgres Secret Rotation",
|
||||||
|
secretPath: "/",
|
||||||
|
isAutoRotationEnabled: true,
|
||||||
|
rotationInterval: 5,
|
||||||
|
rotateAtUtc: {
|
||||||
|
hours: rotationTime.getUTCHours(),
|
||||||
|
minutes: rotationTime.getUTCMinutes()
|
||||||
|
},
|
||||||
|
connectionId: appConnectionId,
|
||||||
|
environment: seedData1.environment.slug,
|
||||||
|
projectId: seedData1.projectV3.id
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await testServer.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/v2/secret-rotations/postgres-credentials`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtAuthToken}`
|
||||||
|
},
|
||||||
|
body: createPostgresSecretRotationReqBody
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().secretRotation).toBeDefined();
|
||||||
|
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Secret Rotations", async () => {
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
type: SecretRotationType.MySQL,
|
||||||
|
name: "MySQL (8.4.6) Secret Rotation",
|
||||||
|
dbCredentials: {
|
||||||
|
database: "mysql-test",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
username: "root",
|
||||||
|
password: "mysql-test",
|
||||||
|
port: 3306
|
||||||
|
},
|
||||||
|
secretMapping: {
|
||||||
|
username: formatSqlUsername("MYSQL_USERNAME"),
|
||||||
|
password: formatSqlUsername("MYSQL_PASSWORD")
|
||||||
|
},
|
||||||
|
userCredentials: [
|
||||||
|
{
|
||||||
|
username: formatSqlUsername("MYSQL_USER_1")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
username: formatSqlUsername("MYSQL_USER_2")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: SecretRotationType.MySQL,
|
||||||
|
name: "MySQL (8.0.29) Secret Rotation",
|
||||||
|
dbCredentials: {
|
||||||
|
database: "mysql-test",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
username: "root",
|
||||||
|
password: "mysql-test",
|
||||||
|
port: 3307
|
||||||
|
},
|
||||||
|
secretMapping: {
|
||||||
|
username: formatSqlUsername("MYSQL_USERNAME"),
|
||||||
|
password: formatSqlUsername("MYSQL_PASSWORD")
|
||||||
|
},
|
||||||
|
userCredentials: [
|
||||||
|
{
|
||||||
|
username: formatSqlUsername("MYSQL_USER_1")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
username: formatSqlUsername("MYSQL_USER_2")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: SecretRotationType.MySQL,
|
||||||
|
name: "MySQL (5.7.31) Secret Rotation",
|
||||||
|
dbCredentials: {
|
||||||
|
database: "mysql-test",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
username: "root",
|
||||||
|
password: "mysql-test",
|
||||||
|
port: 3308
|
||||||
|
},
|
||||||
|
secretMapping: {
|
||||||
|
username: formatSqlUsername("MYSQL_USERNAME"),
|
||||||
|
password: formatSqlUsername("MYSQL_PASSWORD")
|
||||||
|
},
|
||||||
|
userCredentials: [
|
||||||
|
{
|
||||||
|
username: formatSqlUsername("MYSQL_USER_1")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
username: formatSqlUsername("MYSQL_USER_2")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: SecretRotationType.OracleDb,
|
||||||
|
name: "OracleDB (23.8) Secret Rotation",
|
||||||
|
dbCredentials: {
|
||||||
|
database: "FREEPDB1",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
username: "system",
|
||||||
|
password: "pdb-password",
|
||||||
|
port: 1521
|
||||||
|
},
|
||||||
|
secretMapping: {
|
||||||
|
username: formatSqlUsername("ORACLEDB_USERNAME"),
|
||||||
|
password: formatSqlUsername("ORACLEDB_PASSWORD")
|
||||||
|
},
|
||||||
|
userCredentials: [
|
||||||
|
{
|
||||||
|
username: formatSqlUsername("INFISICAL_USER_1")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
username: formatSqlUsername("INFISICAL_USER_2")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: SecretRotationType.OracleDb,
|
||||||
|
name: "OracleDB (19.3) Secret Rotation",
|
||||||
|
skippable: true,
|
||||||
|
dbCredentials: {
|
||||||
|
password: process.env.E2E_TEST_ORACLE_DB_19_PASSWORD!,
|
||||||
|
host: process.env.E2E_TEST_ORACLE_DB_19_HOST!,
|
||||||
|
username: process.env.E2E_TEST_ORACLE_DB_19_USERNAME!,
|
||||||
|
port: 1521,
|
||||||
|
database: "ORCLPDB1"
|
||||||
|
},
|
||||||
|
secretMapping: {
|
||||||
|
username: formatSqlUsername("ORACLEDB_USERNAME"),
|
||||||
|
password: formatSqlUsername("ORACLEDB_PASSWORD")
|
||||||
|
},
|
||||||
|
userCredentials: [
|
||||||
|
{
|
||||||
|
username: formatSqlUsername("INFISICAL_USER_1")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
username: formatSqlUsername("INFISICAL_USER_2")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: SecretRotationType.Postgres,
|
||||||
|
name: "Postgres (17) Secret Rotation",
|
||||||
|
dbCredentials: {
|
||||||
|
database: "postgres-test",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
username: "postgres-test",
|
||||||
|
password: "postgres-test",
|
||||||
|
port: 5433
|
||||||
|
},
|
||||||
|
secretMapping: {
|
||||||
|
username: formatSqlUsername("POSTGRES_USERNAME"),
|
||||||
|
password: formatSqlUsername("POSTGRES_PASSWORD")
|
||||||
|
},
|
||||||
|
userCredentials: [
|
||||||
|
{
|
||||||
|
username: formatSqlUsername("INFISICAL_USER_1")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
username: formatSqlUsername("INFISICAL_USER_2")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: SecretRotationType.Postgres,
|
||||||
|
name: "Postgres (16) Secret Rotation",
|
||||||
|
dbCredentials: {
|
||||||
|
database: "postgres-test",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
username: "postgres-test",
|
||||||
|
password: "postgres-test",
|
||||||
|
port: 5434
|
||||||
|
},
|
||||||
|
secretMapping: {
|
||||||
|
username: formatSqlUsername("POSTGRES_USERNAME"),
|
||||||
|
password: formatSqlUsername("POSTGRES_PASSWORD")
|
||||||
|
},
|
||||||
|
userCredentials: [
|
||||||
|
{
|
||||||
|
username: formatSqlUsername("INFISICAL_USER_1")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
username: formatSqlUsername("INFISICAL_USER_2")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: SecretRotationType.Postgres,
|
||||||
|
name: "Postgres (10.12) Secret Rotation",
|
||||||
|
dbCredentials: {
|
||||||
|
database: "postgres-test",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
username: "postgres-test",
|
||||||
|
password: "postgres-test",
|
||||||
|
port: 5435
|
||||||
|
},
|
||||||
|
secretMapping: {
|
||||||
|
username: formatSqlUsername("POSTGRES_USERNAME"),
|
||||||
|
password: formatSqlUsername("POSTGRES_PASSWORD")
|
||||||
|
},
|
||||||
|
userCredentials: [
|
||||||
|
{
|
||||||
|
username: formatSqlUsername("INFISICAL_USER_1")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
username: formatSqlUsername("INFISICAL_USER_2")
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
] as {
|
||||||
|
skippable?: boolean;
|
||||||
|
type: SecretRotationType;
|
||||||
|
name: string;
|
||||||
|
dbCredentials: TGenericSqlCredentials;
|
||||||
|
secretMapping: TSecretMapping;
|
||||||
|
userCredentials: TDatabaseUserCredentials[];
|
||||||
|
}[];
|
||||||
|
|
||||||
|
const createAppConnectionMap = {
|
||||||
|
[SecretRotationType.OracleDb]: createOracleDBAppConnection,
|
||||||
|
[SecretRotationType.MySQL]: createMySQLAppConnection,
|
||||||
|
[SecretRotationType.Postgres]: createPostgresAppConnection
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRotationMap = {
|
||||||
|
[SecretRotationType.OracleDb]: createOracleDBSecretRotation,
|
||||||
|
[SecretRotationType.MySQL]: createMySQLSecretRotation,
|
||||||
|
[SecretRotationType.Postgres]: createPostgresSecretRotation
|
||||||
|
};
|
||||||
|
|
||||||
|
const appConnectionIds: { id: string; type: SecretRotationType }[] = [];
|
||||||
|
const secretRotationIds: { id: string; type: SecretRotationType }[] = [];
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const { id, type } of secretRotationIds) {
|
||||||
|
await deleteSecretRotation(id, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { id, type } of appConnectionIds) {
|
||||||
|
await deleteAppConnection(id, type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testCases.forEach(({ skippable, dbCredentials, secretMapping, userCredentials, type, name }) => {
|
||||||
|
const shouldSkip = () => {
|
||||||
|
if (skippable) {
|
||||||
|
if (type === SecretRotationType.OracleDb) {
|
||||||
|
if (!process.env.E2E_TEST_ORACLE_DB_19_HOST) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (shouldSkip()) {
|
||||||
|
test.skip(`Skipping Secret Rotation for ${type} (${name}) because E2E_TEST_ORACLE_DB_19_HOST is not set`);
|
||||||
|
} else {
|
||||||
|
test.concurrent(
|
||||||
|
`Create secret rotation for ${name}`,
|
||||||
|
async () => {
|
||||||
|
const appConnectionId = await createAppConnectionMap[type](dbCredentials);
|
||||||
|
|
||||||
|
if (appConnectionId) {
|
||||||
|
appConnectionIds.push({ id: appConnectionId, type });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await createRotationMap[type](appConnectionId, dbCredentials, userCredentials, secretMapping);
|
||||||
|
|
||||||
|
const resJson = JSON.parse(res.payload);
|
||||||
|
|
||||||
|
if (resJson.secretRotation) {
|
||||||
|
secretRotationIds.push({ id: resJson.secretRotation.id, type });
|
||||||
|
}
|
||||||
|
|
||||||
|
const startSecretValue = await getSecretValue(secretMapping.password);
|
||||||
|
expect(startSecretValue).toBeDefined();
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
while (attempts < 60) {
|
||||||
|
const currentSecretValue = await getSecretValue(secretMapping.password);
|
||||||
|
|
||||||
|
if (currentSecretValue !== startSecretValue) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts += 1;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2_500));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts >= 60) {
|
||||||
|
throw new Error("Secret rotation failed to rotate after 60 attempts");
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalSecretValue = await getSecretValue(secretMapping.password);
|
||||||
|
expect(finalSecretValue).not.toBe(startSecretValue);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timeout: 300_000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
@@ -18,6 +18,7 @@ import { keyStoreFactory } from "@app/keystore/keystore";
|
|||||||
import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns";
|
import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns";
|
||||||
import { buildRedisFromConfig } from "@app/lib/config/redis";
|
import { buildRedisFromConfig } from "@app/lib/config/redis";
|
||||||
import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal";
|
import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal";
|
||||||
|
import { bootstrapCheck } from "@app/server/boot-strap-check";
|
||||||
|
|
||||||
dotenv.config({ path: path.join(__dirname, "../../.env.test"), debug: true });
|
dotenv.config({ path: path.join(__dirname, "../../.env.test"), debug: true });
|
||||||
export default {
|
export default {
|
||||||
@@ -63,6 +64,8 @@ export default {
|
|||||||
const queue = queueServiceFactory(envCfg, { dbConnectionUrl: envCfg.DB_CONNECTION_URI });
|
const queue = queueServiceFactory(envCfg, { dbConnectionUrl: envCfg.DB_CONNECTION_URI });
|
||||||
const keyStore = keyStoreFactory(envCfg);
|
const keyStore = keyStoreFactory(envCfg);
|
||||||
|
|
||||||
|
await queue.initialize();
|
||||||
|
|
||||||
const hsmModule = initializeHsmModule(envCfg);
|
const hsmModule = initializeHsmModule(envCfg);
|
||||||
hsmModule.initialize();
|
hsmModule.initialize();
|
||||||
|
|
||||||
@@ -78,9 +81,13 @@ export default {
|
|||||||
envConfig: envCfg
|
envConfig: envCfg
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await bootstrapCheck({ db });
|
||||||
|
|
||||||
// @ts-expect-error type
|
// @ts-expect-error type
|
||||||
globalThis.testServer = server;
|
globalThis.testServer = server;
|
||||||
// @ts-expect-error type
|
// @ts-expect-error type
|
||||||
|
globalThis.testQueue = queue;
|
||||||
|
// @ts-expect-error type
|
||||||
globalThis.testSuperAdminDAL = superAdminDAL;
|
globalThis.testSuperAdminDAL = superAdminDAL;
|
||||||
// @ts-expect-error type
|
// @ts-expect-error type
|
||||||
globalThis.jwtAuthToken = crypto.jwt().sign(
|
globalThis.jwtAuthToken = crypto.jwt().sign(
|
||||||
@@ -105,6 +112,8 @@ export default {
|
|||||||
// custom setup
|
// custom setup
|
||||||
return {
|
return {
|
||||||
async teardown() {
|
async teardown() {
|
||||||
|
// @ts-expect-error type
|
||||||
|
await globalThis.testQueue.shutdown();
|
||||||
// @ts-expect-error type
|
// @ts-expect-error type
|
||||||
await globalThis.testServer.close();
|
await globalThis.testServer.close();
|
||||||
// @ts-expect-error type
|
// @ts-expect-error type
|
||||||
@@ -112,7 +121,9 @@ export default {
|
|||||||
// @ts-expect-error type
|
// @ts-expect-error type
|
||||||
delete globalThis.testSuperAdminDAL;
|
delete globalThis.testSuperAdminDAL;
|
||||||
// @ts-expect-error type
|
// @ts-expect-error type
|
||||||
delete globalThis.jwtToken;
|
delete globalThis.jwtAuthToken;
|
||||||
|
// @ts-expect-error type
|
||||||
|
delete globalThis.testQueue;
|
||||||
// called after all tests with this env have been run
|
// called after all tests with this env have been run
|
||||||
await db.migrate.rollback(
|
await db.migrate.rollback(
|
||||||
{
|
{
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
import { Knex } from "knex";
|
import { Knex } from "knex";
|
||||||
|
|
||||||
import { chunkArray } from "@app/lib/fn";
|
import { chunkArray } from "@app/lib/fn";
|
||||||
import { logger } from "@app/lib/logger";
|
import { initLogger, logger } from "@app/lib/logger";
|
||||||
|
|
||||||
import { TableName } from "../schemas";
|
import { TableName } from "../schemas";
|
||||||
import { TReminders, TRemindersInsert } from "../schemas/reminders";
|
import { TReminders, TRemindersInsert } from "../schemas/reminders";
|
||||||
@@ -107,5 +107,6 @@ export async function up(knex: Knex): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function down(): Promise<void> {
|
export async function down(): Promise<void> {
|
||||||
|
initLogger();
|
||||||
logger.info("Rollback not implemented for secret reminders fix migration");
|
logger.info("Rollback not implemented for secret reminders fix migration");
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,19 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "../schemas/models";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
if (!(await knex.schema.hasColumn(TableName.AccessApprovalPolicy, "maxTimePeriod"))) {
|
||||||
|
await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => {
|
||||||
|
t.string("maxTimePeriod").nullable(); // Ex: 1h - Null is permanent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
if (await knex.schema.hasColumn(TableName.AccessApprovalPolicy, "maxTimePeriod")) {
|
||||||
|
await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => {
|
||||||
|
t.dropColumn("maxTimePeriod");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,38 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "@app/db/schemas";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
const hasEditNoteCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editNote");
|
||||||
|
const hasEditedByUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editedByUserId");
|
||||||
|
|
||||||
|
if (!hasEditNoteCol || !hasEditedByUserId) {
|
||||||
|
await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => {
|
||||||
|
if (!hasEditedByUserId) {
|
||||||
|
t.uuid("editedByUserId").nullable();
|
||||||
|
t.foreign("editedByUserId").references("id").inTable(TableName.Users).onDelete("SET NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasEditNoteCol) {
|
||||||
|
t.string("editNote").nullable();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
const hasEditNoteCol = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editNote");
|
||||||
|
const hasEditedByUserId = await knex.schema.hasColumn(TableName.AccessApprovalRequest, "editedByUserId");
|
||||||
|
|
||||||
|
if (hasEditNoteCol || hasEditedByUserId) {
|
||||||
|
await knex.schema.alterTable(TableName.AccessApprovalRequest, (t) => {
|
||||||
|
if (hasEditedByUserId) {
|
||||||
|
t.dropColumn("editedByUserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasEditNoteCol) {
|
||||||
|
t.dropColumn("editNote");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@@ -17,7 +17,8 @@ export const AccessApprovalPoliciesSchema = z.object({
|
|||||||
updatedAt: z.date(),
|
updatedAt: z.date(),
|
||||||
enforcementLevel: z.string().default("hard"),
|
enforcementLevel: z.string().default("hard"),
|
||||||
deletedAt: z.date().nullable().optional(),
|
deletedAt: z.date().nullable().optional(),
|
||||||
allowedSelfApprovals: z.boolean().default(true)
|
allowedSelfApprovals: z.boolean().default(true),
|
||||||
|
maxTimePeriod: z.string().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TAccessApprovalPolicies = z.infer<typeof AccessApprovalPoliciesSchema>;
|
export type TAccessApprovalPolicies = z.infer<typeof AccessApprovalPoliciesSchema>;
|
||||||
|
@@ -20,7 +20,9 @@ export const AccessApprovalRequestsSchema = z.object({
|
|||||||
requestedByUserId: z.string().uuid(),
|
requestedByUserId: z.string().uuid(),
|
||||||
note: z.string().nullable().optional(),
|
note: z.string().nullable().optional(),
|
||||||
privilegeDeletedAt: z.date().nullable().optional(),
|
privilegeDeletedAt: z.date().nullable().optional(),
|
||||||
status: z.string().default("pending")
|
status: z.string().default("pending"),
|
||||||
|
editedByUserId: z.string().uuid().nullable().optional(),
|
||||||
|
editNote: z.string().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TAccessApprovalRequests = z.infer<typeof AccessApprovalRequestsSchema>;
|
export type TAccessApprovalRequests = z.infer<typeof AccessApprovalRequestsSchema>;
|
||||||
|
@@ -3,12 +3,32 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { ApproverType, BypasserType } from "@app/ee/services/access-approval-policy/access-approval-policy-types";
|
import { ApproverType, BypasserType } from "@app/ee/services/access-approval-policy/access-approval-policy-types";
|
||||||
import { removeTrailingSlash } from "@app/lib/fn";
|
import { removeTrailingSlash } from "@app/lib/fn";
|
||||||
|
import { ms } from "@app/lib/ms";
|
||||||
import { EnforcementLevel } from "@app/lib/types";
|
import { EnforcementLevel } from "@app/lib/types";
|
||||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
import { sapPubSchema } from "@app/server/routes/sanitizedSchemas";
|
import { sapPubSchema } from "@app/server/routes/sanitizedSchemas";
|
||||||
import { AuthMode } from "@app/services/auth/auth-type";
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
|
|
||||||
|
const maxTimePeriodSchema = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.nullish()
|
||||||
|
.transform((val, ctx) => {
|
||||||
|
if (val === undefined) return undefined;
|
||||||
|
if (!val || val === "permanent") return null;
|
||||||
|
const parsedMs = ms(val);
|
||||||
|
|
||||||
|
if (typeof parsedMs !== "number" || parsedMs <= 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')."
|
||||||
|
});
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
|
||||||
export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvider) => {
|
export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvider) => {
|
||||||
server.route({
|
server.route({
|
||||||
url: "/",
|
url: "/",
|
||||||
@@ -71,7 +91,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
|
|||||||
.optional(),
|
.optional(),
|
||||||
approvals: z.number().min(1).default(1),
|
approvals: z.number().min(1).default(1),
|
||||||
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
|
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
|
||||||
allowedSelfApprovals: z.boolean().default(true)
|
allowedSelfApprovals: z.boolean().default(true),
|
||||||
|
maxTimePeriod: maxTimePeriodSchema
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(val) => Boolean(val.environment) || Boolean(val.environments),
|
(val) => Boolean(val.environment) || Boolean(val.environments),
|
||||||
@@ -124,7 +145,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
|
|||||||
.array()
|
.array()
|
||||||
.nullable()
|
.nullable()
|
||||||
.optional(),
|
.optional(),
|
||||||
bypassers: z.object({ type: z.nativeEnum(BypasserType), id: z.string().nullable().optional() }).array()
|
bypassers: z.object({ type: z.nativeEnum(BypasserType), id: z.string().nullable().optional() }).array(),
|
||||||
|
maxTimePeriod: z.string().nullable().optional()
|
||||||
})
|
})
|
||||||
.array()
|
.array()
|
||||||
.nullable()
|
.nullable()
|
||||||
@@ -233,7 +255,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
|
|||||||
stepNumber: z.number().int()
|
stepNumber: z.number().int()
|
||||||
})
|
})
|
||||||
.array()
|
.array()
|
||||||
.optional()
|
.optional(),
|
||||||
|
maxTimePeriod: maxTimePeriodSchema
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
@@ -314,7 +337,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
|
|||||||
})
|
})
|
||||||
.array()
|
.array()
|
||||||
.nullable()
|
.nullable()
|
||||||
.optional()
|
.optional(),
|
||||||
|
maxTimePeriod: z.string().nullable().optional()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema, UsersSchema } from "@app/db/schemas";
|
import { AccessApprovalRequestsReviewersSchema, AccessApprovalRequestsSchema, UsersSchema } from "@app/db/schemas";
|
||||||
import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types";
|
import { ApprovalStatus } from "@app/ee/services/access-approval-request/access-approval-request-types";
|
||||||
|
import { ms } from "@app/lib/ms";
|
||||||
import { writeLimit } from "@app/server/config/rateLimiter";
|
import { writeLimit } 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";
|
||||||
@@ -26,7 +27,23 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
|
|||||||
body: z.object({
|
body: z.object({
|
||||||
permissions: z.any().array(),
|
permissions: z.any().array(),
|
||||||
isTemporary: z.boolean(),
|
isTemporary: z.boolean(),
|
||||||
temporaryRange: z.string().optional(),
|
temporaryRange: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((val, ctx) => {
|
||||||
|
if (!val || val === "permanent") return undefined;
|
||||||
|
|
||||||
|
const parsedMs = ms(val);
|
||||||
|
|
||||||
|
if (typeof parsedMs !== "number" || parsedMs <= 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')."
|
||||||
|
});
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}),
|
||||||
note: z.string().max(255).optional()
|
note: z.string().max(255).optional()
|
||||||
}),
|
}),
|
||||||
querystring: z.object({
|
querystring: z.object({
|
||||||
@@ -128,7 +145,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
|
|||||||
envId: z.string(),
|
envId: z.string(),
|
||||||
enforcementLevel: z.string(),
|
enforcementLevel: z.string(),
|
||||||
deletedAt: z.date().nullish(),
|
deletedAt: z.date().nullish(),
|
||||||
allowedSelfApprovals: z.boolean()
|
allowedSelfApprovals: z.boolean(),
|
||||||
|
maxTimePeriod: z.string().nullable().optional()
|
||||||
}),
|
}),
|
||||||
reviewers: z
|
reviewers: z
|
||||||
.object({
|
.object({
|
||||||
@@ -189,4 +207,47 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
|
|||||||
return { review };
|
return { review };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
url: "/:requestId",
|
||||||
|
method: "PATCH",
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
requestId: z.string().trim()
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
temporaryRange: z.string().transform((val, ctx) => {
|
||||||
|
const parsedMs = ms(val);
|
||||||
|
|
||||||
|
if (typeof parsedMs !== "number" || parsedMs <= 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Invalid time period format or value. Must be a positive duration (e.g., '1h', '30m', '2d')."
|
||||||
|
});
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}),
|
||||||
|
editNote: z.string().max(255)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
approval: AccessApprovalRequestsSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { request } = await server.services.accessApprovalRequest.updateAccessApprovalRequest({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
temporaryRange: req.body.temporaryRange,
|
||||||
|
editNote: req.body.editNote,
|
||||||
|
requestId: req.params.requestId
|
||||||
|
});
|
||||||
|
return { approval: request };
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
@@ -56,6 +56,7 @@ export interface TAccessApprovalPolicyDALFactory
|
|||||||
allowedSelfApprovals: boolean;
|
allowedSelfApprovals: boolean;
|
||||||
secretPath: string;
|
secretPath: string;
|
||||||
deletedAt?: Date | null | undefined;
|
deletedAt?: Date | null | undefined;
|
||||||
|
maxTimePeriod?: string | null;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
bypassers: (
|
bypassers: (
|
||||||
| {
|
| {
|
||||||
@@ -96,6 +97,7 @@ export interface TAccessApprovalPolicyDALFactory
|
|||||||
allowedSelfApprovals: boolean;
|
allowedSelfApprovals: boolean;
|
||||||
secretPath: string;
|
secretPath: string;
|
||||||
deletedAt?: Date | null | undefined;
|
deletedAt?: Date | null | undefined;
|
||||||
|
maxTimePeriod?: string | null;
|
||||||
environments: {
|
environments: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -141,6 +143,7 @@ export interface TAccessApprovalPolicyDALFactory
|
|||||||
allowedSelfApprovals: boolean;
|
allowedSelfApprovals: boolean;
|
||||||
secretPath: string;
|
secretPath: string;
|
||||||
deletedAt?: Date | null | undefined;
|
deletedAt?: Date | null | undefined;
|
||||||
|
maxTimePeriod?: string | null;
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
>;
|
>;
|
||||||
|
@@ -100,7 +100,8 @@ export const accessApprovalPolicyServiceFactory = ({
|
|||||||
environments,
|
environments,
|
||||||
enforcementLevel,
|
enforcementLevel,
|
||||||
allowedSelfApprovals,
|
allowedSelfApprovals,
|
||||||
approvalsRequired
|
approvalsRequired,
|
||||||
|
maxTimePeriod
|
||||||
}) => {
|
}) => {
|
||||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
|
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
|
||||||
@@ -219,7 +220,8 @@ export const accessApprovalPolicyServiceFactory = ({
|
|||||||
secretPath,
|
secretPath,
|
||||||
name,
|
name,
|
||||||
enforcementLevel,
|
enforcementLevel,
|
||||||
allowedSelfApprovals
|
allowedSelfApprovals,
|
||||||
|
maxTimePeriod
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
@@ -318,7 +320,8 @@ export const accessApprovalPolicyServiceFactory = ({
|
|||||||
enforcementLevel,
|
enforcementLevel,
|
||||||
allowedSelfApprovals,
|
allowedSelfApprovals,
|
||||||
approvalsRequired,
|
approvalsRequired,
|
||||||
environments
|
environments,
|
||||||
|
maxTimePeriod
|
||||||
}: TUpdateAccessApprovalPolicy) => {
|
}: TUpdateAccessApprovalPolicy) => {
|
||||||
const groupApprovers = approvers.filter((approver) => approver.type === ApproverType.Group);
|
const groupApprovers = approvers.filter((approver) => approver.type === ApproverType.Group);
|
||||||
|
|
||||||
@@ -461,7 +464,8 @@ export const accessApprovalPolicyServiceFactory = ({
|
|||||||
secretPath,
|
secretPath,
|
||||||
name,
|
name,
|
||||||
enforcementLevel,
|
enforcementLevel,
|
||||||
allowedSelfApprovals
|
allowedSelfApprovals,
|
||||||
|
maxTimePeriod
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
|
@@ -41,6 +41,7 @@ export type TCreateAccessApprovalPolicy = {
|
|||||||
enforcementLevel: EnforcementLevel;
|
enforcementLevel: EnforcementLevel;
|
||||||
allowedSelfApprovals: boolean;
|
allowedSelfApprovals: boolean;
|
||||||
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
|
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
|
||||||
|
maxTimePeriod?: string | null;
|
||||||
} & Omit<TProjectPermission, "projectId">;
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
export type TUpdateAccessApprovalPolicy = {
|
export type TUpdateAccessApprovalPolicy = {
|
||||||
@@ -60,6 +61,7 @@ export type TUpdateAccessApprovalPolicy = {
|
|||||||
allowedSelfApprovals: boolean;
|
allowedSelfApprovals: boolean;
|
||||||
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
|
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
|
||||||
environments?: string[];
|
environments?: string[];
|
||||||
|
maxTimePeriod?: string | null;
|
||||||
} & Omit<TProjectPermission, "projectId">;
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
export type TDeleteAccessApprovalPolicy = {
|
export type TDeleteAccessApprovalPolicy = {
|
||||||
@@ -104,7 +106,8 @@ export interface TAccessApprovalPolicyServiceFactory {
|
|||||||
environment,
|
environment,
|
||||||
enforcementLevel,
|
enforcementLevel,
|
||||||
allowedSelfApprovals,
|
allowedSelfApprovals,
|
||||||
approvalsRequired
|
approvalsRequired,
|
||||||
|
maxTimePeriod
|
||||||
}: TCreateAccessApprovalPolicy) => Promise<{
|
}: TCreateAccessApprovalPolicy) => Promise<{
|
||||||
environment: {
|
environment: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -135,6 +138,7 @@ export interface TAccessApprovalPolicyServiceFactory {
|
|||||||
allowedSelfApprovals: boolean;
|
allowedSelfApprovals: boolean;
|
||||||
secretPath: string;
|
secretPath: string;
|
||||||
deletedAt?: Date | null | undefined;
|
deletedAt?: Date | null | undefined;
|
||||||
|
maxTimePeriod?: string | null;
|
||||||
}>;
|
}>;
|
||||||
deleteAccessApprovalPolicy: ({
|
deleteAccessApprovalPolicy: ({
|
||||||
policyId,
|
policyId,
|
||||||
@@ -159,6 +163,7 @@ export interface TAccessApprovalPolicyServiceFactory {
|
|||||||
allowedSelfApprovals: boolean;
|
allowedSelfApprovals: boolean;
|
||||||
secretPath: string;
|
secretPath: string;
|
||||||
deletedAt?: Date | null | undefined;
|
deletedAt?: Date | null | undefined;
|
||||||
|
maxTimePeriod?: string | null;
|
||||||
environment: {
|
environment: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -185,7 +190,8 @@ export interface TAccessApprovalPolicyServiceFactory {
|
|||||||
enforcementLevel,
|
enforcementLevel,
|
||||||
allowedSelfApprovals,
|
allowedSelfApprovals,
|
||||||
approvalsRequired,
|
approvalsRequired,
|
||||||
environments
|
environments,
|
||||||
|
maxTimePeriod
|
||||||
}: TUpdateAccessApprovalPolicy) => Promise<{
|
}: TUpdateAccessApprovalPolicy) => Promise<{
|
||||||
environment: {
|
environment: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -208,6 +214,7 @@ export interface TAccessApprovalPolicyServiceFactory {
|
|||||||
allowedSelfApprovals: boolean;
|
allowedSelfApprovals: boolean;
|
||||||
secretPath?: string | null | undefined;
|
secretPath?: string | null | undefined;
|
||||||
deletedAt?: Date | null | undefined;
|
deletedAt?: Date | null | undefined;
|
||||||
|
maxTimePeriod?: string | null;
|
||||||
}>;
|
}>;
|
||||||
getAccessApprovalPolicyByProjectSlug: ({
|
getAccessApprovalPolicyByProjectSlug: ({
|
||||||
actorId,
|
actorId,
|
||||||
@@ -242,6 +249,7 @@ export interface TAccessApprovalPolicyServiceFactory {
|
|||||||
allowedSelfApprovals: boolean;
|
allowedSelfApprovals: boolean;
|
||||||
secretPath: string;
|
secretPath: string;
|
||||||
deletedAt?: Date | null | undefined;
|
deletedAt?: Date | null | undefined;
|
||||||
|
maxTimePeriod?: string | null;
|
||||||
environment: {
|
environment: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -298,6 +306,7 @@ export interface TAccessApprovalPolicyServiceFactory {
|
|||||||
allowedSelfApprovals: boolean;
|
allowedSelfApprovals: boolean;
|
||||||
secretPath: string;
|
secretPath: string;
|
||||||
deletedAt?: Date | null | undefined;
|
deletedAt?: Date | null | undefined;
|
||||||
|
maxTimePeriod?: string | null;
|
||||||
environment: {
|
environment: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
@@ -63,6 +63,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
|
|||||||
enforcementLevel: string;
|
enforcementLevel: string;
|
||||||
allowedSelfApprovals: boolean;
|
allowedSelfApprovals: boolean;
|
||||||
deletedAt: Date | null | undefined;
|
deletedAt: Date | null | undefined;
|
||||||
|
maxTimePeriod?: string | null;
|
||||||
};
|
};
|
||||||
projectId: string;
|
projectId: string;
|
||||||
environments: string[];
|
environments: string[];
|
||||||
@@ -161,6 +162,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
|
|||||||
allowedSelfApprovals: boolean;
|
allowedSelfApprovals: boolean;
|
||||||
envId: string;
|
envId: string;
|
||||||
deletedAt: Date | null | undefined;
|
deletedAt: Date | null | undefined;
|
||||||
|
maxTimePeriod?: string | null;
|
||||||
};
|
};
|
||||||
projectId: string;
|
projectId: string;
|
||||||
environment: string;
|
environment: string;
|
||||||
@@ -297,7 +299,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
|
|||||||
db.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"),
|
db.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"),
|
||||||
db.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"),
|
db.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"),
|
||||||
db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"),
|
db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"),
|
||||||
db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt")
|
db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"),
|
||||||
|
db.ref("maxTimePeriod").withSchema(TableName.AccessApprovalPolicy).as("policyMaxTimePeriod")
|
||||||
)
|
)
|
||||||
.select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover))
|
.select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover))
|
||||||
.select(db.ref("sequence").withSchema(TableName.AccessApprovalPolicyApprover).as("approverSequence"))
|
.select(db.ref("sequence").withSchema(TableName.AccessApprovalPolicyApprover).as("approverSequence"))
|
||||||
@@ -364,7 +367,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
|
|||||||
enforcementLevel: doc.policyEnforcementLevel,
|
enforcementLevel: doc.policyEnforcementLevel,
|
||||||
allowedSelfApprovals: doc.policyAllowedSelfApprovals,
|
allowedSelfApprovals: doc.policyAllowedSelfApprovals,
|
||||||
envId: doc.policyEnvId,
|
envId: doc.policyEnvId,
|
||||||
deletedAt: doc.policyDeletedAt
|
deletedAt: doc.policyDeletedAt,
|
||||||
|
maxTimePeriod: doc.policyMaxTimePeriod
|
||||||
},
|
},
|
||||||
requestedByUser: {
|
requestedByUser: {
|
||||||
userId: doc.requestedByUserId,
|
userId: doc.requestedByUserId,
|
||||||
@@ -574,7 +578,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
|
|||||||
tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"),
|
tx.ref("enforcementLevel").withSchema(TableName.AccessApprovalPolicy).as("policyEnforcementLevel"),
|
||||||
tx.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"),
|
tx.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"),
|
||||||
tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"),
|
tx.ref("approvals").withSchema(TableName.AccessApprovalPolicy).as("policyApprovals"),
|
||||||
tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt")
|
tx.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"),
|
||||||
|
tx.ref("maxTimePeriod").withSchema(TableName.AccessApprovalPolicy).as("policyMaxTimePeriod")
|
||||||
);
|
);
|
||||||
|
|
||||||
const findById: TAccessApprovalRequestDALFactory["findById"] = async (id, tx) => {
|
const findById: TAccessApprovalRequestDALFactory["findById"] = async (id, tx) => {
|
||||||
@@ -595,7 +600,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
|
|||||||
secretPath: el.policySecretPath,
|
secretPath: el.policySecretPath,
|
||||||
enforcementLevel: el.policyEnforcementLevel,
|
enforcementLevel: el.policyEnforcementLevel,
|
||||||
allowedSelfApprovals: el.policyAllowedSelfApprovals,
|
allowedSelfApprovals: el.policyAllowedSelfApprovals,
|
||||||
deletedAt: el.policyDeletedAt
|
deletedAt: el.policyDeletedAt,
|
||||||
|
maxTimePeriod: el.policyMaxTimePeriod
|
||||||
},
|
},
|
||||||
requestedByUser: {
|
requestedByUser: {
|
||||||
userId: el.requestedByUserId,
|
userId: el.requestedByUserId,
|
||||||
|
@@ -54,7 +54,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
|
|||||||
accessApprovalPolicyDAL: Pick<TAccessApprovalPolicyDALFactory, "findOne" | "find" | "findLastValidPolicy">;
|
accessApprovalPolicyDAL: Pick<TAccessApprovalPolicyDALFactory, "findOne" | "find" | "findLastValidPolicy">;
|
||||||
accessApprovalRequestReviewerDAL: Pick<
|
accessApprovalRequestReviewerDAL: Pick<
|
||||||
TAccessApprovalRequestReviewerDALFactory,
|
TAccessApprovalRequestReviewerDALFactory,
|
||||||
"create" | "find" | "findOne" | "transaction"
|
"create" | "find" | "findOne" | "transaction" | "delete"
|
||||||
>;
|
>;
|
||||||
groupDAL: Pick<TGroupDALFactory, "findAllGroupPossibleMembers">;
|
groupDAL: Pick<TGroupDALFactory, "findAllGroupPossibleMembers">;
|
||||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findById">;
|
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findById">;
|
||||||
@@ -156,6 +156,15 @@ export const accessApprovalRequestServiceFactory = ({
|
|||||||
throw new BadRequestError({ message: "The policy linked to this request has been deleted" });
|
throw new BadRequestError({ message: "The policy linked to this request has been deleted" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if the requested time falls under policy.maxTimePeriod
|
||||||
|
if (policy.maxTimePeriod) {
|
||||||
|
if (!temporaryRange || ms(temporaryRange) > ms(policy.maxTimePeriod)) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Requested access time range is limited to ${policy.maxTimePeriod} by policy`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const approverIds: string[] = [];
|
const approverIds: string[] = [];
|
||||||
const approverGroupIds: string[] = [];
|
const approverGroupIds: string[] = [];
|
||||||
|
|
||||||
@@ -292,6 +301,155 @@ export const accessApprovalRequestServiceFactory = ({
|
|||||||
return { request: approval };
|
return { request: approval };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateAccessApprovalRequest: TAccessApprovalRequestServiceFactory["updateAccessApprovalRequest"] = async ({
|
||||||
|
temporaryRange,
|
||||||
|
actorId,
|
||||||
|
actor,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
editNote,
|
||||||
|
requestId
|
||||||
|
}) => {
|
||||||
|
const cfg = getConfig();
|
||||||
|
|
||||||
|
const accessApprovalRequest = await accessApprovalRequestDAL.findById(requestId);
|
||||||
|
if (!accessApprovalRequest) {
|
||||||
|
throw new NotFoundError({ message: `Access request with ID '${requestId}' not found` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { policy, requestedByUser } = accessApprovalRequest;
|
||||||
|
if (policy.deletedAt) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "The policy associated with this access request has been deleted."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { membership, hasRole } = await permissionService.getProjectPermission({
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectId: accessApprovalRequest.projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
actionProjectType: ActionProjectType.SecretManager
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!membership) {
|
||||||
|
throw new ForbiddenRequestError({ message: "You are not a member of this project" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isApprover = policy.approvers.find((approver) => approver.userId === actorId);
|
||||||
|
|
||||||
|
if (!hasRole(ProjectMembershipRole.Admin) && !isApprover) {
|
||||||
|
throw new ForbiddenRequestError({ message: "You are not authorized to modify this request" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await projectDAL.findById(accessApprovalRequest.projectId);
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `The project associated with this access request was not found. [projectId=${accessApprovalRequest.projectId}]`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accessApprovalRequest.status !== ApprovalStatus.PENDING) {
|
||||||
|
throw new BadRequestError({ message: "The request has been closed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const editedByUser = await userDAL.findById(actorId);
|
||||||
|
|
||||||
|
if (!editedByUser) throw new NotFoundError({ message: "Editing user not found" });
|
||||||
|
|
||||||
|
if (accessApprovalRequest.isTemporary && accessApprovalRequest.temporaryRange) {
|
||||||
|
if (ms(temporaryRange) > ms(accessApprovalRequest.temporaryRange)) {
|
||||||
|
throw new BadRequestError({ message: "Updated access duration must be less than current access duration" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { envSlug, secretPath, accessTypes } = verifyRequestedPermissions({
|
||||||
|
permissions: accessApprovalRequest.permissions
|
||||||
|
});
|
||||||
|
|
||||||
|
const approval = await accessApprovalRequestDAL.transaction(async (tx) => {
|
||||||
|
const approvalRequest = await accessApprovalRequestDAL.updateById(
|
||||||
|
requestId,
|
||||||
|
{
|
||||||
|
temporaryRange,
|
||||||
|
isTemporary: true,
|
||||||
|
editNote,
|
||||||
|
editedByUserId: actorId
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
// reset review progress
|
||||||
|
await accessApprovalRequestReviewerDAL.delete(
|
||||||
|
{
|
||||||
|
requestId
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
|
||||||
|
const editorFullName = `${editedByUser.firstName} ${editedByUser.lastName}`;
|
||||||
|
const approvalUrl = `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval`;
|
||||||
|
|
||||||
|
await triggerWorkflowIntegrationNotification({
|
||||||
|
input: {
|
||||||
|
notification: {
|
||||||
|
type: TriggerFeature.ACCESS_REQUEST_UPDATED,
|
||||||
|
payload: {
|
||||||
|
projectName: project.name,
|
||||||
|
requesterFullName,
|
||||||
|
isTemporary: true,
|
||||||
|
requesterEmail: requestedByUser.email as string,
|
||||||
|
secretPath,
|
||||||
|
environment: envSlug,
|
||||||
|
permissions: accessTypes,
|
||||||
|
approvalUrl,
|
||||||
|
editNote,
|
||||||
|
editorEmail: editedByUser.email as string,
|
||||||
|
editorFullName
|
||||||
|
}
|
||||||
|
},
|
||||||
|
projectId: project.id
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
projectDAL,
|
||||||
|
projectSlackConfigDAL,
|
||||||
|
kmsService,
|
||||||
|
microsoftTeamsService,
|
||||||
|
projectMicrosoftTeamsConfigDAL
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await smtpService.sendMail({
|
||||||
|
recipients: policy.approvers
|
||||||
|
.filter((approver) => Boolean(approver.email) && approver.userId !== editedByUser.id)
|
||||||
|
.map((approver) => approver.email!),
|
||||||
|
subjectLine: "Access Approval Request Updated",
|
||||||
|
substitutions: {
|
||||||
|
projectName: project.name,
|
||||||
|
requesterFullName,
|
||||||
|
requesterEmail: requestedByUser.email,
|
||||||
|
isTemporary: true,
|
||||||
|
expiresIn: msFn(ms(temporaryRange || ""), { long: true }),
|
||||||
|
secretPath,
|
||||||
|
environment: envSlug,
|
||||||
|
permissions: accessTypes,
|
||||||
|
approvalUrl,
|
||||||
|
editNote,
|
||||||
|
editorFullName,
|
||||||
|
editorEmail: editedByUser.email
|
||||||
|
},
|
||||||
|
template: SmtpTemplates.AccessApprovalRequestUpdated
|
||||||
|
});
|
||||||
|
|
||||||
|
return approvalRequest;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { request: approval };
|
||||||
|
};
|
||||||
|
|
||||||
const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({
|
const listApprovalRequests: TAccessApprovalRequestServiceFactory["listApprovalRequests"] = async ({
|
||||||
projectSlug,
|
projectSlug,
|
||||||
authorUserId,
|
authorUserId,
|
||||||
@@ -641,6 +799,7 @@ export const accessApprovalRequestServiceFactory = ({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
createAccessApprovalRequest,
|
createAccessApprovalRequest,
|
||||||
|
updateAccessApprovalRequest,
|
||||||
listApprovalRequests,
|
listApprovalRequests,
|
||||||
reviewAccessRequest,
|
reviewAccessRequest,
|
||||||
getCount
|
getCount
|
||||||
|
@@ -30,6 +30,12 @@ export type TCreateAccessApprovalRequestDTO = {
|
|||||||
note?: string;
|
note?: string;
|
||||||
} & Omit<TProjectPermission, "projectId">;
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
|
export type TUpdateAccessApprovalRequestDTO = {
|
||||||
|
requestId: string;
|
||||||
|
temporaryRange: string;
|
||||||
|
editNote: string;
|
||||||
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
export type TListApprovalRequestsDTO = {
|
export type TListApprovalRequestsDTO = {
|
||||||
projectSlug: string;
|
projectSlug: string;
|
||||||
authorUserId?: string;
|
authorUserId?: string;
|
||||||
@@ -54,6 +60,23 @@ export interface TAccessApprovalRequestServiceFactory {
|
|||||||
privilegeDeletedAt?: Date | null | undefined;
|
privilegeDeletedAt?: Date | null | undefined;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
updateAccessApprovalRequest: (arg: TUpdateAccessApprovalRequestDTO) => Promise<{
|
||||||
|
request: {
|
||||||
|
status: string;
|
||||||
|
id: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
policyId: string;
|
||||||
|
isTemporary: boolean;
|
||||||
|
requestedByUserId: string;
|
||||||
|
privilegeId?: string | null | undefined;
|
||||||
|
requestedBy?: string | null | undefined;
|
||||||
|
temporaryRange?: string | null | undefined;
|
||||||
|
permissions?: unknown;
|
||||||
|
note?: string | null | undefined;
|
||||||
|
privilegeDeletedAt?: Date | null | undefined;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
listApprovalRequests: (arg: TListApprovalRequestsDTO) => Promise<{
|
listApprovalRequests: (arg: TListApprovalRequestsDTO) => Promise<{
|
||||||
requests: {
|
requests: {
|
||||||
policy: {
|
policy: {
|
||||||
@@ -82,6 +105,7 @@ export interface TAccessApprovalRequestServiceFactory {
|
|||||||
allowedSelfApprovals: boolean;
|
allowedSelfApprovals: boolean;
|
||||||
envId: string;
|
envId: string;
|
||||||
deletedAt: Date | null | undefined;
|
deletedAt: Date | null | undefined;
|
||||||
|
maxTimePeriod?: string | null;
|
||||||
};
|
};
|
||||||
projectId: string;
|
projectId: string;
|
||||||
environment: string;
|
environment: string;
|
||||||
|
@@ -1,8 +1,6 @@
|
|||||||
import { AxiosError, RawAxiosRequestHeaders } from "axios";
|
import { AxiosError, RawAxiosRequestHeaders } from "axios";
|
||||||
|
|
||||||
import { ProjectType, SecretKeyEncoding } from "@app/db/schemas";
|
import { SecretKeyEncoding } from "@app/db/schemas";
|
||||||
import { TEventBusService } from "@app/ee/services/event/event-bus-service";
|
|
||||||
import { TopicName, toPublishableEvent } from "@app/ee/services/event/types";
|
|
||||||
import { request } from "@app/lib/config/request";
|
import { request } from "@app/lib/config/request";
|
||||||
import { crypto } from "@app/lib/crypto/cryptography";
|
import { crypto } from "@app/lib/crypto/cryptography";
|
||||||
import { logger } from "@app/lib/logger";
|
import { logger } from "@app/lib/logger";
|
||||||
@@ -22,7 +20,6 @@ type TAuditLogQueueServiceFactoryDep = {
|
|||||||
queueService: TQueueServiceFactory;
|
queueService: TQueueServiceFactory;
|
||||||
projectDAL: Pick<TProjectDALFactory, "findById">;
|
projectDAL: Pick<TProjectDALFactory, "findById">;
|
||||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||||
eventBusService: TEventBusService;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TAuditLogQueueServiceFactory = {
|
export type TAuditLogQueueServiceFactory = {
|
||||||
@@ -38,8 +35,7 @@ export const auditLogQueueServiceFactory = async ({
|
|||||||
queueService,
|
queueService,
|
||||||
projectDAL,
|
projectDAL,
|
||||||
licenseService,
|
licenseService,
|
||||||
auditLogStreamDAL,
|
auditLogStreamDAL
|
||||||
eventBusService
|
|
||||||
}: TAuditLogQueueServiceFactoryDep): Promise<TAuditLogQueueServiceFactory> => {
|
}: TAuditLogQueueServiceFactoryDep): Promise<TAuditLogQueueServiceFactory> => {
|
||||||
const pushToLog = async (data: TCreateAuditLogDTO) => {
|
const pushToLog = async (data: TCreateAuditLogDTO) => {
|
||||||
await queueService.queue<QueueName.AuditLog>(QueueName.AuditLog, QueueJobs.AuditLog, data, {
|
await queueService.queue<QueueName.AuditLog>(QueueName.AuditLog, QueueJobs.AuditLog, data, {
|
||||||
@@ -145,16 +141,6 @@ export const auditLogQueueServiceFactory = async ({
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const publishable = toPublishableEvent(event);
|
|
||||||
|
|
||||||
if (publishable) {
|
|
||||||
await eventBusService.publish(TopicName.CoreServers, {
|
|
||||||
type: ProjectType.SecretManager,
|
|
||||||
source: "infiscal",
|
|
||||||
data: publishable.data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@@ -9,7 +9,7 @@ import { getDbConnectionHost } from "@app/lib/knex";
|
|||||||
export const verifyHostInputValidity = async (host: string, isGateway = false) => {
|
export const verifyHostInputValidity = async (host: string, isGateway = false) => {
|
||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
|
|
||||||
if (appCfg.isDevelopmentMode) return [host];
|
if (appCfg.isDevelopmentMode || appCfg.isTestMode) return [host];
|
||||||
|
|
||||||
if (isGateway) return [host];
|
if (isGateway) return [host];
|
||||||
|
|
||||||
|
289
backend/src/ee/services/dynamic-secret/providers/couchbase.ts
Normal file
289
backend/src/ee/services/dynamic-secret/providers/couchbase.ts
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import RE2 from "re2";
|
||||||
|
|
||||||
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
|
import { sanitizeString } from "@app/lib/fn";
|
||||||
|
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||||
|
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator/validate-url";
|
||||||
|
|
||||||
|
import { DynamicSecretCouchbaseSchema, PasswordRequirements, TDynamicProviderFns } from "./models";
|
||||||
|
import { compileUsernameTemplate } from "./templateUtils";
|
||||||
|
|
||||||
|
type TCreateCouchbaseUser = {
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
access: {
|
||||||
|
privileges: string[];
|
||||||
|
resources: {
|
||||||
|
buckets: {
|
||||||
|
name: string;
|
||||||
|
scopes?: {
|
||||||
|
name: string;
|
||||||
|
collections?: string[];
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CouchbaseUserResponse = {
|
||||||
|
id: string;
|
||||||
|
uuid?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeCouchbaseUsername = (username: string): string => {
|
||||||
|
// Couchbase username restrictions:
|
||||||
|
// - Cannot contain: ) ( > < , ; : " \ / ] [ ? = } {
|
||||||
|
// - Cannot begin with @ character
|
||||||
|
|
||||||
|
const forbiddenCharsPattern = new RE2('[\\)\\(><,;:"\\\\\\[\\]\\?=\\}\\{]', "g");
|
||||||
|
let sanitized = forbiddenCharsPattern.replace(username, "-");
|
||||||
|
|
||||||
|
const leadingAtPattern = new RE2("^@+");
|
||||||
|
sanitized = leadingAtPattern.replace(sanitized, "");
|
||||||
|
|
||||||
|
if (!sanitized || sanitized.length === 0) {
|
||||||
|
return alphaNumericNanoId(12);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes bucket configuration to handle wildcard (*) access consistently.
|
||||||
|
*
|
||||||
|
* Key behaviors:
|
||||||
|
* - If "*" appears anywhere (string or array), grants access to ALL buckets, scopes, and collections
|
||||||
|
*
|
||||||
|
* @param buckets - Either a string or array of bucket configurations
|
||||||
|
* @returns Normalized bucket resources for Couchbase API
|
||||||
|
*/
|
||||||
|
const normalizeBucketConfiguration = (
|
||||||
|
buckets:
|
||||||
|
| string
|
||||||
|
| Array<{
|
||||||
|
name: string;
|
||||||
|
scopes?: Array<{
|
||||||
|
name: string;
|
||||||
|
collections?: string[];
|
||||||
|
}>;
|
||||||
|
}>
|
||||||
|
) => {
|
||||||
|
if (typeof buckets === "string") {
|
||||||
|
// Simple string format - either "*" or comma-separated bucket names
|
||||||
|
const bucketNames = buckets
|
||||||
|
.split(",")
|
||||||
|
.map((bucket) => bucket.trim())
|
||||||
|
.filter((bucket) => bucket.length > 0);
|
||||||
|
|
||||||
|
// If "*" is present anywhere, grant access to all buckets, scopes, and collections
|
||||||
|
if (bucketNames.includes("*") || buckets === "*") {
|
||||||
|
return [{ name: "*" }];
|
||||||
|
}
|
||||||
|
return bucketNames.map((bucketName) => ({ name: bucketName }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array of bucket objects with scopes and collections
|
||||||
|
// Check if any bucket is "*" - if so, grant access to all buckets, scopes, and collections
|
||||||
|
const hasWildcardBucket = buckets.some((bucket) => bucket.name === "*");
|
||||||
|
|
||||||
|
if (hasWildcardBucket) {
|
||||||
|
return [{ name: "*" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets.map((bucket) => ({
|
||||||
|
name: bucket.name,
|
||||||
|
scopes: bucket.scopes?.map((scope) => ({
|
||||||
|
name: scope.name,
|
||||||
|
collections: scope.collections || []
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => {
|
||||||
|
const randomUsername = alphaNumericNanoId(12);
|
||||||
|
if (!usernameTemplate) return sanitizeCouchbaseUsername(randomUsername);
|
||||||
|
|
||||||
|
const compiledUsername = compileUsernameTemplate({
|
||||||
|
usernameTemplate,
|
||||||
|
randomUsername,
|
||||||
|
identity
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitizeCouchbaseUsername(compiledUsername);
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatePassword = (requirements?: PasswordRequirements): string => {
|
||||||
|
const {
|
||||||
|
length = 12,
|
||||||
|
required = { lowercase: 1, uppercase: 1, digits: 1, symbols: 1 },
|
||||||
|
allowedSymbols = "!@#$%^()_+-=[]{}:,?/~`"
|
||||||
|
} = requirements || {};
|
||||||
|
|
||||||
|
const lowercase = "abcdefghijklmnopqrstuvwxyz";
|
||||||
|
const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
const digits = "0123456789";
|
||||||
|
const symbols = allowedSymbols;
|
||||||
|
|
||||||
|
let password = "";
|
||||||
|
let remaining = length;
|
||||||
|
|
||||||
|
// Add required characters
|
||||||
|
for (let i = 0; i < required.lowercase; i += 1) {
|
||||||
|
password += lowercase[crypto.randomInt(lowercase.length)];
|
||||||
|
remaining -= 1;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < required.uppercase; i += 1) {
|
||||||
|
password += uppercase[crypto.randomInt(uppercase.length)];
|
||||||
|
remaining -= 1;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < required.digits; i += 1) {
|
||||||
|
password += digits[crypto.randomInt(digits.length)];
|
||||||
|
remaining -= 1;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < required.symbols; i += 1) {
|
||||||
|
password += symbols[crypto.randomInt(symbols.length)];
|
||||||
|
remaining -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill remaining with random characters from all sets
|
||||||
|
const allChars = lowercase + uppercase + digits + symbols;
|
||||||
|
for (let i = 0; i < remaining; i += 1) {
|
||||||
|
password += allChars[crypto.randomInt(allChars.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle the password
|
||||||
|
return password
|
||||||
|
.split("")
|
||||||
|
.sort(() => crypto.randomInt(3) - 1)
|
||||||
|
.join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const couchbaseApiRequest = async (
|
||||||
|
method: string,
|
||||||
|
url: string,
|
||||||
|
apiKey: string,
|
||||||
|
data?: unknown
|
||||||
|
): Promise<CouchbaseUserResponse> => {
|
||||||
|
await blockLocalAndPrivateIpAddresses(url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios({
|
||||||
|
method: method.toLowerCase() as "get" | "post" | "put" | "delete",
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
data: data || undefined,
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data as CouchbaseUserResponse;
|
||||||
|
} catch (err) {
|
||||||
|
const sanitizedErrorMessage = sanitizeString({
|
||||||
|
unsanitizedString: (err as Error)?.message,
|
||||||
|
tokens: [apiKey]
|
||||||
|
});
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Failed to connect with provider: ${sanitizedErrorMessage}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CouchbaseProvider = (): TDynamicProviderFns => {
|
||||||
|
const validateProviderInputs = async (inputs: object) => {
|
||||||
|
const providerInputs = DynamicSecretCouchbaseSchema.parse(inputs);
|
||||||
|
|
||||||
|
await blockLocalAndPrivateIpAddresses(providerInputs.url);
|
||||||
|
|
||||||
|
return providerInputs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateConnection = async (inputs: unknown): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const providerInputs = await validateProviderInputs(inputs as object);
|
||||||
|
|
||||||
|
// Test connection by trying to get organization info
|
||||||
|
const url = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}`;
|
||||||
|
await couchbaseApiRequest("GET", url, providerInputs.auth.apiKey);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Failed to connect to Couchbase: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = async ({
|
||||||
|
inputs,
|
||||||
|
usernameTemplate,
|
||||||
|
identity
|
||||||
|
}: {
|
||||||
|
inputs: unknown;
|
||||||
|
usernameTemplate?: string | null;
|
||||||
|
identity?: { name: string };
|
||||||
|
}) => {
|
||||||
|
const providerInputs = await validateProviderInputs(inputs as object);
|
||||||
|
|
||||||
|
const username = generateUsername(usernameTemplate, identity);
|
||||||
|
|
||||||
|
const password = generatePassword(providerInputs.passwordRequirements);
|
||||||
|
|
||||||
|
const createUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users`;
|
||||||
|
|
||||||
|
const bucketResources = normalizeBucketConfiguration(providerInputs.buckets);
|
||||||
|
|
||||||
|
const userData: TCreateCouchbaseUser = {
|
||||||
|
name: username,
|
||||||
|
password,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
privileges: providerInputs.roles,
|
||||||
|
resources: {
|
||||||
|
buckets: bucketResources
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await couchbaseApiRequest("POST", createUserUrl, providerInputs.auth.apiKey, userData);
|
||||||
|
|
||||||
|
const userUuid = response?.id || response?.uuid || username;
|
||||||
|
|
||||||
|
return {
|
||||||
|
entityId: userUuid,
|
||||||
|
data: {
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const revoke = async (inputs: unknown, entityId: string) => {
|
||||||
|
const providerInputs = await validateProviderInputs(inputs as object);
|
||||||
|
|
||||||
|
const deleteUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users/${encodeURIComponent(entityId)}`;
|
||||||
|
|
||||||
|
await couchbaseApiRequest("DELETE", deleteUserUrl, providerInputs.auth.apiKey);
|
||||||
|
|
||||||
|
return { entityId };
|
||||||
|
};
|
||||||
|
|
||||||
|
const renew = async (_inputs: unknown, entityId: string) => {
|
||||||
|
// Couchbase Cloud API doesn't support renewing user credentials
|
||||||
|
// The user remains valid until explicitly deleted
|
||||||
|
return { entityId };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
validateProviderInputs,
|
||||||
|
validateConnection,
|
||||||
|
create,
|
||||||
|
revoke,
|
||||||
|
renew
|
||||||
|
};
|
||||||
|
};
|
@@ -5,6 +5,7 @@ import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache";
|
|||||||
import { AwsIamProvider } from "./aws-iam";
|
import { AwsIamProvider } from "./aws-iam";
|
||||||
import { AzureEntraIDProvider } from "./azure-entra-id";
|
import { AzureEntraIDProvider } from "./azure-entra-id";
|
||||||
import { CassandraProvider } from "./cassandra";
|
import { CassandraProvider } from "./cassandra";
|
||||||
|
import { CouchbaseProvider } from "./couchbase";
|
||||||
import { ElasticSearchProvider } from "./elastic-search";
|
import { ElasticSearchProvider } from "./elastic-search";
|
||||||
import { GcpIamProvider } from "./gcp-iam";
|
import { GcpIamProvider } from "./gcp-iam";
|
||||||
import { GithubProvider } from "./github";
|
import { GithubProvider } from "./github";
|
||||||
@@ -46,5 +47,6 @@ export const buildDynamicSecretProviders = ({
|
|||||||
[DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }),
|
[DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }),
|
||||||
[DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }),
|
[DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }),
|
||||||
[DynamicSecretProviders.GcpIam]: GcpIamProvider(),
|
[DynamicSecretProviders.GcpIam]: GcpIamProvider(),
|
||||||
[DynamicSecretProviders.Github]: GithubProvider()
|
[DynamicSecretProviders.Github]: GithubProvider(),
|
||||||
|
[DynamicSecretProviders.Couchbase]: CouchbaseProvider()
|
||||||
});
|
});
|
||||||
|
@@ -505,6 +505,91 @@ export const DynamicSecretGithubSchema = z.object({
|
|||||||
.describe("The private key generated for your GitHub App.")
|
.describe("The private key generated for your GitHub App.")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const DynamicSecretCouchbaseSchema = z.object({
|
||||||
|
url: z.string().url().trim().min(1).describe("Couchbase Cloud API URL"),
|
||||||
|
orgId: z.string().trim().min(1).describe("Organization ID"),
|
||||||
|
projectId: z.string().trim().min(1).describe("Project ID"),
|
||||||
|
clusterId: z.string().trim().min(1).describe("Cluster ID"),
|
||||||
|
roles: z.array(z.string().trim().min(1)).min(1).describe("Roles to assign to the user"),
|
||||||
|
buckets: z
|
||||||
|
.union([
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.default("*")
|
||||||
|
.refine((val) => {
|
||||||
|
if (val.includes(",")) {
|
||||||
|
const buckets = val
|
||||||
|
.split(",")
|
||||||
|
.map((b) => b.trim())
|
||||||
|
.filter((b) => b.length > 0);
|
||||||
|
if (buckets.includes("*") && buckets.length > 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, "Cannot combine '*' with other bucket names"),
|
||||||
|
z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().trim().min(1).describe("Bucket name"),
|
||||||
|
scopes: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string().trim().min(1).describe("Scope name"),
|
||||||
|
collections: z.array(z.string().trim().min(1)).optional().describe("Collection names")
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.describe("Scopes within the bucket")
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.refine((buckets) => {
|
||||||
|
const hasWildcard = buckets.some((bucket) => bucket.name === "*");
|
||||||
|
if (hasWildcard && buckets.length > 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, "Cannot combine '*' bucket with other buckets")
|
||||||
|
])
|
||||||
|
.default("*")
|
||||||
|
.describe(
|
||||||
|
"Bucket configuration: '*' for all buckets, scopes, and collections or array of bucket objects with specific scopes and collections"
|
||||||
|
),
|
||||||
|
passwordRequirements: z
|
||||||
|
.object({
|
||||||
|
length: z.number().min(8, "Password must be at least 8 characters").max(128),
|
||||||
|
required: z
|
||||||
|
.object({
|
||||||
|
lowercase: z.number().min(1, "At least 1 lowercase character required"),
|
||||||
|
uppercase: z.number().min(1, "At least 1 uppercase character required"),
|
||||||
|
digits: z.number().min(1, "At least 1 digit required"),
|
||||||
|
symbols: z.number().min(1, "At least 1 special character required")
|
||||||
|
})
|
||||||
|
.refine((data) => {
|
||||||
|
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
|
||||||
|
return total <= 128;
|
||||||
|
}, "Sum of required characters cannot exceed 128"),
|
||||||
|
allowedSymbols: z
|
||||||
|
.string()
|
||||||
|
.refine((symbols) => {
|
||||||
|
const forbiddenChars = ["<", ">", ";", ".", "*", "&", "|", "£"];
|
||||||
|
return !forbiddenChars.some((char) => symbols?.includes(char));
|
||||||
|
}, "Cannot contain: < > ; . * & | £")
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
.refine((data) => {
|
||||||
|
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
|
||||||
|
return total <= data.length;
|
||||||
|
}, "Sum of required characters cannot exceed the total length")
|
||||||
|
.optional()
|
||||||
|
.describe("Password generation requirements for Couchbase"),
|
||||||
|
auth: z.object({
|
||||||
|
apiKey: z.string().trim().min(1).describe("Couchbase Cloud API Key")
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
export enum DynamicSecretProviders {
|
export enum DynamicSecretProviders {
|
||||||
SqlDatabase = "sql-database",
|
SqlDatabase = "sql-database",
|
||||||
Cassandra = "cassandra",
|
Cassandra = "cassandra",
|
||||||
@@ -524,7 +609,8 @@ export enum DynamicSecretProviders {
|
|||||||
Kubernetes = "kubernetes",
|
Kubernetes = "kubernetes",
|
||||||
Vertica = "vertica",
|
Vertica = "vertica",
|
||||||
GcpIam = "gcp-iam",
|
GcpIam = "gcp-iam",
|
||||||
Github = "github"
|
Github = "github",
|
||||||
|
Couchbase = "couchbase"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
|
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
|
||||||
@@ -546,7 +632,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
|
|||||||
z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }),
|
z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }),
|
||||||
z.object({ type: z.literal(DynamicSecretProviders.Vertica), inputs: DynamicSecretVerticaSchema }),
|
z.object({ type: z.literal(DynamicSecretProviders.Vertica), inputs: DynamicSecretVerticaSchema }),
|
||||||
z.object({ type: z.literal(DynamicSecretProviders.GcpIam), inputs: DynamicSecretGcpIamSchema }),
|
z.object({ type: z.literal(DynamicSecretProviders.GcpIam), inputs: DynamicSecretGcpIamSchema }),
|
||||||
z.object({ type: z.literal(DynamicSecretProviders.Github), inputs: DynamicSecretGithubSchema })
|
z.object({ type: z.literal(DynamicSecretProviders.Github), inputs: DynamicSecretGithubSchema }),
|
||||||
|
z.object({ type: z.literal(DynamicSecretProviders.Couchbase), inputs: DynamicSecretCouchbaseSchema })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type TDynamicProviderFns = {
|
export type TDynamicProviderFns = {
|
||||||
|
@@ -3,7 +3,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { logger } from "@app/lib/logger";
|
import { logger } from "@app/lib/logger";
|
||||||
|
|
||||||
import { EventSchema, TopicName } from "./types";
|
import { BusEventSchema, TopicName } from "./types";
|
||||||
|
|
||||||
export const eventBusFactory = (redis: Redis) => {
|
export const eventBusFactory = (redis: Redis) => {
|
||||||
const publisher = redis.duplicate();
|
const publisher = redis.duplicate();
|
||||||
@@ -28,7 +28,7 @@ export const eventBusFactory = (redis: Redis) => {
|
|||||||
* @param topic - The topic to publish the event to.
|
* @param topic - The topic to publish the event to.
|
||||||
* @param event - The event data to publish.
|
* @param event - The event data to publish.
|
||||||
*/
|
*/
|
||||||
const publish = async <T extends z.input<typeof EventSchema>>(topic: TopicName, event: T) => {
|
const publish = async <T extends z.input<typeof BusEventSchema>>(topic: TopicName, event: T) => {
|
||||||
const json = JSON.stringify(event);
|
const json = JSON.stringify(event);
|
||||||
|
|
||||||
return publisher.publish(topic, json, (err) => {
|
return publisher.publish(topic, json, (err) => {
|
||||||
@@ -44,7 +44,7 @@ export const eventBusFactory = (redis: Redis) => {
|
|||||||
* @template T - The type of the event data, which should match the schema defined in EventSchema.
|
* @template T - The type of the event data, which should match the schema defined in EventSchema.
|
||||||
* @returns A function that can be called to unsubscribe from the event bus.
|
* @returns A function that can be called to unsubscribe from the event bus.
|
||||||
*/
|
*/
|
||||||
const subscribe = <T extends z.infer<typeof EventSchema>>(fn: (data: T) => Promise<void> | void) => {
|
const subscribe = <T extends z.infer<typeof BusEventSchema>>(fn: (data: T) => Promise<void> | void) => {
|
||||||
// Not using async await cause redis client's `on` method does not expect async listeners.
|
// Not using async await cause redis client's `on` method does not expect async listeners.
|
||||||
const listener = (channel: string, message: string) => {
|
const listener = (channel: string, message: string) => {
|
||||||
try {
|
try {
|
||||||
|
@@ -7,7 +7,7 @@ import { logger } from "@app/lib/logger";
|
|||||||
|
|
||||||
import { TEventBusService } from "./event-bus-service";
|
import { TEventBusService } from "./event-bus-service";
|
||||||
import { createEventStreamClient, EventStreamClient, IEventStreamClientOpts } from "./event-sse-stream";
|
import { createEventStreamClient, EventStreamClient, IEventStreamClientOpts } from "./event-sse-stream";
|
||||||
import { EventData, RegisteredEvent, toBusEventName } from "./types";
|
import { BusEvent, RegisteredEvent } from "./types";
|
||||||
|
|
||||||
const AUTH_REFRESH_INTERVAL = 60 * 1000;
|
const AUTH_REFRESH_INTERVAL = 60 * 1000;
|
||||||
const HEART_BEAT_INTERVAL = 15 * 1000;
|
const HEART_BEAT_INTERVAL = 15 * 1000;
|
||||||
@@ -69,8 +69,8 @@ export const sseServiceFactory = (bus: TEventBusService, redis: Redis) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
function filterEventsForClient(client: EventStreamClient, event: EventData, registered: RegisteredEvent[]) {
|
function filterEventsForClient(client: EventStreamClient, event: BusEvent, registered: RegisteredEvent[]) {
|
||||||
const eventType = toBusEventName(event.data.eventType);
|
const eventType = event.data.event;
|
||||||
const match = registered.find((r) => r.event === eventType);
|
const match = registered.find((r) => r.event === eventType);
|
||||||
if (!match) return;
|
if (!match) return;
|
||||||
|
|
||||||
|
@@ -12,7 +12,7 @@ import { KeyStorePrefixes } from "@app/keystore/keystore";
|
|||||||
import { conditionsMatcher } from "@app/lib/casl";
|
import { conditionsMatcher } from "@app/lib/casl";
|
||||||
import { logger } from "@app/lib/logger";
|
import { logger } from "@app/lib/logger";
|
||||||
|
|
||||||
import { EventData, RegisteredEvent } from "./types";
|
import { BusEvent, RegisteredEvent } from "./types";
|
||||||
|
|
||||||
export const getServerSentEventsHeaders = () =>
|
export const getServerSentEventsHeaders = () =>
|
||||||
({
|
({
|
||||||
@@ -55,7 +55,7 @@ export type EventStreamClient = {
|
|||||||
id: string;
|
id: string;
|
||||||
stream: Readable;
|
stream: Readable;
|
||||||
open: () => Promise<void>;
|
open: () => Promise<void>;
|
||||||
send: (data: EventMessage | EventData) => void;
|
send: (data: EventMessage | BusEvent) => void;
|
||||||
ping: () => Promise<void>;
|
ping: () => Promise<void>;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
@@ -73,15 +73,12 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien
|
|||||||
return {
|
return {
|
||||||
subject: options.type,
|
subject: options.type,
|
||||||
action: "subscribe",
|
action: "subscribe",
|
||||||
conditions: {
|
conditions: hasConditions
|
||||||
eventType: r.event,
|
|
||||||
...(hasConditions
|
|
||||||
? {
|
? {
|
||||||
environment: r.conditions?.environmentSlug ?? "",
|
environment: r.conditions?.environmentSlug ?? "",
|
||||||
secretPath: { $glob: secretPath }
|
secretPath: { $glob: secretPath }
|
||||||
}
|
}
|
||||||
: {})
|
: undefined
|
||||||
}
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,7 +95,7 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien
|
|||||||
// We will manually push data to the stream
|
// We will manually push data to the stream
|
||||||
stream._read = () => {};
|
stream._read = () => {};
|
||||||
|
|
||||||
const send = (data: EventMessage | EventData) => {
|
const send = (data: EventMessage | BusEvent) => {
|
||||||
const chunk = serializeSseEvent(data);
|
const chunk = serializeSseEvent(data);
|
||||||
if (!stream.push(chunk)) {
|
if (!stream.push(chunk)) {
|
||||||
logger.debug("Backpressure detected: dropped manual event");
|
logger.debug("Backpressure detected: dropped manual event");
|
||||||
|
@@ -1,7 +1,8 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { ProjectType } from "@app/db/schemas";
|
import { ProjectType } from "@app/db/schemas";
|
||||||
import { Event, EventType } from "@app/ee/services/audit-log/audit-log-types";
|
|
||||||
|
import { ProjectPermissionSecretEventActions } from "../permission/project-permission";
|
||||||
|
|
||||||
export enum TopicName {
|
export enum TopicName {
|
||||||
CoreServers = "infisical::core-servers"
|
CoreServers = "infisical::core-servers"
|
||||||
@@ -10,84 +11,44 @@ export enum TopicName {
|
|||||||
export enum BusEventName {
|
export enum BusEventName {
|
||||||
CreateSecret = "secret:create",
|
CreateSecret = "secret:create",
|
||||||
UpdateSecret = "secret:update",
|
UpdateSecret = "secret:update",
|
||||||
DeleteSecret = "secret:delete"
|
DeleteSecret = "secret:delete",
|
||||||
|
ImportMutation = "secret:import-mutation"
|
||||||
}
|
}
|
||||||
|
|
||||||
type PublisableEventTypes =
|
export const Mappings = {
|
||||||
| EventType.CREATE_SECRET
|
BusEventToAction(input: BusEventName) {
|
||||||
| EventType.CREATE_SECRETS
|
|
||||||
| EventType.DELETE_SECRET
|
|
||||||
| EventType.DELETE_SECRETS
|
|
||||||
| EventType.UPDATE_SECRETS
|
|
||||||
| EventType.UPDATE_SECRET;
|
|
||||||
|
|
||||||
export function toBusEventName(input: EventType) {
|
|
||||||
switch (input) {
|
switch (input) {
|
||||||
case EventType.CREATE_SECRET:
|
case BusEventName.CreateSecret:
|
||||||
case EventType.CREATE_SECRETS:
|
return ProjectPermissionSecretEventActions.SubscribeCreated;
|
||||||
return BusEventName.CreateSecret;
|
case BusEventName.DeleteSecret:
|
||||||
case EventType.UPDATE_SECRET:
|
return ProjectPermissionSecretEventActions.SubscribeDeleted;
|
||||||
case EventType.UPDATE_SECRETS:
|
case BusEventName.ImportMutation:
|
||||||
return BusEventName.UpdateSecret;
|
return ProjectPermissionSecretEventActions.SubscribeImportMutations;
|
||||||
case EventType.DELETE_SECRET:
|
case BusEventName.UpdateSecret:
|
||||||
case EventType.DELETE_SECRETS:
|
return ProjectPermissionSecretEventActions.SubscribeUpdated;
|
||||||
return BusEventName.DeleteSecret;
|
|
||||||
default:
|
default:
|
||||||
return null;
|
throw new Error("Unknown bus event name");
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isBulkEvent = (event: Event): event is Extract<Event, { metadata: { secrets: Array<unknown> } }> => {
|
|
||||||
return event.type.endsWith("-secrets"); // Feels so wrong
|
|
||||||
};
|
|
||||||
|
|
||||||
export const toPublishableEvent = (event: Event) => {
|
|
||||||
const name = toBusEventName(event.type);
|
|
||||||
|
|
||||||
if (!name) return null;
|
|
||||||
|
|
||||||
const e = event as Extract<Event, { type: PublisableEventTypes }>;
|
|
||||||
|
|
||||||
if (isBulkEvent(e)) {
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
isBulk: true,
|
|
||||||
data: {
|
|
||||||
eventType: e.type,
|
|
||||||
payload: e.metadata.secrets.map((s) => ({
|
|
||||||
environment: e.metadata.environment,
|
|
||||||
secretPath: e.metadata.secretPath,
|
|
||||||
...s
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
isBulk: false,
|
|
||||||
data: {
|
|
||||||
eventType: e.type,
|
|
||||||
payload: {
|
|
||||||
...e.metadata,
|
|
||||||
environment: e.metadata.environment
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} as const;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EventName = z.nativeEnum(BusEventName);
|
export const EventName = z.nativeEnum(BusEventName);
|
||||||
|
|
||||||
const EventSecretPayload = z.object({
|
const EventSecretPayload = z.object({
|
||||||
secretPath: z.string().optional(),
|
|
||||||
secretId: z.string(),
|
secretId: z.string(),
|
||||||
|
secretPath: z.string().optional(),
|
||||||
secretKey: z.string(),
|
secretKey: z.string(),
|
||||||
environment: z.string()
|
environment: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const EventImportMutationPayload = z.object({
|
||||||
|
secretPath: z.string(),
|
||||||
|
environment: z.string()
|
||||||
|
});
|
||||||
|
|
||||||
export type EventSecret = z.infer<typeof EventSecretPayload>;
|
export type EventSecret = z.infer<typeof EventSecretPayload>;
|
||||||
|
|
||||||
export const EventSchema = z.object({
|
export const BusEventSchema = z.object({
|
||||||
datacontenttype: z.literal("application/json").optional().default("application/json"),
|
datacontenttype: z.literal("application/json").optional().default("application/json"),
|
||||||
type: z.nativeEnum(ProjectType),
|
type: z.nativeEnum(ProjectType),
|
||||||
source: z.string(),
|
source: z.string(),
|
||||||
@@ -95,25 +56,38 @@ export const EventSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.default(() => new Date().toISOString()),
|
.default(() => new Date().toISOString()),
|
||||||
data: z.discriminatedUnion("eventType", [
|
data: z.discriminatedUnion("event", [
|
||||||
z.object({
|
z.object({
|
||||||
specversion: z.number().optional().default(1),
|
specversion: z.number().optional().default(1),
|
||||||
eventType: z.enum([EventType.CREATE_SECRET, EventType.UPDATE_SECRET, EventType.DELETE_SECRET]),
|
event: z.enum([BusEventName.CreateSecret, BusEventName.DeleteSecret, BusEventName.UpdateSecret]),
|
||||||
payload: EventSecretPayload
|
payload: z.union([EventSecretPayload, EventSecretPayload.array()])
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
specversion: z.number().optional().default(1),
|
specversion: z.number().optional().default(1),
|
||||||
eventType: z.enum([EventType.CREATE_SECRETS, EventType.UPDATE_SECRETS, EventType.DELETE_SECRETS]),
|
event: z.enum([BusEventName.ImportMutation]),
|
||||||
payload: EventSecretPayload.array()
|
payload: z.union([EventImportMutationPayload, EventImportMutationPayload.array()])
|
||||||
})
|
})
|
||||||
// Add more event types as needed
|
// Add more event types as needed
|
||||||
])
|
])
|
||||||
});
|
});
|
||||||
|
|
||||||
export type EventData = z.infer<typeof EventSchema>;
|
export type BusEvent = z.infer<typeof BusEventSchema>;
|
||||||
|
|
||||||
|
type PublishableEventPayload = z.input<typeof BusEventSchema>["data"];
|
||||||
|
type PublishableSecretEvent = Extract<
|
||||||
|
PublishableEventPayload,
|
||||||
|
{ event: Exclude<BusEventName, BusEventName.ImportMutation> }
|
||||||
|
>["payload"];
|
||||||
|
|
||||||
|
export type PublishableEvent = {
|
||||||
|
created?: PublishableSecretEvent;
|
||||||
|
updated?: PublishableSecretEvent;
|
||||||
|
deleted?: PublishableSecretEvent;
|
||||||
|
importMutation?: Extract<PublishableEventPayload, { event: BusEventName.ImportMutation }>["payload"];
|
||||||
|
};
|
||||||
|
|
||||||
export const EventRegisterSchema = z.object({
|
export const EventRegisterSchema = z.object({
|
||||||
event: EventName,
|
event: z.nativeEnum(BusEventName),
|
||||||
conditions: z
|
conditions: z
|
||||||
.object({
|
.object({
|
||||||
secretPath: z.string().optional().default("/"),
|
secretPath: z.string().optional().default("/"),
|
||||||
|
@@ -31,7 +31,7 @@ export const getDefaultOnPremFeatures = () => {
|
|||||||
caCrl: false,
|
caCrl: false,
|
||||||
sshHostGroups: false,
|
sshHostGroups: false,
|
||||||
enterpriseSecretSyncs: false,
|
enterpriseSecretSyncs: false,
|
||||||
enterpriseAppConnections: false,
|
enterpriseAppConnections: true,
|
||||||
machineIdentityAuthTemplates: false
|
machineIdentityAuthTemplates: false
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@@ -161,8 +161,7 @@ const buildAdminPermissionRules = () => {
|
|||||||
ProjectPermissionSecretActions.ReadValue,
|
ProjectPermissionSecretActions.ReadValue,
|
||||||
ProjectPermissionSecretActions.Create,
|
ProjectPermissionSecretActions.Create,
|
||||||
ProjectPermissionSecretActions.Edit,
|
ProjectPermissionSecretActions.Edit,
|
||||||
ProjectPermissionSecretActions.Delete,
|
ProjectPermissionSecretActions.Delete
|
||||||
ProjectPermissionSecretActions.Subscribe
|
|
||||||
],
|
],
|
||||||
ProjectPermissionSub.Secrets
|
ProjectPermissionSub.Secrets
|
||||||
);
|
);
|
||||||
@@ -266,8 +265,7 @@ const buildMemberPermissionRules = () => {
|
|||||||
ProjectPermissionSecretActions.ReadValue,
|
ProjectPermissionSecretActions.ReadValue,
|
||||||
ProjectPermissionSecretActions.Edit,
|
ProjectPermissionSecretActions.Edit,
|
||||||
ProjectPermissionSecretActions.Create,
|
ProjectPermissionSecretActions.Create,
|
||||||
ProjectPermissionSecretActions.Delete,
|
ProjectPermissionSecretActions.Delete
|
||||||
ProjectPermissionSecretActions.Subscribe
|
|
||||||
],
|
],
|
||||||
ProjectPermissionSub.Secrets
|
ProjectPermissionSub.Secrets
|
||||||
);
|
);
|
||||||
|
@@ -36,8 +36,7 @@ export enum ProjectPermissionSecretActions {
|
|||||||
ReadValue = "readValue",
|
ReadValue = "readValue",
|
||||||
Create = "create",
|
Create = "create",
|
||||||
Edit = "edit",
|
Edit = "edit",
|
||||||
Delete = "delete",
|
Delete = "delete"
|
||||||
Subscribe = "subscribe"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ProjectPermissionCmekActions {
|
export enum ProjectPermissionCmekActions {
|
||||||
@@ -158,6 +157,13 @@ export enum ProjectPermissionSecretScanningConfigActions {
|
|||||||
Update = "update-configs"
|
Update = "update-configs"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ProjectPermissionSecretEventActions {
|
||||||
|
SubscribeCreated = "subscribe-on-created",
|
||||||
|
SubscribeUpdated = "subscribe-on-updated",
|
||||||
|
SubscribeDeleted = "subscribe-on-deleted",
|
||||||
|
SubscribeImportMutations = "subscribe-on-import-mutations"
|
||||||
|
}
|
||||||
|
|
||||||
export enum ProjectPermissionSub {
|
export enum ProjectPermissionSub {
|
||||||
Role = "role",
|
Role = "role",
|
||||||
Member = "member",
|
Member = "member",
|
||||||
@@ -197,7 +203,8 @@ export enum ProjectPermissionSub {
|
|||||||
Kmip = "kmip",
|
Kmip = "kmip",
|
||||||
SecretScanningDataSources = "secret-scanning-data-sources",
|
SecretScanningDataSources = "secret-scanning-data-sources",
|
||||||
SecretScanningFindings = "secret-scanning-findings",
|
SecretScanningFindings = "secret-scanning-findings",
|
||||||
SecretScanningConfigs = "secret-scanning-configs"
|
SecretScanningConfigs = "secret-scanning-configs",
|
||||||
|
SecretEvents = "secret-events"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SecretSubjectFields = {
|
export type SecretSubjectFields = {
|
||||||
@@ -205,7 +212,13 @@ export type SecretSubjectFields = {
|
|||||||
secretPath: string;
|
secretPath: string;
|
||||||
secretName?: string;
|
secretName?: string;
|
||||||
secretTags?: string[];
|
secretTags?: string[];
|
||||||
eventType?: string;
|
};
|
||||||
|
|
||||||
|
export type SecretEventSubjectFields = {
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
secretName?: string;
|
||||||
|
secretTags?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SecretFolderSubjectFields = {
|
export type SecretFolderSubjectFields = {
|
||||||
@@ -344,7 +357,11 @@ export type ProjectPermissionSet =
|
|||||||
| [ProjectPermissionCommitsActions, ProjectPermissionSub.Commits]
|
| [ProjectPermissionCommitsActions, ProjectPermissionSub.Commits]
|
||||||
| [ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources]
|
| [ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources]
|
||||||
| [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings]
|
| [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings]
|
||||||
| [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs];
|
| [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]
|
||||||
|
| [
|
||||||
|
ProjectPermissionSecretEventActions,
|
||||||
|
ProjectPermissionSub.SecretEvents | (ForcedSubject<ProjectPermissionSub.SecretEvents> & SecretEventSubjectFields)
|
||||||
|
];
|
||||||
|
|
||||||
const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
|
const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
|
||||||
const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
|
const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
|
||||||
@@ -877,7 +894,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
|
|||||||
"When specified, only matching conditions will be allowed to access given resource."
|
"When specified, only matching conditions will be allowed to access given resource."
|
||||||
).optional()
|
).optional()
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
subject: z.literal(ProjectPermissionSub.SecretEvents).describe("The entity this permission pertains to."),
|
||||||
|
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
|
||||||
|
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretEventActions).describe(
|
||||||
|
"Describe what action an entity can take."
|
||||||
|
),
|
||||||
|
conditions: SecretSyncConditionV2Schema.describe(
|
||||||
|
"When specified, only matching conditions will be allowed to access given resource."
|
||||||
|
).optional()
|
||||||
|
}),
|
||||||
...GeneralPermissionSchema
|
...GeneralPermissionSchema
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@@ -952,13 +952,39 @@ export const secretApprovalRequestServiceFactory = ({
|
|||||||
if (!folder) {
|
if (!folder) {
|
||||||
throw new NotFoundError({ message: `Folder with ID '${folderId}' not found in project with ID '${projectId}'` });
|
throw new NotFoundError({ message: `Folder with ID '${folderId}' not found in project with ID '${projectId}'` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { secrets } = mergeStatus;
|
||||||
|
|
||||||
await secretQueueService.syncSecrets({
|
await secretQueueService.syncSecrets({
|
||||||
projectId,
|
projectId,
|
||||||
orgId: actorOrgId,
|
orgId: actorOrgId,
|
||||||
secretPath: folder.path,
|
secretPath: folder.path,
|
||||||
environmentSlug: folder.environmentSlug,
|
environmentSlug: folder.environmentSlug,
|
||||||
actorId,
|
actorId,
|
||||||
actor
|
actor,
|
||||||
|
event: {
|
||||||
|
created: secrets.created.map((el) => ({
|
||||||
|
environment: folder.environmentSlug,
|
||||||
|
secretPath: folder.path,
|
||||||
|
secretId: el.id,
|
||||||
|
// @ts-expect-error - not present on V1 secrets
|
||||||
|
secretKey: el.key as string
|
||||||
|
})),
|
||||||
|
updated: secrets.updated.map((el) => ({
|
||||||
|
environment: folder.environmentSlug,
|
||||||
|
secretPath: folder.path,
|
||||||
|
secretId: el.id,
|
||||||
|
// @ts-expect-error - not present on V1 secrets
|
||||||
|
secretKey: el.key as string
|
||||||
|
})),
|
||||||
|
deleted: secrets.deleted.map((el) => ({
|
||||||
|
environment: folder.environmentSlug,
|
||||||
|
secretPath: folder.path,
|
||||||
|
secretId: el.id,
|
||||||
|
// @ts-expect-error - not present on V1 secrets
|
||||||
|
secretKey: el.key as string
|
||||||
|
}))
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isSoftEnforcement) {
|
if (isSoftEnforcement) {
|
||||||
|
@@ -2,6 +2,7 @@ import { AxiosError } from "axios";
|
|||||||
|
|
||||||
import { getConfig } from "@app/lib/config/env";
|
import { getConfig } from "@app/lib/config/env";
|
||||||
import { BadRequestError } from "@app/lib/errors";
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
|
import { logger } from "@app/lib/logger";
|
||||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||||
|
|
||||||
import { AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./auth0-client-secret";
|
import { AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./auth0-client-secret";
|
||||||
@@ -13,9 +14,11 @@ import { MYSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mysql-credentials";
|
|||||||
import { OKTA_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./okta-client-secret";
|
import { OKTA_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./okta-client-secret";
|
||||||
import { ORACLEDB_CREDENTIALS_ROTATION_LIST_OPTION } from "./oracledb-credentials";
|
import { ORACLEDB_CREDENTIALS_ROTATION_LIST_OPTION } from "./oracledb-credentials";
|
||||||
import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials";
|
import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials";
|
||||||
|
import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal";
|
||||||
import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums";
|
import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums";
|
||||||
import { TSecretRotationV2ServiceFactoryDep } from "./secret-rotation-v2-service";
|
import { TSecretRotationV2ServiceFactory, TSecretRotationV2ServiceFactoryDep } from "./secret-rotation-v2-service";
|
||||||
import {
|
import {
|
||||||
|
TSecretRotationRotateSecretsJobPayload,
|
||||||
TSecretRotationV2,
|
TSecretRotationV2,
|
||||||
TSecretRotationV2GeneratedCredentials,
|
TSecretRotationV2GeneratedCredentials,
|
||||||
TSecretRotationV2ListItem,
|
TSecretRotationV2ListItem,
|
||||||
@@ -74,6 +77,10 @@ export const getNextUtcRotationInterval = (rotateAtUtc?: TSecretRotationV2["rota
|
|||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
|
|
||||||
if (appCfg.isRotationDevelopmentMode) {
|
if (appCfg.isRotationDevelopmentMode) {
|
||||||
|
if (appCfg.isTestMode) {
|
||||||
|
// if its test mode, it should always rotate
|
||||||
|
return new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); // Current time + 1 year
|
||||||
|
}
|
||||||
return getNextUTCMinuteInterval(rotateAtUtc);
|
return getNextUTCMinuteInterval(rotateAtUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,3 +270,51 @@ export const throwOnImmutableParameterUpdate = (
|
|||||||
// do nothing
|
// do nothing
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const rotateSecretsFns = async ({
|
||||||
|
job,
|
||||||
|
secretRotationV2DAL,
|
||||||
|
secretRotationV2Service
|
||||||
|
}: {
|
||||||
|
job: {
|
||||||
|
data: TSecretRotationRotateSecretsJobPayload;
|
||||||
|
id: string;
|
||||||
|
retryCount: number;
|
||||||
|
retryLimit: number;
|
||||||
|
};
|
||||||
|
secretRotationV2DAL: Pick<TSecretRotationV2DALFactory, "findById">;
|
||||||
|
secretRotationV2Service: Pick<TSecretRotationV2ServiceFactory, "rotateGeneratedCredentials">;
|
||||||
|
}) => {
|
||||||
|
const { rotationId, queuedAt, isManualRotation } = job.data;
|
||||||
|
const { retryCount, retryLimit } = job;
|
||||||
|
|
||||||
|
const logDetails = `[rotationId=${rotationId}] [jobId=${job.id}] retryCount=[${retryCount}/${retryLimit}]`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const secretRotation = await secretRotationV2DAL.findById(rotationId);
|
||||||
|
|
||||||
|
if (!secretRotation) throw new Error(`Secret rotation ${rotationId} not found`);
|
||||||
|
|
||||||
|
if (!secretRotation.isAutoRotationEnabled) {
|
||||||
|
logger.info(`secretRotationV2Queue: Skipping Rotation - Auto-Rotation Disabled Since Queue ${logDetails}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new Date(secretRotation.lastRotatedAt).getTime() >= new Date(queuedAt).getTime()) {
|
||||||
|
// rotated since being queued, skip rotation
|
||||||
|
logger.info(`secretRotationV2Queue: Skipping Rotation - Rotated Since Queue ${logDetails}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await secretRotationV2Service.rotateGeneratedCredentials(secretRotation, {
|
||||||
|
jobId: job.id,
|
||||||
|
shouldSendNotification: true,
|
||||||
|
isFinalAttempt: retryCount === retryLimit,
|
||||||
|
isManualRotation
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`secretRotationV2Queue: Secrets Rotated ${logDetails}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, `secretRotationV2Queue: Failed to Rotate Secrets ${logDetails}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
@@ -1,9 +1,12 @@
|
|||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
import { ProjectMembershipRole } from "@app/db/schemas";
|
import { ProjectMembershipRole } from "@app/db/schemas";
|
||||||
import { TSecretRotationV2DALFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-dal";
|
import { TSecretRotationV2DALFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-dal";
|
||||||
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
|
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
|
||||||
import {
|
import {
|
||||||
getNextUtcRotationInterval,
|
getNextUtcRotationInterval,
|
||||||
getSecretRotationRotateSecretJobOptions
|
getSecretRotationRotateSecretJobOptions,
|
||||||
|
rotateSecretsFns
|
||||||
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-fns";
|
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-fns";
|
||||||
import { SECRET_ROTATION_NAME_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps";
|
import { SECRET_ROTATION_NAME_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps";
|
||||||
import { TSecretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service";
|
import { TSecretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service";
|
||||||
@@ -63,6 +66,25 @@ export const secretRotationV2QueueServiceFactory = async ({
|
|||||||
rotation.lastRotatedAt
|
rotation.lastRotatedAt
|
||||||
).toISOString()}] [rotateAt=${new Date(rotation.nextRotationAt!).toISOString()}]`
|
).toISOString()}] [rotateAt=${new Date(rotation.nextRotationAt!).toISOString()}]`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
rotationId: rotation.id,
|
||||||
|
queuedAt: currentTime
|
||||||
|
} as TSecretRotationRotateSecretsJobPayload;
|
||||||
|
|
||||||
|
if (appCfg.isTestMode) {
|
||||||
|
logger.warn("secretRotationV2Queue: Manually rotating secrets for test mode");
|
||||||
|
await rotateSecretsFns({
|
||||||
|
job: {
|
||||||
|
id: uuidv4(),
|
||||||
|
data,
|
||||||
|
retryCount: 0,
|
||||||
|
retryLimit: 0
|
||||||
|
},
|
||||||
|
secretRotationV2DAL,
|
||||||
|
secretRotationV2Service
|
||||||
|
});
|
||||||
|
} else {
|
||||||
await queueService.queuePg(
|
await queueService.queuePg(
|
||||||
QueueJobs.SecretRotationV2RotateSecrets,
|
QueueJobs.SecretRotationV2RotateSecrets,
|
||||||
{
|
{
|
||||||
@@ -72,6 +94,7 @@ export const secretRotationV2QueueServiceFactory = async ({
|
|||||||
getSecretRotationRotateSecretJobOptions(rotation)
|
getSecretRotationRotateSecretJobOptions(rotation)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error, "secretRotationV2Queue: Queue Rotations Error:");
|
logger.error(error, "secretRotationV2Queue: Queue Rotations Error:");
|
||||||
throw error;
|
throw error;
|
||||||
@@ -87,38 +110,14 @@ export const secretRotationV2QueueServiceFactory = async ({
|
|||||||
await queueService.startPg<QueueName.SecretRotationV2>(
|
await queueService.startPg<QueueName.SecretRotationV2>(
|
||||||
QueueJobs.SecretRotationV2RotateSecrets,
|
QueueJobs.SecretRotationV2RotateSecrets,
|
||||||
async ([job]) => {
|
async ([job]) => {
|
||||||
const { rotationId, queuedAt, isManualRotation } = job.data as TSecretRotationRotateSecretsJobPayload;
|
await rotateSecretsFns({
|
||||||
const { retryCount, retryLimit } = job;
|
job: {
|
||||||
|
...job,
|
||||||
const logDetails = `[rotationId=${rotationId}] [jobId=${job.id}] retryCount=[${retryCount}/${retryLimit}]`;
|
data: job.data as TSecretRotationRotateSecretsJobPayload
|
||||||
|
},
|
||||||
try {
|
secretRotationV2DAL,
|
||||||
const secretRotation = await secretRotationV2DAL.findById(rotationId);
|
secretRotationV2Service
|
||||||
|
|
||||||
if (!secretRotation) throw new Error(`Secret rotation ${rotationId} not found`);
|
|
||||||
|
|
||||||
if (!secretRotation.isAutoRotationEnabled) {
|
|
||||||
logger.info(`secretRotationV2Queue: Skipping Rotation - Auto-Rotation Disabled Since Queue ${logDetails}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (new Date(secretRotation.lastRotatedAt).getTime() >= new Date(queuedAt).getTime()) {
|
|
||||||
// rotated since being queued, skip rotation
|
|
||||||
logger.info(`secretRotationV2Queue: Skipping Rotation - Rotated Since Queue ${logDetails}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await secretRotationV2Service.rotateGeneratedCredentials(secretRotation, {
|
|
||||||
jobId: job.id,
|
|
||||||
shouldSendNotification: true,
|
|
||||||
isFinalAttempt: retryCount === retryLimit,
|
|
||||||
isManualRotation
|
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`secretRotationV2Queue: Secrets Rotated ${logDetails}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error, `secretRotationV2Queue: Failed to Rotate Secrets ${logDetails}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
batchSize: 1,
|
batchSize: 1,
|
||||||
|
@@ -58,9 +58,9 @@ export function scanDirectory(inputPath: string, outputPath: string, configPath?
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scanFile(inputPath: string): Promise<void> {
|
export function scanFile(inputPath: string, configPath?: string): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const command = `infisical scan --exit-code=77 --source "${inputPath}" --no-git`;
|
const command = `infisical scan --exit-code=77 --source "${inputPath}" --no-git ${configPath ? `-c ${configPath}` : ""}`;
|
||||||
exec(command, (error) => {
|
exec(command, (error) => {
|
||||||
if (error && error.code === 77) {
|
if (error && error.code === 77) {
|
||||||
reject(error);
|
reject(error);
|
||||||
@@ -166,6 +166,20 @@ export const parseScanErrorMessage = (err: unknown): string => {
|
|||||||
: `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`;
|
: `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const generateSecretValuePolicyConfiguration = (entropy: number): string => `
|
||||||
|
# Extend default configuration to preserve existing rules
|
||||||
|
[extend]
|
||||||
|
useDefault = true
|
||||||
|
|
||||||
|
# Add custom high-entropy rule
|
||||||
|
[[rules]]
|
||||||
|
id = "high-entropy"
|
||||||
|
description = "Will scan for high entropy secrets"
|
||||||
|
regex = '''.*'''
|
||||||
|
entropy = ${entropy}
|
||||||
|
keywords = []
|
||||||
|
`;
|
||||||
|
|
||||||
export const scanSecretPolicyViolations = async (
|
export const scanSecretPolicyViolations = async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
secretPath: string,
|
secretPath: string,
|
||||||
@@ -188,14 +202,25 @@ export const scanSecretPolicyViolations = async (
|
|||||||
|
|
||||||
const tempFolder = await createTempFolder();
|
const tempFolder = await createTempFolder();
|
||||||
try {
|
try {
|
||||||
|
const configPath = join(tempFolder, "infisical-scan.toml");
|
||||||
|
|
||||||
|
const secretPolicyConfiguration = generateSecretValuePolicyConfiguration(
|
||||||
|
appCfg.PARAMS_FOLDER_SECRET_DETECTION_ENTROPY
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeTextToFile(configPath, secretPolicyConfiguration);
|
||||||
|
|
||||||
const scanPromises = secrets
|
const scanPromises = secrets
|
||||||
.filter((secret) => !ignoreValues.includes(secret.secretValue))
|
.filter((secret) => !ignoreValues.includes(secret.secretValue))
|
||||||
.map(async (secret) => {
|
.map(async (secret) => {
|
||||||
const secretFilePath = join(tempFolder, `${crypto.nativeCrypto.randomUUID()}.txt`);
|
const secretKeyValueFilePath = join(tempFolder, `${crypto.nativeCrypto.randomUUID()}.txt`);
|
||||||
await writeTextToFile(secretFilePath, `${secret.secretKey}=${secret.secretValue}`);
|
const secretValueOnlyFilePath = join(tempFolder, `${crypto.nativeCrypto.randomUUID()}.txt`);
|
||||||
|
await writeTextToFile(secretKeyValueFilePath, `${secret.secretKey}=${secret.secretValue}`);
|
||||||
|
await writeTextToFile(secretValueOnlyFilePath, secret.secretValue);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await scanFile(secretFilePath);
|
await scanFile(secretKeyValueFilePath);
|
||||||
|
await scanFile(secretValueOnlyFilePath, configPath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
message: `Secret value detected in ${secret.secretKey}. Please add this instead to the designated secrets path in the project.`,
|
message: `Secret value detected in ${secret.secretKey}. Please add this instead to the designated secrets path in the project.`,
|
||||||
|
@@ -79,6 +79,7 @@ const envSchema = z
|
|||||||
QUEUE_WORKER_PROFILE: z.nativeEnum(QueueWorkerProfile).default(QueueWorkerProfile.All),
|
QUEUE_WORKER_PROFILE: z.nativeEnum(QueueWorkerProfile).default(QueueWorkerProfile.All),
|
||||||
HTTPS_ENABLED: zodStrBool,
|
HTTPS_ENABLED: zodStrBool,
|
||||||
ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
|
ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
|
||||||
|
DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
|
||||||
// smtp options
|
// smtp options
|
||||||
SMTP_HOST: zpStr(z.string().optional()),
|
SMTP_HOST: zpStr(z.string().optional()),
|
||||||
SMTP_IGNORE_TLS: zodStrBool.default("false"),
|
SMTP_IGNORE_TLS: zodStrBool.default("false"),
|
||||||
@@ -215,6 +216,7 @@ const envSchema = z
|
|||||||
return JSON.parse(val) as { secretPath: string; projectId: string }[];
|
return JSON.parse(val) as { secretPath: string; projectId: string }[];
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
PARAMS_FOLDER_SECRET_DETECTION_ENTROPY: z.coerce.number().optional().default(3.7),
|
||||||
|
|
||||||
// HSM
|
// HSM
|
||||||
HSM_LIB_PATH: zpStr(z.string().optional()),
|
HSM_LIB_PATH: zpStr(z.string().optional()),
|
||||||
@@ -346,7 +348,11 @@ const envSchema = z
|
|||||||
isSmtpConfigured: Boolean(data.SMTP_HOST),
|
isSmtpConfigured: Boolean(data.SMTP_HOST),
|
||||||
isRedisConfigured: Boolean(data.REDIS_URL || data.REDIS_SENTINEL_HOSTS),
|
isRedisConfigured: Boolean(data.REDIS_URL || data.REDIS_SENTINEL_HOSTS),
|
||||||
isDevelopmentMode: data.NODE_ENV === "development",
|
isDevelopmentMode: data.NODE_ENV === "development",
|
||||||
isRotationDevelopmentMode: data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE,
|
isTestMode: data.NODE_ENV === "test",
|
||||||
|
isRotationDevelopmentMode:
|
||||||
|
(data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE) || data.NODE_ENV === "test",
|
||||||
|
isDailyResourceCleanUpDevelopmentMode:
|
||||||
|
data.NODE_ENV === "development" && data.DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE,
|
||||||
isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED,
|
isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED,
|
||||||
isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS),
|
isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS),
|
||||||
REDIS_SENTINEL_HOSTS: data.REDIS_SENTINEL_HOSTS?.trim()
|
REDIS_SENTINEL_HOSTS: data.REDIS_SENTINEL_HOSTS?.trim()
|
||||||
|
@@ -20,7 +20,10 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl
|
|||||||
const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId);
|
const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId);
|
||||||
|
|
||||||
if (slackConfig) {
|
if (slackConfig) {
|
||||||
if (notification.type === TriggerFeature.ACCESS_REQUEST) {
|
if (
|
||||||
|
notification.type === TriggerFeature.ACCESS_REQUEST ||
|
||||||
|
notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED
|
||||||
|
) {
|
||||||
const targetChannelIds = slackConfig.accessRequestChannels?.split(", ") || [];
|
const targetChannelIds = slackConfig.accessRequestChannels?.split(", ") || [];
|
||||||
if (targetChannelIds.length && slackConfig.isAccessRequestNotificationEnabled) {
|
if (targetChannelIds.length && slackConfig.isAccessRequestNotificationEnabled) {
|
||||||
await sendSlackNotification({
|
await sendSlackNotification({
|
||||||
@@ -50,7 +53,10 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (microsoftTeamsConfig) {
|
if (microsoftTeamsConfig) {
|
||||||
if (notification.type === TriggerFeature.ACCESS_REQUEST) {
|
if (
|
||||||
|
notification.type === TriggerFeature.ACCESS_REQUEST ||
|
||||||
|
notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED
|
||||||
|
) {
|
||||||
if (microsoftTeamsConfig.isAccessRequestNotificationEnabled && microsoftTeamsConfig.accessRequestChannels) {
|
if (microsoftTeamsConfig.isAccessRequestNotificationEnabled && microsoftTeamsConfig.accessRequestChannels) {
|
||||||
const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse(
|
const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse(
|
||||||
microsoftTeamsConfig.accessRequestChannels
|
microsoftTeamsConfig.accessRequestChannels
|
||||||
|
@@ -6,7 +6,8 @@ import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack
|
|||||||
|
|
||||||
export enum TriggerFeature {
|
export enum TriggerFeature {
|
||||||
SECRET_APPROVAL = "secret-approval",
|
SECRET_APPROVAL = "secret-approval",
|
||||||
ACCESS_REQUEST = "access-request"
|
ACCESS_REQUEST = "access-request",
|
||||||
|
ACCESS_REQUEST_UPDATED = "access-request-updated"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TNotification =
|
export type TNotification =
|
||||||
@@ -34,6 +35,22 @@ export type TNotification =
|
|||||||
approvalUrl: string;
|
approvalUrl: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: TriggerFeature.ACCESS_REQUEST_UPDATED;
|
||||||
|
payload: {
|
||||||
|
requesterFullName: string;
|
||||||
|
requesterEmail: string;
|
||||||
|
isTemporary: boolean;
|
||||||
|
secretPath: string;
|
||||||
|
environment: string;
|
||||||
|
projectName: string;
|
||||||
|
permissions: string[];
|
||||||
|
approvalUrl: string;
|
||||||
|
editNote?: string;
|
||||||
|
editorFullName?: string;
|
||||||
|
editorEmail?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TTriggerWorkflowNotificationDTO = {
|
export type TTriggerWorkflowNotificationDTO = {
|
||||||
|
@@ -560,8 +560,7 @@ export const registerRoutes = async (
|
|||||||
queueService,
|
queueService,
|
||||||
projectDAL,
|
projectDAL,
|
||||||
licenseService,
|
licenseService,
|
||||||
auditLogStreamDAL,
|
auditLogStreamDAL
|
||||||
eventBusService
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue });
|
const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue });
|
||||||
@@ -1121,7 +1120,9 @@ export const registerRoutes = async (
|
|||||||
resourceMetadataDAL,
|
resourceMetadataDAL,
|
||||||
folderCommitService,
|
folderCommitService,
|
||||||
secretSyncQueue,
|
secretSyncQueue,
|
||||||
reminderService
|
reminderService,
|
||||||
|
eventBusService,
|
||||||
|
licenseService
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectService = projectServiceFactory({
|
const projectService = projectServiceFactory({
|
||||||
@@ -1972,7 +1973,7 @@ export const registerRoutes = async (
|
|||||||
|
|
||||||
await telemetryQueue.startTelemetryCheck();
|
await telemetryQueue.startTelemetryCheck();
|
||||||
await telemetryQueue.startAggregatedEventsJob();
|
await telemetryQueue.startAggregatedEventsJob();
|
||||||
await dailyResourceCleanUp.startCleanUp();
|
await dailyResourceCleanUp.init();
|
||||||
await dailyReminderQueueService.startDailyRemindersJob();
|
await dailyReminderQueueService.startDailyRemindersJob();
|
||||||
await dailyReminderQueueService.startSecretReminderMigrationJob();
|
await dailyReminderQueueService.startSecretReminderMigrationJob();
|
||||||
await dailyExpiringPkiItemAlert.startSendingAlerts();
|
await dailyExpiringPkiItemAlert.startSendingAlerts();
|
||||||
|
@@ -583,16 +583,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
|
|||||||
email: z.string().email().trim(),
|
email: z.string().email().trim(),
|
||||||
password: z.string().trim(),
|
password: z.string().trim(),
|
||||||
firstName: z.string().trim(),
|
firstName: z.string().trim(),
|
||||||
lastName: z.string().trim().optional(),
|
lastName: z.string().trim().optional()
|
||||||
protectedKey: z.string().trim(),
|
|
||||||
protectedKeyIV: z.string().trim(),
|
|
||||||
protectedKeyTag: z.string().trim(),
|
|
||||||
publicKey: z.string().trim(),
|
|
||||||
encryptedPrivateKey: z.string().trim(),
|
|
||||||
encryptedPrivateKeyIV: z.string().trim(),
|
|
||||||
encryptedPrivateKeyTag: z.string().trim(),
|
|
||||||
salt: z.string().trim(),
|
|
||||||
verifier: z.string().trim()
|
|
||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
|
@@ -5,8 +5,8 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { ActionProjectType, ProjectType } from "@app/db/schemas";
|
import { ActionProjectType, ProjectType } from "@app/db/schemas";
|
||||||
import { getServerSentEventsHeaders } from "@app/ee/services/event/event-sse-stream";
|
import { getServerSentEventsHeaders } from "@app/ee/services/event/event-sse-stream";
|
||||||
import { EventRegisterSchema } from "@app/ee/services/event/types";
|
import { EventRegisterSchema, Mappings } from "@app/ee/services/event/types";
|
||||||
import { ProjectPermissionSecretActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||||
import { ApiDocsTags, EventSubscriptions } from "@app/lib/api-docs";
|
import { ApiDocsTags, EventSubscriptions } from "@app/lib/api-docs";
|
||||||
import { BadRequestError, ForbiddenRequestError, RateLimitError } from "@app/lib/errors";
|
import { BadRequestError, ForbiddenRequestError, RateLimitError } from "@app/lib/errors";
|
||||||
import { readLimit } from "@app/server/config/rateLimiter";
|
import { readLimit } from "@app/server/config/rateLimiter";
|
||||||
@@ -82,21 +82,19 @@ export const registerEventRouter = async (server: FastifyZodProvider) => {
|
|||||||
req.body.register.forEach((r) => {
|
req.body.register.forEach((r) => {
|
||||||
const fields = {
|
const fields = {
|
||||||
environment: r.conditions?.environmentSlug ?? "",
|
environment: r.conditions?.environmentSlug ?? "",
|
||||||
secretPath: r.conditions?.secretPath ?? "/",
|
secretPath: r.conditions?.secretPath ?? "/"
|
||||||
eventType: r.event
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const allowed = info.permission.can(
|
const action = Mappings.BusEventToAction(r.event);
|
||||||
ProjectPermissionSecretActions.Subscribe,
|
|
||||||
subject(ProjectPermissionSub.Secrets, fields)
|
const allowed = info.permission.can(action, subject(ProjectPermissionSub.SecretEvents, fields));
|
||||||
);
|
|
||||||
|
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
throw new ForbiddenRequestError({
|
throw new ForbiddenRequestError({
|
||||||
name: "PermissionDenied",
|
name: "PermissionDenied",
|
||||||
message: `You are not allowed to subscribe on secrets`,
|
message: `You are not allowed to subscribe on ${ProjectPermissionSub.SecretEvents}`,
|
||||||
details: {
|
details: {
|
||||||
event: fields.eventType,
|
action,
|
||||||
environmentSlug: fields.environment,
|
environmentSlug: fields.environment,
|
||||||
secretPath: fields.secretPath
|
secretPath: fields.secretPath
|
||||||
}
|
}
|
||||||
|
@@ -478,4 +478,30 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
|
|||||||
return { identityMemberships };
|
return { identityMemberships };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "GET",
|
||||||
|
url: "/details",
|
||||||
|
config: {
|
||||||
|
rateLimit: readLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
identityDetails: z.object({
|
||||||
|
organization: z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
slug: z.string()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN], { requireOrg: false }),
|
||||||
|
handler: async (req) => {
|
||||||
|
const organization = await server.services.org.findIdentityOrganization(req.permission.id);
|
||||||
|
return { identityDetails: { organization } };
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
@@ -45,7 +45,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
|
|||||||
.transform(removeTrailingSlash)
|
.transform(removeTrailingSlash)
|
||||||
.describe(FOLDERS.CREATE.path)
|
.describe(FOLDERS.CREATE.path)
|
||||||
.optional(),
|
.optional(),
|
||||||
// backward compatiability with cli
|
// backward compatibility with cli
|
||||||
directory: z
|
directory: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@@ -58,7 +58,9 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
|
|||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
folder: SecretFoldersSchema
|
folder: SecretFoldersSchema.extend({
|
||||||
|
path: z.string()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -130,7 +132,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
|
|||||||
.transform(removeTrailingSlash)
|
.transform(removeTrailingSlash)
|
||||||
.describe(FOLDERS.UPDATE.path)
|
.describe(FOLDERS.UPDATE.path)
|
||||||
.optional(),
|
.optional(),
|
||||||
// backward compatiability with cli
|
// backward compatibility with cli
|
||||||
directory: z
|
directory: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
@@ -143,7 +145,9 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
|
|||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
folder: SecretFoldersSchema
|
folder: SecretFoldersSchema.extend({
|
||||||
|
path: z.string()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -359,7 +363,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
|
|||||||
.transform(removeTrailingSlash)
|
.transform(removeTrailingSlash)
|
||||||
.describe(FOLDERS.LIST.path)
|
.describe(FOLDERS.LIST.path)
|
||||||
.optional(),
|
.optional(),
|
||||||
// backward compatiability with cli
|
// backward compatibility with cli
|
||||||
directory: z
|
directory: z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
|
@@ -142,16 +142,27 @@ export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) =>
|
|||||||
return token;
|
return token;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseGitHubLinkHeader = (linkHeader: string | undefined): Record<string, string> => {
|
||||||
|
if (!linkHeader) return {};
|
||||||
|
|
||||||
|
const links: Record<string, string> = {};
|
||||||
|
const segments = linkHeader.split(",");
|
||||||
|
const re = new RE2(/<([^>]+)>;\s*rel="([^"]+)"/);
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
const match = re.exec(segment.trim());
|
||||||
|
if (match) {
|
||||||
|
const url = match[1];
|
||||||
|
const rel = match[2];
|
||||||
|
links[rel] = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return links;
|
||||||
|
};
|
||||||
|
|
||||||
function extractNextPageUrl(linkHeader: string | undefined): string | null {
|
function extractNextPageUrl(linkHeader: string | undefined): string | null {
|
||||||
if (!linkHeader) return null;
|
const links = parseGitHubLinkHeader(linkHeader);
|
||||||
|
return links.next || null;
|
||||||
const links = linkHeader.split(",");
|
|
||||||
const nextLink = links.find((link) => link.includes('rel="next"'));
|
|
||||||
|
|
||||||
if (!nextLink) return null;
|
|
||||||
|
|
||||||
const match = new RE2(/<([^>]+)>/).exec(nextLink);
|
|
||||||
return match ? match[1] : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const makePaginatedGitHubRequest = async <T, R = T[]>(
|
export const makePaginatedGitHubRequest = async <T, R = T[]>(
|
||||||
@@ -164,11 +175,66 @@ export const makePaginatedGitHubRequest = async <T, R = T[]>(
|
|||||||
|
|
||||||
const token =
|
const token =
|
||||||
method === GitHubConnectionMethod.OAuth ? credentials.accessToken : await getGitHubAppAuthToken(appConnection);
|
method === GitHubConnectionMethod.OAuth ? credentials.accessToken : await getGitHubAppAuthToken(appConnection);
|
||||||
let url: string | null = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`;
|
|
||||||
let results: T[] = [];
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (url && i < 1000) {
|
const baseUrl = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`;
|
||||||
|
const initialUrlObj = new URL(baseUrl);
|
||||||
|
initialUrlObj.searchParams.set("per_page", "100");
|
||||||
|
|
||||||
|
let results: T[] = [];
|
||||||
|
const maxIterations = 1000;
|
||||||
|
|
||||||
|
// Make initial request to get link header
|
||||||
|
const firstResponse: AxiosResponse<R> = await requestWithGitHubGateway<R>(appConnection, gatewayService, {
|
||||||
|
url: initialUrlObj.toString(),
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstPageItems = dataMapper ? dataMapper(firstResponse.data) : (firstResponse.data as unknown as T[]);
|
||||||
|
results = results.concat(firstPageItems);
|
||||||
|
|
||||||
|
const linkHeader = parseGitHubLinkHeader(firstResponse.headers.link as string | undefined);
|
||||||
|
const lastPageUrl = linkHeader.last;
|
||||||
|
|
||||||
|
// If there's a last page URL, get its page number and concurrently fetch every page starting from 2 to last
|
||||||
|
if (lastPageUrl) {
|
||||||
|
const lastPageParam = new URL(lastPageUrl).searchParams.get("page");
|
||||||
|
const totalPages = lastPageParam ? parseInt(lastPageParam, 10) : 1;
|
||||||
|
|
||||||
|
const pageRequests: Promise<AxiosResponse<R>>[] = [];
|
||||||
|
|
||||||
|
for (let pageNum = 2; pageNum <= totalPages && pageNum - 1 < maxIterations; pageNum += 1) {
|
||||||
|
const pageUrlObj = new URL(initialUrlObj.toString());
|
||||||
|
pageUrlObj.searchParams.set("page", pageNum.toString());
|
||||||
|
|
||||||
|
pageRequests.push(
|
||||||
|
requestWithGitHubGateway<R>(appConnection, gatewayService, {
|
||||||
|
url: pageUrlObj.toString(),
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const responses = await Promise.all(pageRequests);
|
||||||
|
|
||||||
|
for (const response of responses) {
|
||||||
|
const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]);
|
||||||
|
results = results.concat(items);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback in case last link isn't present
|
||||||
|
let url: string | null = extractNextPageUrl(firstResponse.headers.link as string | undefined);
|
||||||
|
let i = 1;
|
||||||
|
|
||||||
|
while (url && i < maxIterations) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
const response: AxiosResponse<R> = await requestWithGitHubGateway<R>(appConnection, gatewayService, {
|
const response: AxiosResponse<R> = await requestWithGitHubGateway<R>(appConnection, gatewayService, {
|
||||||
url,
|
url,
|
||||||
@@ -186,6 +252,7 @@ export const makePaginatedGitHubRequest = async <T, R = T[]>(
|
|||||||
url = extractNextPageUrl(response.headers.link as string | undefined);
|
url = extractNextPageUrl(response.headers.link as string | undefined);
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
};
|
};
|
||||||
|
@@ -462,6 +462,54 @@ export const buildTeamsPayload = (notification: TNotification) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case TriggerFeature.ACCESS_REQUEST_UPDATED: {
|
||||||
|
const { payload } = notification;
|
||||||
|
|
||||||
|
const adaptiveCard = {
|
||||||
|
type: "AdaptiveCard",
|
||||||
|
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||||
|
version: "1.5",
|
||||||
|
body: [
|
||||||
|
{
|
||||||
|
type: "TextBlock",
|
||||||
|
text: "Updated access approval request pending for review",
|
||||||
|
weight: "Bolder",
|
||||||
|
size: "Large"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "TextBlock",
|
||||||
|
text: `${payload.editorFullName} (${payload.editorEmail}) has updated the ${
|
||||||
|
payload.isTemporary ? "temporary" : "permanent"
|
||||||
|
} access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.`,
|
||||||
|
wrap: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "TextBlock",
|
||||||
|
text: `The following permissions are requested: ${payload.permissions.join(", ")}`,
|
||||||
|
wrap: true
|
||||||
|
},
|
||||||
|
payload.editNote
|
||||||
|
? {
|
||||||
|
type: "TextBlock",
|
||||||
|
text: `**Editor Note**: ${payload.editNote}`,
|
||||||
|
wrap: true
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
].filter(Boolean),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: "Action.OpenUrl",
|
||||||
|
title: "View request in Infisical",
|
||||||
|
url: payload.approvalUrl
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
adaptiveCard
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
message: "Teams notification type not supported."
|
message: "Teams notification type not supported."
|
||||||
|
@@ -630,6 +630,25 @@ export const orgDALFactory = (db: TDbClient) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findIdentityOrganization = async (
|
||||||
|
identityId: string
|
||||||
|
): Promise<{ id: string; name: string; slug: string; role: string }> => {
|
||||||
|
try {
|
||||||
|
const org = await db
|
||||||
|
.replicaNode()(TableName.IdentityOrgMembership)
|
||||||
|
.where({ identityId })
|
||||||
|
.join(TableName.Organization, `${TableName.IdentityOrgMembership}.orgId`, `${TableName.Organization}.id`)
|
||||||
|
.select(db.ref("id").withSchema(TableName.Organization).as("id"))
|
||||||
|
.select(db.ref("name").withSchema(TableName.Organization).as("name"))
|
||||||
|
.select(db.ref("slug").withSchema(TableName.Organization).as("slug"))
|
||||||
|
.select(db.ref("role").withSchema(TableName.IdentityOrgMembership).as("role"));
|
||||||
|
|
||||||
|
return org?.[0];
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find identity organization" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return withTransaction(db, {
|
return withTransaction(db, {
|
||||||
...orgOrm,
|
...orgOrm,
|
||||||
findOrgByProjectId,
|
findOrgByProjectId,
|
||||||
@@ -652,6 +671,7 @@ export const orgDALFactory = (db: TDbClient) => {
|
|||||||
updateMembershipById,
|
updateMembershipById,
|
||||||
deleteMembershipById,
|
deleteMembershipById,
|
||||||
deleteMembershipsById,
|
deleteMembershipsById,
|
||||||
updateMembership
|
updateMembership,
|
||||||
|
findIdentityOrganization
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@@ -198,6 +198,15 @@ export const orgServiceFactory = ({
|
|||||||
// Filter out orgs where the membership object is an invitation
|
// Filter out orgs where the membership object is an invitation
|
||||||
return orgs.filter((org) => org.userStatus !== "invited");
|
return orgs.filter((org) => org.userStatus !== "invited");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Get all organization an identity is part of
|
||||||
|
* */
|
||||||
|
const findIdentityOrganization = async (identityId: string) => {
|
||||||
|
const org = await orgDAL.findIdentityOrganization(identityId);
|
||||||
|
|
||||||
|
return org;
|
||||||
|
};
|
||||||
/*
|
/*
|
||||||
* Get all workspace members
|
* Get all workspace members
|
||||||
* */
|
* */
|
||||||
@@ -1403,6 +1412,7 @@ export const orgServiceFactory = ({
|
|||||||
findOrganizationById,
|
findOrganizationById,
|
||||||
findAllOrgMembers,
|
findAllOrgMembers,
|
||||||
findAllOrganizationOfUser,
|
findAllOrganizationOfUser,
|
||||||
|
findIdentityOrganization,
|
||||||
inviteUserToOrganization,
|
inviteUserToOrganization,
|
||||||
verifyUserToOrg,
|
verifyUserToOrg,
|
||||||
updateOrg,
|
updateOrg,
|
||||||
|
@@ -177,6 +177,18 @@ export const projectEnvServiceFactory = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const envs = await projectEnvDAL.find({ projectId });
|
||||||
|
const project = await projectDAL.findById(projectId);
|
||||||
|
const plan = await licenseService.getPlan(project.orgId);
|
||||||
|
if (plan.environmentLimit !== null && envs.length > plan.environmentLimit) {
|
||||||
|
// case: limit imposed on number of environments allowed
|
||||||
|
// case: number of environments used exceeds the number of environments allowed
|
||||||
|
throw new BadRequestError({
|
||||||
|
message:
|
||||||
|
"Failed to update environment due to environment limit exceeded. To update an environment, please upgrade your plan or remove unused environments."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const env = await projectEnvDAL.transaction(async (tx) => {
|
const env = await projectEnvDAL.transaction(async (tx) => {
|
||||||
if (position) {
|
if (position) {
|
||||||
const existingEnvWithPosition = await projectEnvDAL.findOne({ projectId, position }, tx);
|
const existingEnvWithPosition = await projectEnvDAL.findOne({ projectId, position }, tx);
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
import { TAuditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal";
|
import { TAuditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal";
|
||||||
import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal";
|
import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal";
|
||||||
|
import { getConfig } from "@app/lib/config/env";
|
||||||
import { logger } from "@app/lib/logger";
|
import { logger } from "@app/lib/logger";
|
||||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||||
|
|
||||||
@@ -41,7 +42,30 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
|
|||||||
serviceTokenService,
|
serviceTokenService,
|
||||||
orgService
|
orgService
|
||||||
}: TDailyResourceCleanUpQueueServiceFactoryDep) => {
|
}: TDailyResourceCleanUpQueueServiceFactoryDep) => {
|
||||||
queueService.start(QueueName.DailyResourceCleanUp, async () => {
|
const appCfg = getConfig();
|
||||||
|
|
||||||
|
if (appCfg.isDailyResourceCleanUpDevelopmentMode) {
|
||||||
|
logger.warn("Daily Resource Clean Up is in development mode.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
await queueService.stopRepeatableJob(
|
||||||
|
QueueName.AuditLogPrune,
|
||||||
|
QueueJobs.AuditLogPrune,
|
||||||
|
{ pattern: "0 0 * * *", utc: true },
|
||||||
|
QueueName.AuditLogPrune // just a job id
|
||||||
|
);
|
||||||
|
await queueService.stopRepeatableJob(
|
||||||
|
QueueName.DailyResourceCleanUp,
|
||||||
|
QueueJobs.DailyResourceCleanUp,
|
||||||
|
{ pattern: "0 0 * * *", utc: true },
|
||||||
|
QueueName.DailyResourceCleanUp // just a job id
|
||||||
|
);
|
||||||
|
|
||||||
|
await queueService.startPg<QueueName.DailyResourceCleanUp>(
|
||||||
|
QueueJobs.DailyResourceCleanUp,
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`);
|
logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`);
|
||||||
await identityAccessTokenDAL.removeExpiredTokens();
|
await identityAccessTokenDAL.removeExpiredTokens();
|
||||||
await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets();
|
await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets();
|
||||||
@@ -55,37 +79,26 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
|
|||||||
await orgService.notifyInvitedUsers();
|
await orgService.notifyInvitedUsers();
|
||||||
await auditLogDAL.pruneAuditLog();
|
await auditLogDAL.pruneAuditLog();
|
||||||
logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`);
|
logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`);
|
||||||
});
|
} catch (error) {
|
||||||
|
logger.error(error, `${QueueName.DailyResourceCleanUp}: resource cleanup failed`);
|
||||||
// we do a repeat cron job in utc timezone at 12 Midnight each day
|
throw error;
|
||||||
const startCleanUp = async () => {
|
}
|
||||||
// TODO(akhilmhdh): remove later
|
},
|
||||||
await queueService.stopRepeatableJob(
|
{
|
||||||
QueueName.AuditLogPrune,
|
batchSize: 1,
|
||||||
QueueJobs.AuditLogPrune,
|
workerCount: 1,
|
||||||
{ pattern: "0 0 * * *", utc: true },
|
pollingIntervalSeconds: 1
|
||||||
QueueName.AuditLogPrune // just a job id
|
}
|
||||||
);
|
);
|
||||||
// clear previous job
|
await queueService.schedulePg(
|
||||||
await queueService.stopRepeatableJob(
|
|
||||||
QueueName.DailyResourceCleanUp,
|
|
||||||
QueueJobs.DailyResourceCleanUp,
|
QueueJobs.DailyResourceCleanUp,
|
||||||
{ pattern: "0 0 * * *", utc: true },
|
appCfg.isDailyResourceCleanUpDevelopmentMode ? "*/5 * * * *" : "0 0 * * *",
|
||||||
QueueName.DailyResourceCleanUp // just a job id
|
undefined,
|
||||||
|
{ tz: "UTC" }
|
||||||
);
|
);
|
||||||
|
|
||||||
await queueService.queue(QueueName.DailyResourceCleanUp, QueueJobs.DailyResourceCleanUp, undefined, {
|
|
||||||
delay: 5000,
|
|
||||||
jobId: QueueName.DailyResourceCleanUp,
|
|
||||||
repeat: { pattern: "0 0 * * *", utc: true }
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
queueService.listen(QueueName.DailyResourceCleanUp, "failed", (_, err) => {
|
|
||||||
logger.error(err, `${QueueName.DailyResourceCleanUp}: resource cleanup failed`);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
startCleanUp
|
init
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@@ -238,8 +238,16 @@ export const secretFolderServiceFactory = ({
|
|||||||
return doc;
|
return doc;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [folderWithFullPath] = await folderDAL.findSecretPathByFolderIds(projectId, [folder.id]);
|
||||||
|
|
||||||
|
if (!folderWithFullPath) {
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Failed to retrieve path for folder with ID '${folder.id}'`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await snapshotService.performSnapshot(folder.parentId as string);
|
await snapshotService.performSnapshot(folder.parentId as string);
|
||||||
return folder;
|
return { ...folder, path: folderWithFullPath.path };
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateManyFolders = async ({
|
const updateManyFolders = async ({
|
||||||
@@ -496,8 +504,27 @@ export const secretFolderServiceFactory = ({
|
|||||||
return doc;
|
return doc;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const foldersWithFullPaths = await folderDAL.findSecretPathByFolderIds(projectId, [newFolder.id, folder.id]);
|
||||||
|
|
||||||
|
const newFolderWithFullPath = foldersWithFullPaths.find((f) => f?.id === newFolder.id);
|
||||||
|
if (!newFolderWithFullPath) {
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Failed to retrieve path for folder with ID '${newFolder.id}'`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderWithFullPath = foldersWithFullPaths.find((f) => f?.id === folder.id);
|
||||||
|
if (!folderWithFullPath) {
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Failed to retrieve path for folder with ID '${folder.id}'`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await snapshotService.performSnapshot(newFolder.parentId as string);
|
await snapshotService.performSnapshot(newFolder.parentId as string);
|
||||||
return { folder: newFolder, old: folder };
|
return {
|
||||||
|
folder: { ...newFolder, path: newFolderWithFullPath.path },
|
||||||
|
old: { ...folder, path: folderWithFullPath.path }
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const $checkFolderPolicy = async ({
|
const $checkFolderPolicy = async ({
|
||||||
|
@@ -181,7 +181,13 @@ export const secretImportServiceFactory = ({
|
|||||||
projectId,
|
projectId,
|
||||||
environmentSlug: environment,
|
environmentSlug: environment,
|
||||||
actorId,
|
actorId,
|
||||||
actor
|
actor,
|
||||||
|
event: {
|
||||||
|
importMutation: {
|
||||||
|
secretPath,
|
||||||
|
environment
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +362,13 @@ export const secretImportServiceFactory = ({
|
|||||||
projectId,
|
projectId,
|
||||||
environmentSlug: environment,
|
environmentSlug: environment,
|
||||||
actor,
|
actor,
|
||||||
actorId
|
actorId,
|
||||||
|
event: {
|
||||||
|
importMutation: {
|
||||||
|
secretPath,
|
||||||
|
environment
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
|
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
import AWS, { AWSError } from "aws-sdk";
|
import AWS, { AWSError } from "aws-sdk";
|
||||||
|
import handlebars from "handlebars";
|
||||||
|
|
||||||
import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns";
|
import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns";
|
||||||
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
|
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
|
||||||
@@ -34,18 +35,51 @@ const sleep = async () =>
|
|||||||
setTimeout(resolve, 1000);
|
setTimeout(resolve, 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSParameterStoreRecord> => {
|
const getFullPath = ({ path, keySchema, environment }: { path: string; keySchema?: string; environment: string }) => {
|
||||||
|
if (!keySchema || !keySchema.includes("/")) return path;
|
||||||
|
|
||||||
|
if (keySchema.startsWith("/")) {
|
||||||
|
throw new SecretSyncError({ message: `Key schema cannot contain leading '/'`, shouldRetry: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const keySchemaSegments = handlebars
|
||||||
|
.compile(keySchema)({
|
||||||
|
environment,
|
||||||
|
secretKey: "{{secretKey}}"
|
||||||
|
})
|
||||||
|
.split("/");
|
||||||
|
|
||||||
|
const pathSegments = keySchemaSegments.slice(0, keySchemaSegments.length - 1);
|
||||||
|
|
||||||
|
if (pathSegments.some((segment) => segment.includes("{{secretKey}}"))) {
|
||||||
|
throw new SecretSyncError({
|
||||||
|
message: "Key schema cannot contain '/' after {{secretKey}}",
|
||||||
|
shouldRetry: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${path}${pathSegments.join("/")}/`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getParametersByPath = async (
|
||||||
|
ssm: AWS.SSM,
|
||||||
|
path: string,
|
||||||
|
keySchema: string | undefined,
|
||||||
|
environment: string
|
||||||
|
): Promise<TAWSParameterStoreRecord> => {
|
||||||
const awsParameterStoreSecretsRecord: TAWSParameterStoreRecord = {};
|
const awsParameterStoreSecretsRecord: TAWSParameterStoreRecord = {};
|
||||||
let hasNext = true;
|
let hasNext = true;
|
||||||
let nextToken: string | undefined;
|
let nextToken: string | undefined;
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
|
|
||||||
|
const fullPath = getFullPath({ path, keySchema, environment });
|
||||||
|
|
||||||
while (hasNext) {
|
while (hasNext) {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
const parameters = await ssm
|
const parameters = await ssm
|
||||||
.getParametersByPath({
|
.getParametersByPath({
|
||||||
Path: path,
|
Path: fullPath,
|
||||||
Recursive: false,
|
Recursive: false,
|
||||||
WithDecryption: true,
|
WithDecryption: true,
|
||||||
MaxResults: BATCH_SIZE,
|
MaxResults: BATCH_SIZE,
|
||||||
@@ -59,7 +93,7 @@ const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSPara
|
|||||||
parameters.Parameters.forEach((parameter) => {
|
parameters.Parameters.forEach((parameter) => {
|
||||||
if (parameter.Name) {
|
if (parameter.Name) {
|
||||||
// no leading slash if path is '/'
|
// no leading slash if path is '/'
|
||||||
const secKey = path.length > 1 ? parameter.Name.substring(path.length) : parameter.Name;
|
const secKey = fullPath.length > 1 ? parameter.Name.substring(path.length) : parameter.Name;
|
||||||
awsParameterStoreSecretsRecord[secKey] = parameter;
|
awsParameterStoreSecretsRecord[secKey] = parameter;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -83,12 +117,19 @@ const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSPara
|
|||||||
return awsParameterStoreSecretsRecord;
|
return awsParameterStoreSecretsRecord;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getParameterMetadataByPath = async (ssm: AWS.SSM, path: string): Promise<TAWSParameterStoreMetadataRecord> => {
|
const getParameterMetadataByPath = async (
|
||||||
|
ssm: AWS.SSM,
|
||||||
|
path: string,
|
||||||
|
keySchema: string | undefined,
|
||||||
|
environment: string
|
||||||
|
): Promise<TAWSParameterStoreMetadataRecord> => {
|
||||||
const awsParameterStoreMetadataRecord: TAWSParameterStoreMetadataRecord = {};
|
const awsParameterStoreMetadataRecord: TAWSParameterStoreMetadataRecord = {};
|
||||||
let hasNext = true;
|
let hasNext = true;
|
||||||
let nextToken: string | undefined;
|
let nextToken: string | undefined;
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
|
|
||||||
|
const fullPath = getFullPath({ path, keySchema, environment });
|
||||||
|
|
||||||
while (hasNext) {
|
while (hasNext) {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
@@ -100,7 +141,7 @@ const getParameterMetadataByPath = async (ssm: AWS.SSM, path: string): Promise<T
|
|||||||
{
|
{
|
||||||
Key: "Path",
|
Key: "Path",
|
||||||
Option: "OneLevel",
|
Option: "OneLevel",
|
||||||
Values: [path]
|
Values: [fullPath]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -112,7 +153,7 @@ const getParameterMetadataByPath = async (ssm: AWS.SSM, path: string): Promise<T
|
|||||||
parameters.Parameters.forEach((parameter) => {
|
parameters.Parameters.forEach((parameter) => {
|
||||||
if (parameter.Name) {
|
if (parameter.Name) {
|
||||||
// no leading slash if path is '/'
|
// no leading slash if path is '/'
|
||||||
const secKey = path.length > 1 ? parameter.Name.substring(path.length) : parameter.Name;
|
const secKey = fullPath.length > 1 ? parameter.Name.substring(path.length) : parameter.Name;
|
||||||
awsParameterStoreMetadataRecord[secKey] = parameter;
|
awsParameterStoreMetadataRecord[secKey] = parameter;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -298,9 +339,19 @@ export const AwsParameterStoreSyncFns = {
|
|||||||
|
|
||||||
const ssm = await getSSM(secretSync);
|
const ssm = await getSSM(secretSync);
|
||||||
|
|
||||||
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path);
|
const awsParameterStoreSecretsRecord = await getParametersByPath(
|
||||||
|
ssm,
|
||||||
|
destinationConfig.path,
|
||||||
|
syncOptions.keySchema,
|
||||||
|
environment!.slug
|
||||||
|
);
|
||||||
|
|
||||||
const awsParameterStoreMetadataRecord = await getParameterMetadataByPath(ssm, destinationConfig.path);
|
const awsParameterStoreMetadataRecord = await getParameterMetadataByPath(
|
||||||
|
ssm,
|
||||||
|
destinationConfig.path,
|
||||||
|
syncOptions.keySchema,
|
||||||
|
environment!.slug
|
||||||
|
);
|
||||||
|
|
||||||
const { shouldManageTags, awsParameterStoreTagsRecord } = await getParameterStoreTagsRecord(
|
const { shouldManageTags, awsParameterStoreTagsRecord } = await getParameterStoreTagsRecord(
|
||||||
ssm,
|
ssm,
|
||||||
@@ -400,22 +451,32 @@ export const AwsParameterStoreSyncFns = {
|
|||||||
await deleteParametersBatch(ssm, parametersToDelete);
|
await deleteParametersBatch(ssm, parametersToDelete);
|
||||||
},
|
},
|
||||||
getSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials): Promise<TSecretMap> => {
|
getSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials): Promise<TSecretMap> => {
|
||||||
const { destinationConfig } = secretSync;
|
const { destinationConfig, syncOptions, environment } = secretSync;
|
||||||
|
|
||||||
const ssm = await getSSM(secretSync);
|
const ssm = await getSSM(secretSync);
|
||||||
|
|
||||||
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path);
|
const awsParameterStoreSecretsRecord = await getParametersByPath(
|
||||||
|
ssm,
|
||||||
|
destinationConfig.path,
|
||||||
|
syncOptions.keySchema,
|
||||||
|
environment!.slug
|
||||||
|
);
|
||||||
|
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(awsParameterStoreSecretsRecord).map(([key, value]) => [key, { value: value.Value ?? "" }])
|
Object.entries(awsParameterStoreSecretsRecord).map(([key, value]) => [key, { value: value.Value ?? "" }])
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
removeSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => {
|
removeSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => {
|
||||||
const { destinationConfig } = secretSync;
|
const { destinationConfig, syncOptions, environment } = secretSync;
|
||||||
|
|
||||||
const ssm = await getSSM(secretSync);
|
const ssm = await getSSM(secretSync);
|
||||||
|
|
||||||
const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path);
|
const awsParameterStoreSecretsRecord = await getParametersByPath(
|
||||||
|
ssm,
|
||||||
|
destinationConfig.path,
|
||||||
|
syncOptions.keySchema,
|
||||||
|
environment!.slug
|
||||||
|
);
|
||||||
|
|
||||||
const parametersToDelete: AWS.SSM.Parameter[] = [];
|
const parametersToDelete: AWS.SSM.Parameter[] = [];
|
||||||
|
|
||||||
|
@@ -386,7 +386,15 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
actorId,
|
actorId,
|
||||||
actor,
|
actor,
|
||||||
projectId,
|
projectId,
|
||||||
environmentSlug: folder.environment.slug
|
environmentSlug: folder.environment.slug,
|
||||||
|
event: {
|
||||||
|
created: {
|
||||||
|
secretId: secret.id,
|
||||||
|
environment: folder.environment.slug,
|
||||||
|
secretKey: secret.key,
|
||||||
|
secretPath
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,7 +624,15 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
actor,
|
actor,
|
||||||
projectId,
|
projectId,
|
||||||
orgId: actorOrgId,
|
orgId: actorOrgId,
|
||||||
environmentSlug: folder.environment.slug
|
environmentSlug: folder.environment.slug,
|
||||||
|
event: {
|
||||||
|
updated: {
|
||||||
|
secretId: secret.id,
|
||||||
|
environment: folder.environment.slug,
|
||||||
|
secretKey: secret.key,
|
||||||
|
secretPath
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -728,7 +744,15 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
actor,
|
actor,
|
||||||
projectId,
|
projectId,
|
||||||
orgId: actorOrgId,
|
orgId: actorOrgId,
|
||||||
environmentSlug: folder.environment.slug
|
environmentSlug: folder.environment.slug,
|
||||||
|
event: {
|
||||||
|
deleted: {
|
||||||
|
secretId: secretToDelete.id,
|
||||||
|
environment: folder.environment.slug,
|
||||||
|
secretKey: secretToDelete.key,
|
||||||
|
secretPath
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1708,7 +1732,15 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
secretPath,
|
secretPath,
|
||||||
projectId,
|
projectId,
|
||||||
orgId: actorOrgId,
|
orgId: actorOrgId,
|
||||||
environmentSlug: folder.environment.slug
|
environmentSlug: folder.environment.slug,
|
||||||
|
event: {
|
||||||
|
created: newSecrets.map((el) => ({
|
||||||
|
secretId: el.id,
|
||||||
|
secretKey: el.key,
|
||||||
|
secretPath,
|
||||||
|
environment: folder.environment.slug
|
||||||
|
}))
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return newSecrets.map((el) => {
|
return newSecrets.map((el) => {
|
||||||
@@ -2075,7 +2107,15 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
secretPath: el.path,
|
secretPath: el.path,
|
||||||
projectId,
|
projectId,
|
||||||
orgId: actorOrgId,
|
orgId: actorOrgId,
|
||||||
environmentSlug: environment
|
environmentSlug: environment,
|
||||||
|
event: {
|
||||||
|
updated: updatedSecrets.map((sec) => ({
|
||||||
|
secretId: sec.id,
|
||||||
|
secretKey: sec.key,
|
||||||
|
secretPath: sec.secretPath,
|
||||||
|
environment
|
||||||
|
}))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
)
|
)
|
||||||
@@ -2214,7 +2254,15 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
secretPath,
|
secretPath,
|
||||||
projectId,
|
projectId,
|
||||||
orgId: actorOrgId,
|
orgId: actorOrgId,
|
||||||
environmentSlug: folder.environment.slug
|
environmentSlug: folder.environment.slug,
|
||||||
|
event: {
|
||||||
|
deleted: secretsDeleted.map((el) => ({
|
||||||
|
secretId: el.id,
|
||||||
|
secretKey: el.key,
|
||||||
|
secretPath,
|
||||||
|
environment: folder.environment.slug
|
||||||
|
}))
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||||
@@ -2751,7 +2799,13 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
secretPath: destinationFolder.path,
|
secretPath: destinationFolder.path,
|
||||||
environmentSlug: destinationFolder.environment.slug,
|
environmentSlug: destinationFolder.environment.slug,
|
||||||
actorId,
|
actorId,
|
||||||
actor
|
actor,
|
||||||
|
event: {
|
||||||
|
importMutation: {
|
||||||
|
secretPath: sourceFolder.path,
|
||||||
|
environment: sourceFolder.environment.slug
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2763,7 +2817,13 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
secretPath: sourceFolder.path,
|
secretPath: sourceFolder.path,
|
||||||
environmentSlug: sourceFolder.environment.slug,
|
environmentSlug: sourceFolder.environment.slug,
|
||||||
actorId,
|
actorId,
|
||||||
actor
|
actor,
|
||||||
|
event: {
|
||||||
|
importMutation: {
|
||||||
|
secretPath: sourceFolder.path,
|
||||||
|
environment: sourceFolder.environment.slug
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -5,6 +5,7 @@ import { Knex } from "knex";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ProjectMembershipRole,
|
ProjectMembershipRole,
|
||||||
|
ProjectType,
|
||||||
ProjectUpgradeStatus,
|
ProjectUpgradeStatus,
|
||||||
ProjectVersion,
|
ProjectVersion,
|
||||||
SecretType,
|
SecretType,
|
||||||
@@ -12,6 +13,9 @@ import {
|
|||||||
TSecretVersionsV2
|
TSecretVersionsV2
|
||||||
} from "@app/db/schemas";
|
} from "@app/db/schemas";
|
||||||
import { Actor, EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types";
|
import { Actor, EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types";
|
||||||
|
import { TEventBusService } from "@app/ee/services/event/event-bus-service";
|
||||||
|
import { BusEventName, PublishableEvent, TopicName } from "@app/ee/services/event/types";
|
||||||
|
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||||
import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal";
|
import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal";
|
||||||
import { TSecretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal";
|
import { TSecretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal";
|
||||||
import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal";
|
import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal";
|
||||||
@@ -111,6 +115,8 @@ type TSecretQueueFactoryDep = {
|
|||||||
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
|
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
|
||||||
secretSyncQueue: Pick<TSecretSyncQueueFactory, "queueSecretSyncsSyncSecretsByPath">;
|
secretSyncQueue: Pick<TSecretSyncQueueFactory, "queueSecretSyncsSyncSecretsByPath">;
|
||||||
reminderService: Pick<TReminderServiceFactory, "createReminderInternal" | "deleteReminderBySecretId">;
|
reminderService: Pick<TReminderServiceFactory, "createReminderInternal" | "deleteReminderBySecretId">;
|
||||||
|
eventBusService: TEventBusService;
|
||||||
|
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TGetSecrets = {
|
export type TGetSecrets = {
|
||||||
@@ -172,7 +178,9 @@ export const secretQueueFactory = ({
|
|||||||
resourceMetadataDAL,
|
resourceMetadataDAL,
|
||||||
secretSyncQueue,
|
secretSyncQueue,
|
||||||
folderCommitService,
|
folderCommitService,
|
||||||
reminderService
|
reminderService,
|
||||||
|
eventBusService,
|
||||||
|
licenseService
|
||||||
}: TSecretQueueFactoryDep) => {
|
}: TSecretQueueFactoryDep) => {
|
||||||
const integrationMeter = opentelemetry.metrics.getMeter("Integrations");
|
const integrationMeter = opentelemetry.metrics.getMeter("Integrations");
|
||||||
const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", {
|
const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", {
|
||||||
@@ -534,17 +542,70 @@ export const secretQueueFactory = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const publishEvents = async (event: PublishableEvent) => {
|
||||||
|
if (event.created) {
|
||||||
|
await eventBusService.publish(TopicName.CoreServers, {
|
||||||
|
type: ProjectType.SecretManager,
|
||||||
|
source: "infiscal",
|
||||||
|
data: {
|
||||||
|
event: BusEventName.CreateSecret,
|
||||||
|
payload: event.created
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.updated) {
|
||||||
|
await eventBusService.publish(TopicName.CoreServers, {
|
||||||
|
type: ProjectType.SecretManager,
|
||||||
|
source: "infiscal",
|
||||||
|
data: {
|
||||||
|
event: BusEventName.UpdateSecret,
|
||||||
|
payload: event.updated
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.deleted) {
|
||||||
|
await eventBusService.publish(TopicName.CoreServers, {
|
||||||
|
type: ProjectType.SecretManager,
|
||||||
|
source: "infiscal",
|
||||||
|
data: {
|
||||||
|
event: BusEventName.DeleteSecret,
|
||||||
|
payload: event.deleted
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.importMutation) {
|
||||||
|
await eventBusService.publish(TopicName.CoreServers, {
|
||||||
|
type: ProjectType.SecretManager,
|
||||||
|
source: "infiscal",
|
||||||
|
data: {
|
||||||
|
event: BusEventName.ImportMutation,
|
||||||
|
payload: event.importMutation
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const syncSecrets = async <T extends boolean = false>({
|
const syncSecrets = async <T extends boolean = false>({
|
||||||
// seperate de-dupe queue for integration sync and replication sync
|
// seperate de-dupe queue for integration sync and replication sync
|
||||||
_deDupeQueue: deDupeQueue = {},
|
_deDupeQueue: deDupeQueue = {},
|
||||||
_depth: depth = 0,
|
_depth: depth = 0,
|
||||||
_deDupeReplicationQueue: deDupeReplicationQueue = {},
|
_deDupeReplicationQueue: deDupeReplicationQueue = {},
|
||||||
|
event,
|
||||||
...dto
|
...dto
|
||||||
}: TSyncSecretsDTO<T>) => {
|
}: TSyncSecretsDTO<T> & { event?: PublishableEvent }) => {
|
||||||
logger.info(
|
logger.info(
|
||||||
`syncSecrets: syncing project secrets where [projectId=${dto.projectId}] [environment=${dto.environmentSlug}] [path=${dto.secretPath}]`
|
`syncSecrets: syncing project secrets where [projectId=${dto.projectId}] [environment=${dto.environmentSlug}] [path=${dto.secretPath}]`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const plan = await licenseService.getPlan(dto.orgId);
|
||||||
|
|
||||||
|
if (event && plan.eventSubscriptions) {
|
||||||
|
await publishEvents(event);
|
||||||
|
}
|
||||||
|
|
||||||
const deDuplicationKey = uniqueSecretQueueKey(dto.environmentSlug, dto.secretPath);
|
const deDuplicationKey = uniqueSecretQueueKey(dto.environmentSlug, dto.secretPath);
|
||||||
if (
|
if (
|
||||||
!dto.excludeReplication
|
!dto.excludeReplication
|
||||||
@@ -565,7 +626,7 @@ export const secretQueueFactory = ({
|
|||||||
_deDupeQueue: deDupeQueue,
|
_deDupeQueue: deDupeQueue,
|
||||||
_deDupeReplicationQueue: deDupeReplicationQueue,
|
_deDupeReplicationQueue: deDupeReplicationQueue,
|
||||||
_depth: depth
|
_depth: depth
|
||||||
} as TSyncSecretsDTO,
|
} as unknown as TSyncSecretsDTO,
|
||||||
{
|
{
|
||||||
removeOnFail: true,
|
removeOnFail: true,
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
@@ -689,6 +750,7 @@ export const secretQueueFactory = ({
|
|||||||
isManual,
|
isManual,
|
||||||
projectId,
|
projectId,
|
||||||
secretPath,
|
secretPath,
|
||||||
|
|
||||||
depth = 1,
|
depth = 1,
|
||||||
deDupeQueue = {}
|
deDupeQueue = {}
|
||||||
} = job.data as TIntegrationSyncPayload;
|
} = job.data as TIntegrationSyncPayload;
|
||||||
@@ -738,7 +800,13 @@ export const secretQueueFactory = ({
|
|||||||
environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string,
|
environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string,
|
||||||
_deDupeQueue: deDupeQueue,
|
_deDupeQueue: deDupeQueue,
|
||||||
_depth: depth + 1,
|
_depth: depth + 1,
|
||||||
excludeReplication: true
|
excludeReplication: true,
|
||||||
|
event: {
|
||||||
|
importMutation: {
|
||||||
|
secretPath: foldersGroupedById[folderId][0]?.path as string,
|
||||||
|
environment: foldersGroupedById[folderId][0]?.environmentSlug as string
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -791,7 +859,13 @@ export const secretQueueFactory = ({
|
|||||||
environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
|
environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
|
||||||
_deDupeQueue: deDupeQueue,
|
_deDupeQueue: deDupeQueue,
|
||||||
_depth: depth + 1,
|
_depth: depth + 1,
|
||||||
excludeReplication: true
|
excludeReplication: true,
|
||||||
|
event: {
|
||||||
|
importMutation: {
|
||||||
|
secretPath: referencedFoldersGroupedById[folderId][0]?.path as string,
|
||||||
|
environment: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@@ -115,6 +115,44 @@ User Note: ${payload.note}`
|
|||||||
payloadBlocks
|
payloadBlocks
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case TriggerFeature.ACCESS_REQUEST_UPDATED: {
|
||||||
|
const { payload } = notification;
|
||||||
|
const messageBody = `${payload.editorFullName} (${payload.editorEmail}) has updated the ${
|
||||||
|
payload.isTemporary ? "temporary" : "permanent"
|
||||||
|
} access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.
|
||||||
|
|
||||||
|
The following permissions are requested: ${payload.permissions.join(", ")}
|
||||||
|
|
||||||
|
View the request and approve or deny it <${payload.approvalUrl}|here>.${
|
||||||
|
payload.editNote
|
||||||
|
? `
|
||||||
|
Editor Note: ${payload.editNote}`
|
||||||
|
: ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const payloadBlocks = [
|
||||||
|
{
|
||||||
|
type: "header",
|
||||||
|
text: {
|
||||||
|
type: "plain_text",
|
||||||
|
text: "Updated access approval request pending for review",
|
||||||
|
emoji: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "section",
|
||||||
|
text: {
|
||||||
|
type: "mrkdwn",
|
||||||
|
text: messageBody
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
payloadMessage: messageBody,
|
||||||
|
payloadBlocks
|
||||||
|
};
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
message: "Slack notification type not supported."
|
message: "Slack notification type not supported."
|
||||||
|
@@ -0,0 +1,95 @@
|
|||||||
|
import { Heading, Section, Text } from "@react-email/components";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { BaseButton } from "./BaseButton";
|
||||||
|
import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper";
|
||||||
|
import { BaseLink } from "./BaseLink";
|
||||||
|
|
||||||
|
interface AccessApprovalRequestUpdatedTemplateProps
|
||||||
|
extends Omit<BaseEmailWrapperProps, "title" | "preview" | "children"> {
|
||||||
|
projectName: string;
|
||||||
|
requesterFullName: string;
|
||||||
|
requesterEmail: string;
|
||||||
|
isTemporary: boolean;
|
||||||
|
secretPath: string;
|
||||||
|
environment: string;
|
||||||
|
expiresIn: string;
|
||||||
|
permissions: string[];
|
||||||
|
editNote: string;
|
||||||
|
editorFullName: string;
|
||||||
|
editorEmail: string;
|
||||||
|
approvalUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccessApprovalRequestUpdatedTemplate = ({
|
||||||
|
projectName,
|
||||||
|
siteUrl,
|
||||||
|
requesterFullName,
|
||||||
|
requesterEmail,
|
||||||
|
isTemporary,
|
||||||
|
secretPath,
|
||||||
|
environment,
|
||||||
|
expiresIn,
|
||||||
|
permissions,
|
||||||
|
editNote,
|
||||||
|
editorEmail,
|
||||||
|
editorFullName,
|
||||||
|
approvalUrl
|
||||||
|
}: AccessApprovalRequestUpdatedTemplateProps) => {
|
||||||
|
return (
|
||||||
|
<BaseEmailWrapper
|
||||||
|
title="Access Approval Request Update"
|
||||||
|
preview="An access approval request was updated and requires your review."
|
||||||
|
siteUrl={siteUrl}
|
||||||
|
>
|
||||||
|
<Heading className="text-black text-[18px] leading-[28px] text-center font-normal p-0 mx-0">
|
||||||
|
An access approval request was updated and is pending your review for the project <strong>{projectName}</strong>
|
||||||
|
</Heading>
|
||||||
|
<Section className="px-[24px] mb-[28px] mt-[36px] pt-[12px] pb-[8px] border border-solid border-gray-200 rounded-md bg-gray-50">
|
||||||
|
<Text className="text-black text-[14px] leading-[24px]">
|
||||||
|
<strong>{editorFullName}</strong> (<BaseLink href={`mailto:${editorEmail}`}>{editorEmail}</BaseLink>) has
|
||||||
|
updated the access request submitted by <strong>{requesterFullName}</strong> (
|
||||||
|
<BaseLink href={`mailto:${requesterEmail}`}>{requesterEmail}</BaseLink>) for <strong>{secretPath}</strong> in
|
||||||
|
the <strong>{environment}</strong> environment.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isTemporary && (
|
||||||
|
<Text className="text-[14px] text-red-600 leading-[24px]">
|
||||||
|
<strong>This access will expire {expiresIn} after approval.</strong>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text className="text-[14px] leading-[24px] mb-[4px]">
|
||||||
|
<strong>The following permissions are requested:</strong>
|
||||||
|
</Text>
|
||||||
|
{permissions.map((permission) => (
|
||||||
|
<Text key={permission} className="text-[14px] my-[2px] leading-[24px]">
|
||||||
|
- {permission}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
<Text className="text-[14px] text-slate-700 leading-[24px]">
|
||||||
|
<strong className="text-black">Editor Note:</strong> "{editNote}"
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Section className="text-center">
|
||||||
|
<BaseButton href={approvalUrl}>Review Request</BaseButton>
|
||||||
|
</Section>
|
||||||
|
</BaseEmailWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccessApprovalRequestUpdatedTemplate;
|
||||||
|
|
||||||
|
AccessApprovalRequestUpdatedTemplate.PreviewProps = {
|
||||||
|
requesterFullName: "Abigail Williams",
|
||||||
|
requesterEmail: "abigail@infisical.com",
|
||||||
|
isTemporary: true,
|
||||||
|
secretPath: "/api/secrets",
|
||||||
|
environment: "Production",
|
||||||
|
siteUrl: "https://infisical.com",
|
||||||
|
projectName: "Example Project",
|
||||||
|
expiresIn: "1 day",
|
||||||
|
permissions: ["Read Secret", "Delete Project", "Create Dynamic Secret"],
|
||||||
|
editNote: "Too permissive, they only need 3 days",
|
||||||
|
editorEmail: "john@infisical.com",
|
||||||
|
editorFullName: "John Smith"
|
||||||
|
} as AccessApprovalRequestUpdatedTemplateProps;
|
@@ -1,4 +1,5 @@
|
|||||||
export * from "./AccessApprovalRequestTemplate";
|
export * from "./AccessApprovalRequestTemplate";
|
||||||
|
export * from "./AccessApprovalRequestUpdatedTemplate";
|
||||||
export * from "./EmailMfaTemplate";
|
export * from "./EmailMfaTemplate";
|
||||||
export * from "./EmailVerificationTemplate";
|
export * from "./EmailVerificationTemplate";
|
||||||
export * from "./ExternalImportFailedTemplate";
|
export * from "./ExternalImportFailedTemplate";
|
||||||
|
@@ -8,6 +8,7 @@ import { logger } from "@app/lib/logger";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
AccessApprovalRequestTemplate,
|
AccessApprovalRequestTemplate,
|
||||||
|
AccessApprovalRequestUpdatedTemplate,
|
||||||
EmailMfaTemplate,
|
EmailMfaTemplate,
|
||||||
EmailVerificationTemplate,
|
EmailVerificationTemplate,
|
||||||
ExternalImportFailedTemplate,
|
ExternalImportFailedTemplate,
|
||||||
@@ -54,6 +55,7 @@ export enum SmtpTemplates {
|
|||||||
EmailMfa = "emailMfa",
|
EmailMfa = "emailMfa",
|
||||||
UnlockAccount = "unlockAccount",
|
UnlockAccount = "unlockAccount",
|
||||||
AccessApprovalRequest = "accessApprovalRequest",
|
AccessApprovalRequest = "accessApprovalRequest",
|
||||||
|
AccessApprovalRequestUpdated = "accessApprovalRequestUpdated",
|
||||||
AccessSecretRequestBypassed = "accessSecretRequestBypassed",
|
AccessSecretRequestBypassed = "accessSecretRequestBypassed",
|
||||||
SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview",
|
SecretApprovalRequestNeedsReview = "secretApprovalRequestNeedsReview",
|
||||||
// HistoricalSecretList = "historicalSecretLeakIncident", not used anymore?
|
// HistoricalSecretList = "historicalSecretLeakIncident", not used anymore?
|
||||||
@@ -96,6 +98,7 @@ const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = {
|
|||||||
[SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate,
|
[SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate,
|
||||||
[SmtpTemplates.EmailMfa]: EmailMfaTemplate,
|
[SmtpTemplates.EmailMfa]: EmailMfaTemplate,
|
||||||
[SmtpTemplates.AccessApprovalRequest]: AccessApprovalRequestTemplate,
|
[SmtpTemplates.AccessApprovalRequest]: AccessApprovalRequestTemplate,
|
||||||
|
[SmtpTemplates.AccessApprovalRequestUpdated]: AccessApprovalRequestUpdatedTemplate,
|
||||||
[SmtpTemplates.EmailVerification]: EmailVerificationTemplate,
|
[SmtpTemplates.EmailVerification]: EmailVerificationTemplate,
|
||||||
[SmtpTemplates.ExternalImportFailed]: ExternalImportFailedTemplate,
|
[SmtpTemplates.ExternalImportFailed]: ExternalImportFailedTemplate,
|
||||||
[SmtpTemplates.ExternalImportStarted]: ExternalImportStartedTemplate,
|
[SmtpTemplates.ExternalImportStarted]: ExternalImportStartedTemplate,
|
||||||
|
@@ -11,7 +11,6 @@ import {
|
|||||||
validateOverrides
|
validateOverrides
|
||||||
} from "@app/lib/config/env";
|
} from "@app/lib/config/env";
|
||||||
import { crypto } from "@app/lib/crypto/cryptography";
|
import { crypto } from "@app/lib/crypto/cryptography";
|
||||||
import { generateUserSrpKeys, getUserPrivateKey } from "@app/lib/crypto/srp";
|
|
||||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||||
import { logger } from "@app/lib/logger";
|
import { logger } from "@app/lib/logger";
|
||||||
import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
|
import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
|
||||||
@@ -465,43 +464,15 @@ export const superAdminServiceFactory = ({
|
|||||||
return updatedServerCfg;
|
return updatedServerCfg;
|
||||||
};
|
};
|
||||||
|
|
||||||
const adminSignUp = async ({
|
const adminSignUp = async ({ lastName, firstName, email, password, ip, userAgent }: TAdminSignUpDTO) => {
|
||||||
lastName,
|
|
||||||
firstName,
|
|
||||||
email,
|
|
||||||
salt,
|
|
||||||
password,
|
|
||||||
verifier,
|
|
||||||
publicKey,
|
|
||||||
protectedKey,
|
|
||||||
protectedKeyIV,
|
|
||||||
protectedKeyTag,
|
|
||||||
encryptedPrivateKey,
|
|
||||||
encryptedPrivateKeyIV,
|
|
||||||
encryptedPrivateKeyTag,
|
|
||||||
ip,
|
|
||||||
userAgent
|
|
||||||
}: TAdminSignUpDTO) => {
|
|
||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
|
|
||||||
const sanitizedEmail = email.trim().toLowerCase();
|
const sanitizedEmail = email.trim().toLowerCase();
|
||||||
const existingUser = await userDAL.findOne({ username: sanitizedEmail });
|
const existingUser = await userDAL.findOne({ username: sanitizedEmail });
|
||||||
if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exists" });
|
if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exists" });
|
||||||
|
|
||||||
const privateKey = await getUserPrivateKey(password, {
|
|
||||||
encryptionVersion: 2,
|
|
||||||
salt,
|
|
||||||
protectedKey,
|
|
||||||
protectedKeyIV,
|
|
||||||
protectedKeyTag,
|
|
||||||
encryptedPrivateKey,
|
|
||||||
iv: encryptedPrivateKeyIV,
|
|
||||||
tag: encryptedPrivateKeyTag
|
|
||||||
});
|
|
||||||
|
|
||||||
const hashedPassword = await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS);
|
const hashedPassword = await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS);
|
||||||
|
|
||||||
const { iv, tag, ciphertext, encoding } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(privateKey);
|
|
||||||
const userInfo = await userDAL.transaction(async (tx) => {
|
const userInfo = await userDAL.transaction(async (tx) => {
|
||||||
const newUser = await userDAL.create(
|
const newUser = await userDAL.create(
|
||||||
{
|
{
|
||||||
@@ -519,25 +490,13 @@ export const superAdminServiceFactory = ({
|
|||||||
);
|
);
|
||||||
const userEnc = await userDAL.createUserEncryption(
|
const userEnc = await userDAL.createUserEncryption(
|
||||||
{
|
{
|
||||||
salt,
|
|
||||||
encryptionVersion: 2,
|
encryptionVersion: 2,
|
||||||
protectedKey,
|
|
||||||
protectedKeyIV,
|
|
||||||
protectedKeyTag,
|
|
||||||
publicKey,
|
|
||||||
encryptedPrivateKey,
|
|
||||||
iv: encryptedPrivateKeyIV,
|
|
||||||
tag: encryptedPrivateKeyTag,
|
|
||||||
verifier,
|
|
||||||
userId: newUser.id,
|
userId: newUser.id,
|
||||||
hashedPassword,
|
hashedPassword
|
||||||
serverEncryptedPrivateKey: ciphertext,
|
|
||||||
serverEncryptedPrivateKeyIV: iv,
|
|
||||||
serverEncryptedPrivateKeyTag: tag,
|
|
||||||
serverEncryptedPrivateKeyEncoding: encoding
|
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
|
|
||||||
return { user: newUser, enc: userEnc };
|
return { user: newUser, enc: userEnc };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -587,26 +546,14 @@ export const superAdminServiceFactory = ({
|
|||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
const { tag, encoding, ciphertext, iv } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(password);
|
|
||||||
const encKeys = await generateUserSrpKeys(sanitizedEmail, password);
|
const hashedPassword = await crypto.hashing().createHash(password, appCfg.SALT_ROUNDS);
|
||||||
|
|
||||||
const userEnc = await userDAL.createUserEncryption(
|
const userEnc = await userDAL.createUserEncryption(
|
||||||
{
|
{
|
||||||
userId: newUser.id,
|
userId: newUser.id,
|
||||||
encryptionVersion: 2,
|
encryptionVersion: 2,
|
||||||
protectedKey: encKeys.protectedKey,
|
hashedPassword
|
||||||
protectedKeyIV: encKeys.protectedKeyIV,
|
|
||||||
protectedKeyTag: encKeys.protectedKeyTag,
|
|
||||||
publicKey: encKeys.publicKey,
|
|
||||||
encryptedPrivateKey: encKeys.encryptedPrivateKey,
|
|
||||||
iv: encKeys.encryptedPrivateKeyIV,
|
|
||||||
tag: encKeys.encryptedPrivateKeyTag,
|
|
||||||
salt: encKeys.salt,
|
|
||||||
verifier: encKeys.verifier,
|
|
||||||
serverEncryptedPrivateKeyEncoding: encoding,
|
|
||||||
serverEncryptedPrivateKeyTag: tag,
|
|
||||||
serverEncryptedPrivateKeyIV: iv,
|
|
||||||
serverEncryptedPrivateKey: ciphertext
|
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
|
@@ -3,17 +3,8 @@ import { TEnvConfig } from "@app/lib/config/env";
|
|||||||
export type TAdminSignUpDTO = {
|
export type TAdminSignUpDTO = {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
publicKey: string;
|
|
||||||
salt: string;
|
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
verifier: string;
|
|
||||||
firstName: string;
|
firstName: string;
|
||||||
protectedKey: string;
|
|
||||||
protectedKeyIV: string;
|
|
||||||
protectedKeyTag: string;
|
|
||||||
encryptedPrivateKey: string;
|
|
||||||
encryptedPrivateKeyIV: string;
|
|
||||||
encryptedPrivateKeyTag: string;
|
|
||||||
ip: string;
|
ip: string;
|
||||||
userAgent: string;
|
userAgent: string;
|
||||||
};
|
};
|
||||||
|
@@ -5,7 +5,10 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
env: {
|
env: {
|
||||||
NODE_ENV: "test"
|
NODE_ENV: "test",
|
||||||
|
E2E_TEST_ORACLE_DB_19_HOST: process.env.E2E_TEST_ORACLE_DB_19_HOST!,
|
||||||
|
E2E_TEST_ORACLE_DB_19_USERNAME: process.env.E2E_TEST_ORACLE_DB_19_USERNAME!,
|
||||||
|
E2E_TEST_ORACLE_DB_19_PASSWORD: process.env.E2E_TEST_ORACLE_DB_19_PASSWORD!
|
||||||
},
|
},
|
||||||
environment: "./e2e-test/vitest-environment-knex.ts",
|
environment: "./e2e-test/vitest-environment-knex.ts",
|
||||||
include: ["./e2e-test/**/*.spec.ts"],
|
include: ["./e2e-test/**/*.spec.ts"],
|
||||||
|
157
docker-compose.e2e-dbs.yml
Normal file
157
docker-compose.e2e-dbs.yml
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Oracle Databases
|
||||||
|
oracle-db-23.8:
|
||||||
|
image: container-registry.oracle.com/database/free:23.8.0.0
|
||||||
|
container_name: oracle-db-23.8
|
||||||
|
ports:
|
||||||
|
- "1521:1521"
|
||||||
|
environment:
|
||||||
|
- ORACLE_PDB=pdb
|
||||||
|
- ORACLE_PWD=pdb-password
|
||||||
|
volumes:
|
||||||
|
- oracle-data-23.8:/opt/oracle/oradata
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "sqlplus", "-L", "system/pdb-password@//localhost:1521/FREEPDB1", "<<<", "SELECT 1 FROM DUAL;"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 30
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
# MySQL Databases
|
||||||
|
mysql-8.4.6:
|
||||||
|
image: mysql:8.4.6
|
||||||
|
container_name: mysql-8.4.6
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
environment:
|
||||||
|
- MYSQL_ROOT_PASSWORD=mysql-test
|
||||||
|
- MYSQL_DATABASE=mysql-test
|
||||||
|
- MYSQL_ROOT_HOST=%
|
||||||
|
- MYSQL_USER=mysql-test
|
||||||
|
- MYSQL_PASSWORD=mysql-test
|
||||||
|
volumes:
|
||||||
|
- mysql-data-8.4.6:/var/lib/mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "mysql-test", "-pmysql-test"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 30
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
mysql-8.0.29:
|
||||||
|
image: mysql:8.0.29
|
||||||
|
container_name: mysql-8.0.28
|
||||||
|
ports:
|
||||||
|
- "3307:3306"
|
||||||
|
environment:
|
||||||
|
- MYSQL_ROOT_PASSWORD=mysql-test
|
||||||
|
- MYSQL_DATABASE=mysql-test
|
||||||
|
- MYSQL_ROOT_HOST=%
|
||||||
|
- MYSQL_USER=mysql-test
|
||||||
|
- MYSQL_PASSWORD=mysql-test
|
||||||
|
volumes:
|
||||||
|
- mysql-data-8.0.29:/var/lib/mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "mysql-test", "-pmysql-test"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 30
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
mysql-5.7.31:
|
||||||
|
image: mysql:5.7.31
|
||||||
|
container_name: mysql-5.7.31
|
||||||
|
platform: linux/amd64
|
||||||
|
ports:
|
||||||
|
- "3308:3306"
|
||||||
|
environment:
|
||||||
|
- MYSQL_ROOT_PASSWORD=mysql-test
|
||||||
|
- MYSQL_DATABASE=mysql-test
|
||||||
|
- MYSQL_ROOT_HOST=%
|
||||||
|
- MYSQL_USER=mysql-test
|
||||||
|
- MYSQL_PASSWORD=mysql-test
|
||||||
|
volumes:
|
||||||
|
- mysql-data-5.7.31:/var/lib/mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "mysql-test", "-pmysql-test"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 30
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
|
||||||
|
# PostgreSQL Databases
|
||||||
|
postgres-17:
|
||||||
|
image: postgres:17
|
||||||
|
platform: linux/amd64
|
||||||
|
container_name: postgres-17
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=postgres-test
|
||||||
|
- POSTGRES_USER=postgres-test
|
||||||
|
- POSTGRES_PASSWORD=postgres-test
|
||||||
|
volumes:
|
||||||
|
- postgres-data-17:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres-test -d postgres-test"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 30
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
postgres-16:
|
||||||
|
image: postgres:16
|
||||||
|
platform: linux/amd64
|
||||||
|
container_name: postgres-16
|
||||||
|
ports:
|
||||||
|
- "5434:5432"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=postgres-test
|
||||||
|
- POSTGRES_USER=postgres-test
|
||||||
|
- POSTGRES_PASSWORD=postgres-test
|
||||||
|
volumes:
|
||||||
|
- postgres-data-16:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres-test -d postgres-test"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 30
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
postgres-10.12:
|
||||||
|
image: postgres:10.12
|
||||||
|
platform: linux/amd64
|
||||||
|
container_name: postgres-10.12
|
||||||
|
ports:
|
||||||
|
- "5435:5432"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=postgres-test
|
||||||
|
- POSTGRES_USER=postgres-test
|
||||||
|
- POSTGRES_PASSWORD=postgres-test
|
||||||
|
volumes:
|
||||||
|
- postgres-data-10.12:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres-test -d postgres-test"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 30
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
oracle-data-23.8:
|
||||||
|
mysql-data-8.4.6:
|
||||||
|
mysql-data-8.0.29:
|
||||||
|
mysql-data-5.7.31:
|
||||||
|
postgres-data-17:
|
||||||
|
postgres-data-16:
|
||||||
|
postgres-data-10.12:
|
@@ -416,6 +416,9 @@
|
|||||||
"pages": [
|
"pages": [
|
||||||
"documentation/platform/secrets-mgmt/project",
|
"documentation/platform/secrets-mgmt/project",
|
||||||
"documentation/platform/folder",
|
"documentation/platform/folder",
|
||||||
|
"documentation/platform/secret-versioning",
|
||||||
|
"documentation/platform/pit-recovery",
|
||||||
|
"documentation/platform/secret-reference",
|
||||||
{
|
{
|
||||||
"group": "Secret Rotation",
|
"group": "Secret Rotation",
|
||||||
"pages": [
|
"pages": [
|
||||||
@@ -439,6 +442,7 @@
|
|||||||
"documentation/platform/dynamic-secrets/aws-iam",
|
"documentation/platform/dynamic-secrets/aws-iam",
|
||||||
"documentation/platform/dynamic-secrets/azure-entra-id",
|
"documentation/platform/dynamic-secrets/azure-entra-id",
|
||||||
"documentation/platform/dynamic-secrets/cassandra",
|
"documentation/platform/dynamic-secrets/cassandra",
|
||||||
|
"documentation/platform/dynamic-secrets/couchbase",
|
||||||
"documentation/platform/dynamic-secrets/elastic-search",
|
"documentation/platform/dynamic-secrets/elastic-search",
|
||||||
"documentation/platform/dynamic-secrets/gcp-iam",
|
"documentation/platform/dynamic-secrets/gcp-iam",
|
||||||
"documentation/platform/dynamic-secrets/github",
|
"documentation/platform/dynamic-secrets/github",
|
||||||
@@ -458,7 +462,8 @@
|
|||||||
"documentation/platform/dynamic-secrets/kubernetes",
|
"documentation/platform/dynamic-secrets/kubernetes",
|
||||||
"documentation/platform/dynamic-secrets/vertica"
|
"documentation/platform/dynamic-secrets/vertica"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"documentation/platform/webhooks"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
259
docs/documentation/platform/dynamic-secrets/couchbase.mdx
Normal file
259
docs/documentation/platform/dynamic-secrets/couchbase.mdx
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
---
|
||||||
|
title: "Couchbase"
|
||||||
|
description: "Learn how to dynamically generate Couchbase Database user credentials."
|
||||||
|
---
|
||||||
|
|
||||||
|
The Infisical Couchbase dynamic secret allows you to generate Couchbase Cloud Database user credentials on demand based on configured roles and bucket access permissions.
|
||||||
|
|
||||||
|
## Prerequisite
|
||||||
|
|
||||||
|
Create an API Key in your Couchbase Cloud following the [official documentation](https://docs.couchbase.com/cloud/get-started/create-account.html#create-api-key).
|
||||||
|
|
||||||
|
<Info>The API Key must have permission to manage database users in your Couchbase Cloud organization and project.</Info>
|
||||||
|
|
||||||
|
## Set up Dynamic Secrets with Couchbase
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
<Step title="Open Secret Overview Dashboard">
|
||||||
|
Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret.
|
||||||
|
</Step>
|
||||||
|
<Step title="Click on the 'Add Dynamic Secret' button">
|
||||||
|

|
||||||
|
</Step>
|
||||||
|
<Step title="Select Couchbase">
|
||||||
|

|
||||||
|
</Step>
|
||||||
|
<Step title="Provide the inputs for dynamic secret parameters">
|
||||||
|
<ParamField path="Secret Name" type="string" required>
|
||||||
|
Name by which you want the secret to be referenced
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="Default TTL" type="string" required>
|
||||||
|
Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated)
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="Max TTL" type="string" required>
|
||||||
|
Maximum time-to-live for a generated secret
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="URL" type="string" required default="https://cloudapi.cloud.couchbase.com">
|
||||||
|
The Couchbase Cloud API URL
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="Organization ID" type="string" required>
|
||||||
|
Your Couchbase Cloud organization ID
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="Project ID" type="string" required>
|
||||||
|
Your Couchbase Cloud project ID
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="Cluster ID" type="string" required>
|
||||||
|
Your Couchbase Cloud cluster ID where users will be created
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="Roles" type="array" required>
|
||||||
|
Database credential roles to assign to the generated user. Available options:
|
||||||
|
- **read**: Read access to bucket data (alias for data_reader)
|
||||||
|
- **write**: Read and write access to bucket data (alias for data_writer)
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="Bucket Access" type="string" required default="*">
|
||||||
|
Specify bucket access configuration:
|
||||||
|
- Use `*` for access to all buckets
|
||||||
|
- Use comma-separated bucket names (e.g., `bucket1,bucket2,bucket3`) for specific buckets
|
||||||
|
- Use Advanced Bucket Configuration for granular scope and collection access
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="API Key" type="string" required>
|
||||||
|
Your Couchbase Cloud API Key for authentication
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</Step>
|
||||||
|
<Step title="(Optional) Advanced Configuration">
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<ParamField path="Advanced Bucket Configuration" type="boolean" default="false">
|
||||||
|
Enable advanced bucket configuration to specify granular access to buckets, scopes, and collections
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
When Advanced Bucket Configuration is enabled, you can configure:
|
||||||
|
|
||||||
|
<ParamField path="Buckets" type="array">
|
||||||
|
List of buckets with optional scope and collection specifications:
|
||||||
|
- **Bucket Name**: Name of the bucket (e.g., travel-sample)
|
||||||
|
- **Scopes**: Optional array of scopes within the bucket
|
||||||
|
- **Scope Name**: Name of the scope (e.g., inventory, _default)
|
||||||
|
- **Collections**: Optional array of collection names within the scope
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="Username Template" type="string" default="{{randomUsername}}">
|
||||||
|
Specifies a template for generating usernames. This field allows customization of how usernames are automatically created.
|
||||||
|
|
||||||
|
Allowed template variables are:
|
||||||
|
- `{{randomUsername}}`: Random username string
|
||||||
|
- `{{unixTimestamp}}`: Current Unix timestamp
|
||||||
|
- `{{identity.name}}`: Name of the identity that is generating the secret
|
||||||
|
- `{{random N}}`: Random string of N characters
|
||||||
|
|
||||||
|
Allowed template functions are:
|
||||||
|
- `truncate`: Truncates a string to a specified length
|
||||||
|
- `replace`: Replaces a substring with another value
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
```
|
||||||
|
{{randomUsername}} // infisical-3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX
|
||||||
|
{{unixTimestamp}} // 17490641580
|
||||||
|
{{identity.name}} // testuser
|
||||||
|
{{random 5}} // x9k2m
|
||||||
|
{{truncate identity.name 4}} // test
|
||||||
|
{{replace identity.name 'user' 'replace'}} // testreplace
|
||||||
|
```
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="Password Configuration" type="object">
|
||||||
|
Optional password generation requirements for Couchbase users:
|
||||||
|
|
||||||
|
<ParamField path="Password Length" type="number" default="12" min="8" max="128">
|
||||||
|
Length of the generated password
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="Character Requirements" type="object">
|
||||||
|
Minimum required character counts:
|
||||||
|
- **Lowercase Count**: Minimum lowercase letters (default: 1)
|
||||||
|
- **Uppercase Count**: Minimum uppercase letters (default: 1)
|
||||||
|
- **Digit Count**: Minimum digits (default: 1)
|
||||||
|
- **Symbol Count**: Minimum special characters (default: 1)
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<ParamField path="Allowed Symbols" type="string" default="!@#$%^()_+-=[]{}:,?/~`">
|
||||||
|
Special characters allowed in passwords. Cannot contain: `< > ; . * & | £`
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
<Info>
|
||||||
|
Couchbase password requirements: minimum 8 characters, maximum 128 characters, at least 1 uppercase, 1 lowercase, 1 digit, and 1 special character. Cannot contain: `< > ; . * & | £`
|
||||||
|
</Info>
|
||||||
|
</ParamField>
|
||||||
|
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
<Step title="Click 'Submit'">
|
||||||
|
After submitting the form, you will see a dynamic secret created in the dashboard.
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
If this step fails, you may need to verify your Couchbase Cloud API key permissions and organization/project/cluster IDs.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</Step>
|
||||||
|
<Step title="Generate dynamic secrets">
|
||||||
|
Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials.
|
||||||
|
To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item.
|
||||||
|
Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<Tip>
|
||||||
|
Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret.
|
||||||
|
</Tip>
|
||||||
|
|
||||||
|
Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Advanced Bucket Configuration Examples
|
||||||
|
|
||||||
|
The advanced bucket configuration allows you to specify granular access control:
|
||||||
|
|
||||||
|
### Example 1: Specific Bucket Access
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "travel-sample"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Bucket with Specific Scopes
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "travel-sample",
|
||||||
|
"scopes": [
|
||||||
|
{
|
||||||
|
"name": "inventory"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "_default"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Bucket with Scopes and Collections
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "travel-sample",
|
||||||
|
"scopes": [
|
||||||
|
{
|
||||||
|
"name": "inventory",
|
||||||
|
"collections": ["airport", "airline"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "_default",
|
||||||
|
"collections": ["users"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audit or Revoke Leases
|
||||||
|
|
||||||
|
Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard.
|
||||||
|
This will allow you to see the expiration time of the lease or delete a lease before its set time to live.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Renew Leases
|
||||||
|
|
||||||
|
To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** button as illustrated below.
|
||||||
|

|
||||||
|
|
||||||
|
<Warning>
|
||||||
|
Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret
|
||||||
|
</Warning>
|
||||||
|
|
||||||
|
## Couchbase Roles and Permissions
|
||||||
|
|
||||||
|
The Couchbase dynamic secret integration supports the following database credential roles:
|
||||||
|
|
||||||
|
- **read**: Provides read-only access to bucket data
|
||||||
|
- **write**: Provides read and write access to bucket data
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
These roles are specifically for database credentials and are different from Couchbase's administrative roles. They provide data-level access to buckets, scopes, and collections based on your configuration.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Invalid API Key**: Ensure your Couchbase Cloud API key has the necessary permissions to manage database users
|
||||||
|
2. **Invalid Organization/Project/Cluster IDs**: Verify that the provided IDs exist and are accessible with your API key
|
||||||
|
3. **Role Permission Errors**: Make sure you're using only the supported database credential roles (read, write)
|
||||||
|
4. **Bucket Access Issues**: Ensure the specified buckets exist in your cluster and are accessible
|
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: "Delivering Secrets"
|
title: "Fetching Secrets"
|
||||||
description: "Learn how to get secrets out of Infisical and into the systems, applications, and environments that need them."
|
description: "Learn how to deliver secrets from Infisical into the systems, applications, and environments that need them."
|
||||||
---
|
---
|
||||||
|
|
||||||
Once secrets are stored and scoped in Infisical, the next step is delivering them securely to the systems and applications that need them.
|
Once secrets are stored and scoped in Infisical, the next step is delivering them securely to the systems and applications that need them.
|
||||||
|
Binary file not shown.
After Width: | Height: | Size: 536 KiB |
Binary file not shown.
After Width: | Height: | Size: 517 KiB |
Binary file not shown.
After Width: | Height: | Size: 758 KiB |
Binary file not shown.
After Width: | Height: | Size: 524 KiB |
@@ -22,7 +22,7 @@ It can also automatically reload dependent Deployments resources whenever releva
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
The operator can be install via [Helm](https://helm.sh). Helm is a package manager for Kubernetes that allows you to define, install, and upgrade Kubernetes applications.
|
The operator can be installed via [Helm](https://helm.sh). Helm is a package manager for Kubernetes that allows you to define, install, and upgrade Kubernetes applications.
|
||||||
|
|
||||||
**Install the latest Helm repository**
|
**Install the latest Helm repository**
|
||||||
```bash
|
```bash
|
||||||
@@ -229,7 +229,7 @@ The managed secret created by the operator will not be deleted when the operator
|
|||||||
|
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<Tab title="Helm">
|
<Tab title="Helm">
|
||||||
Install Infisical Helm repository
|
Uninstall Infisical Helm repository
|
||||||
```bash
|
```bash
|
||||||
helm uninstall <release name>
|
helm uninstall <release name>
|
||||||
```
|
```
|
||||||
|
@@ -9,6 +9,10 @@ description: "Learn how to configure an AWS Parameter Store Sync for Infisical."
|
|||||||
- Create an [AWS Connection](/integrations/app-connections/aws) with the required **Secret Sync** permissions
|
- Create an [AWS Connection](/integrations/app-connections/aws) with the required **Secret Sync** permissions
|
||||||
- Ensure your network security policies allow incoming requests from Infisical to this secret sync provider, if network restrictions apply.
|
- Ensure your network security policies allow incoming requests from Infisical to this secret sync provider, if network restrictions apply.
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
For workflows involving large amounts of secrets or frequent syncs, we recommend increasing your [AWS Parameter Store throughput quota](https://docs.aws.amazon.com/systems-manager/latest/userguide/parameter-store-throughput.html) to avoid rate limiting.
|
||||||
|
</Note>
|
||||||
|
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<Tab title="Infisical UI">
|
<Tab title="Infisical UI">
|
||||||
1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button.
|
1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button.
|
||||||
|
@@ -169,6 +169,15 @@ Supports conditions and permission inversion
|
|||||||
| `edit` | Modify secret imports |
|
| `edit` | Modify secret imports |
|
||||||
| `delete` | Remove secret imports |
|
| `delete` | Remove secret imports |
|
||||||
|
|
||||||
|
#### Subject: `secret-events`
|
||||||
|
|
||||||
|
| Action | Description |
|
||||||
|
| ------------------------------- | ------------------------------------------------------------- |
|
||||||
|
| `subscribe-on-created` | Subscribe to events when secrets are created |
|
||||||
|
| `subscribe-on-updated` | Subscribe to events when secrets are updated |
|
||||||
|
| `subscribe-on-deleted` | Subscribe to events when secrets are deleted |
|
||||||
|
| `subscribe-on-import-mutations` | Subscribe to events when secrets are modified through imports |
|
||||||
|
|
||||||
#### Subject: `secret-rollback`
|
#### Subject: `secret-rollback`
|
||||||
|
|
||||||
| Action | Description |
|
| Action | Description |
|
||||||
@@ -179,9 +188,9 @@ Supports conditions and permission inversion
|
|||||||
#### Subject: `commits`
|
#### Subject: `commits`
|
||||||
|
|
||||||
| Action | Description |
|
| Action | Description |
|
||||||
| -------- | ---------------------------------- |
|
| ------------------ | --------------------------------------------------------------- |
|
||||||
| `read` | View commits and changes across folders |
|
| `read` | View commits and changes across folders |
|
||||||
| `perform-rollback` | Roll back commits changes and restore folders to previous state|
|
| `perform-rollback` | Roll back commits changes and restore folders to previous state |
|
||||||
|
|
||||||
#### Subject: `secret-approval`
|
#### Subject: `secret-approval`
|
||||||
|
|
||||||
@@ -264,7 +273,7 @@ Supports conditions and permission inversion
|
|||||||
#### Subject: `certificates`
|
#### Subject: `certificates`
|
||||||
|
|
||||||
| Action | Description |
|
| Action | Description |
|
||||||
| -------------------- | ----------------------------- |
|
| ------------------ | ----------------------------- |
|
||||||
| `read` | View certificates |
|
| `read` | View certificates |
|
||||||
| `read-private-key` | Read certificate private key |
|
| `read-private-key` | Read certificate private key |
|
||||||
| `create` | Issue new certificates |
|
| `create` | Issue new certificates |
|
||||||
@@ -331,7 +340,7 @@ Supports conditions and permission inversion
|
|||||||
#### Subject: `secret-scanning-data-sources`
|
#### Subject: `secret-scanning-data-sources`
|
||||||
|
|
||||||
| Action | Description |
|
| Action | Description |
|
||||||
| -------- | ---------------------------------------------------- |
|
| ---------------------------- | -------------------------------- |
|
||||||
| `read-data-sources` | View Data Sources |
|
| `read-data-sources` | View Data Sources |
|
||||||
| `create-data-sources` | Create new Data Sources |
|
| `create-data-sources` | Create new Data Sources |
|
||||||
| `edit-data-sources` | Modify Data Sources |
|
| `edit-data-sources` | Modify Data Sources |
|
||||||
@@ -343,14 +352,13 @@ Supports conditions and permission inversion
|
|||||||
#### Subject: `secret-scanning-findings`
|
#### Subject: `secret-scanning-findings`
|
||||||
|
|
||||||
| Action | Description |
|
| Action | Description |
|
||||||
| -------- | --------------------------------- |
|
| ----------------- | ------------------------------- |
|
||||||
| `read-findings` | View Secret Scanning Findings |
|
| `read-findings` | View Secret Scanning Findings |
|
||||||
| `update-findings` | Update Secret Scanning Findings |
|
| `update-findings` | Update Secret Scanning Findings |
|
||||||
|
|
||||||
|
|
||||||
#### Subject: `secret-scanning-configs`
|
#### Subject: `secret-scanning-configs`
|
||||||
|
|
||||||
| Action | Description |
|
| Action | Description |
|
||||||
| ---------------- | ------------------------------------------------ |
|
| ---------------- | -------------------------------------------- |
|
||||||
| `read-configs` | View Secret Scanning Project Configuration |
|
| `read-configs` | View Secret Scanning Project Configuration |
|
||||||
| `update-configs` | Update Secret Scanning Project Configuration |
|
| `update-configs` | Update Secret Scanning Project Configuration |
|
||||||
|
@@ -92,12 +92,11 @@ Infisical Cloud utilizes several strategies to ensure high availability, leverag
|
|||||||
|
|
||||||
## Cross-Region Replication for Disaster Recovery (Infisical Cloud)
|
## Cross-Region Replication for Disaster Recovery (Infisical Cloud)
|
||||||
|
|
||||||
To handle regional failures, Infisical Cloud keeps standby regions updated and ready to take over when needed.
|
To handle regional failures, Infisical Cloud keeps backups both within AWS and across cloud providers in GCP updated and ready to take over when needed.
|
||||||
|
|
||||||
- ElastiCache (Redis): Data is replicated across regions using AWS Global Datastore, keeping cached data consistent and available even if a primary region goes down.
|
- ElastiCache (Redis): Data is replicated across regions using AWS Global Datastore, keeping cached data consistent and available even if a primary region goes down.
|
||||||
- RDS (PostgreSQL): Cross-region read replicas ensure database data is available in multiple locations, allowing for failover in case of a regional outage.
|
- RDS (PostgreSQL): Cross-region read replicas ensure database data is available in multiple AWS locations, with additional replication to GCP for multi-cloud disaster recovery, allowing for failover in case of a regional outage or cloud provider issues.
|
||||||
|
|
||||||
With standby regions and automated failovers in place, Infisical Cloud faces minimal service disruptions even during large-scale outages.
|
|
||||||
|
|
||||||
## Penetration testing
|
## Penetration testing
|
||||||
|
|
||||||
|
@@ -53,8 +53,8 @@
|
|||||||
"project-id": "Project ID",
|
"project-id": "Project ID",
|
||||||
"save-changes": "Save Changes",
|
"save-changes": "Save Changes",
|
||||||
"saved": "Saved",
|
"saved": "Saved",
|
||||||
"drop-zone": "Drag and drop a .env, .json, or .yml file here.",
|
"drop-zone": "Drag and drop a .env, .json, .csv, or .yml file here.",
|
||||||
"drop-zone-keys": "Drag and drop a .env, .json, or .yml file here to add more secrets.",
|
"drop-zone-keys": "Drag and drop a .env, .json, .csv, or .yml file here to add more secrets.",
|
||||||
"role": "Role",
|
"role": "Role",
|
||||||
"role_admin": "admin",
|
"role_admin": "admin",
|
||||||
"display-name": "Display Name",
|
"display-name": "Display Name",
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
import { faArrowUpRightFromSquare, faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
import { FormLabel, Tooltip } from "../v2";
|
import { FormLabel, Tooltip } from "../v2";
|
||||||
@@ -10,15 +10,18 @@ export const TtlFormLabel = ({ label }: { label: string }) => (
|
|||||||
label={label}
|
label={label}
|
||||||
icon={
|
icon={
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
className="max-w-lg"
|
||||||
content={
|
content={
|
||||||
<span>
|
<span>
|
||||||
|
Examples: 30m, 1h, 3d, etc.{" "}
|
||||||
<a
|
<a
|
||||||
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
|
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-primary-700"
|
className="text-primary-500 hover:text-mineshaft-100"
|
||||||
>
|
>
|
||||||
More
|
See More Examples{" "}
|
||||||
|
<FontAwesomeIcon size="xs" className="mt-0.5" icon={faArrowUpRightFromSquare} />
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -26,7 +29,7 @@ export const TtlFormLabel = ({ label }: { label: string }) => (
|
|||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={faQuestionCircle}
|
icon={faQuestionCircle}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="relative bottom-px right-1"
|
className="relative right-1 mt-0.5 text-mineshaft-300"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
|
29
frontend/src/components/roles/RoleOption.tsx
Normal file
29
frontend/src/components/roles/RoleOption.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { components, OptionProps } from "react-select";
|
||||||
|
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
export const RoleOption = ({
|
||||||
|
isSelected,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: OptionProps<{ name: string; slug: string; description?: string | undefined }>) => {
|
||||||
|
return (
|
||||||
|
<components.Option isSelected={isSelected} {...props}>
|
||||||
|
<div className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="truncate">{children}</p>
|
||||||
|
{props.data.description ? (
|
||||||
|
<p className="truncate text-xs leading-4 text-mineshaft-400">
|
||||||
|
{props.data.description}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs leading-4 text-mineshaft-400/50">No Description</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<FontAwesomeIcon className="ml-2 text-primary" icon={faCheckCircle} size="sm" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</components.Option>
|
||||||
|
);
|
||||||
|
};
|
1
frontend/src/components/roles/index.tsx
Normal file
1
frontend/src/components/roles/index.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./RoleOption";
|
@@ -165,3 +165,61 @@ export function parseYaml(src: ArrayBuffer | string) {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectSeparator(csvContent: string): string {
|
||||||
|
const firstLine = csvContent.split("\n")[0];
|
||||||
|
const separators = [",", ";", "\t", "|"];
|
||||||
|
|
||||||
|
const counts = separators.map((sep) => ({
|
||||||
|
separator: sep,
|
||||||
|
count: (firstLine.match(new RegExp(`\\${sep}`, "g")) || []).length
|
||||||
|
}));
|
||||||
|
|
||||||
|
const detected = counts.reduce((max, curr) => (curr.count > max.count ? curr : max));
|
||||||
|
|
||||||
|
return detected.count > 0 ? detected.separator : ",";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCsvToMatrix(src: ArrayBuffer | string): string[][] {
|
||||||
|
let csvContent: string;
|
||||||
|
if (typeof src === "string") {
|
||||||
|
csvContent = src;
|
||||||
|
} else {
|
||||||
|
csvContent = new TextDecoder("utf-8").decode(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = detectSeparator(csvContent);
|
||||||
|
const lines = csvContent.replace(/\r\n?/g, "\n").split("\n");
|
||||||
|
const matrix: string[][] = [];
|
||||||
|
|
||||||
|
lines.forEach((line) => {
|
||||||
|
if (line.trim() !== "") {
|
||||||
|
const cells: string[] = [];
|
||||||
|
let currentCell = "";
|
||||||
|
let inQuote = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i += 1) {
|
||||||
|
const char = line[i];
|
||||||
|
const nextChar = line[i + 1];
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuote && nextChar === '"') {
|
||||||
|
currentCell += '"';
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
inQuote = !inQuote;
|
||||||
|
}
|
||||||
|
} else if (char === separator && !inQuote) {
|
||||||
|
cells.push(currentCell.trim());
|
||||||
|
currentCell = "";
|
||||||
|
} else {
|
||||||
|
currentCell += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cells.push(currentCell.trim());
|
||||||
|
matrix.push(cells);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return matrix;
|
||||||
|
}
|
||||||
|
@@ -143,6 +143,13 @@ export enum ProjectPermissionSecretScanningConfigActions {
|
|||||||
Update = "update-configs"
|
Update = "update-configs"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ProjectPermissionSecretEventActions {
|
||||||
|
SubscribeCreated = "subscribe-on-created",
|
||||||
|
SubscribeUpdated = "subscribe-on-updated",
|
||||||
|
SubscribeDeleted = "subscribe-on-deleted",
|
||||||
|
SubscribeImportMutations = "subscribe-on-import-mutations"
|
||||||
|
}
|
||||||
|
|
||||||
export enum PermissionConditionOperators {
|
export enum PermissionConditionOperators {
|
||||||
$IN = "$in",
|
$IN = "$in",
|
||||||
$ALL = "$all",
|
$ALL = "$all",
|
||||||
@@ -172,7 +179,8 @@ export type ConditionalProjectPermissionSubject =
|
|||||||
| ProjectPermissionSub.CertificateTemplates
|
| ProjectPermissionSub.CertificateTemplates
|
||||||
| ProjectPermissionSub.SecretFolders
|
| ProjectPermissionSub.SecretFolders
|
||||||
| ProjectPermissionSub.SecretImports
|
| ProjectPermissionSub.SecretImports
|
||||||
| ProjectPermissionSub.SecretRotation;
|
| ProjectPermissionSub.SecretRotation
|
||||||
|
| ProjectPermissionSub.SecretEvents;
|
||||||
|
|
||||||
export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = {
|
export const formatedConditionsOperatorNames: { [K in PermissionConditionOperators]: string } = {
|
||||||
[PermissionConditionOperators.$EQ]: "equal to",
|
[PermissionConditionOperators.$EQ]: "equal to",
|
||||||
@@ -250,7 +258,8 @@ export enum ProjectPermissionSub {
|
|||||||
Commits = "commits",
|
Commits = "commits",
|
||||||
SecretScanningDataSources = "secret-scanning-data-sources",
|
SecretScanningDataSources = "secret-scanning-data-sources",
|
||||||
SecretScanningFindings = "secret-scanning-findings",
|
SecretScanningFindings = "secret-scanning-findings",
|
||||||
SecretScanningConfigs = "secret-scanning-configs"
|
SecretScanningConfigs = "secret-scanning-configs",
|
||||||
|
SecretEvents = "secret-events"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SecretSubjectFields = {
|
export type SecretSubjectFields = {
|
||||||
@@ -260,6 +269,14 @@ export type SecretSubjectFields = {
|
|||||||
secretTags: string[];
|
secretTags: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SecretEventSubjectFields = {
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
secretName: string;
|
||||||
|
secretTags: string[];
|
||||||
|
action: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type SecretFolderSubjectFields = {
|
export type SecretFolderSubjectFields = {
|
||||||
environment: string;
|
environment: string;
|
||||||
secretPath: string;
|
secretPath: string;
|
||||||
@@ -403,6 +420,13 @@ export type ProjectPermissionSet =
|
|||||||
ProjectPermissionSub.SecretScanningDataSources
|
ProjectPermissionSub.SecretScanningDataSources
|
||||||
]
|
]
|
||||||
| [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings]
|
| [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings]
|
||||||
| [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs];
|
| [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs]
|
||||||
|
| [
|
||||||
|
ProjectPermissionSecretEventActions,
|
||||||
|
(
|
||||||
|
| ProjectPermissionSub.SecretEvents
|
||||||
|
| (ForcedSubject<ProjectPermissionSub.SecretEvents> & SecretEventSubjectFields)
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
export type TProjectPermission = MongoAbility<ProjectPermissionSet>;
|
export type TProjectPermission = MongoAbility<ProjectPermissionSet>;
|
||||||
|
@@ -1,84 +0,0 @@
|
|||||||
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
|
|
||||||
import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Object} obj
|
|
||||||
* @param {Number} obj.encryptionVersion
|
|
||||||
* @param {String} obj.encryptedPrivateKey
|
|
||||||
* @param {String} obj.iv
|
|
||||||
* @param {String} obj.tag
|
|
||||||
* @param {String} obj.password
|
|
||||||
* @param {String} obj.salt
|
|
||||||
* @param {String} obj.protectedKey
|
|
||||||
* @param {String} obj.protectedKeyIV
|
|
||||||
* @param {String} obj.protectedKeyTag
|
|
||||||
*/
|
|
||||||
const decryptPrivateKeyHelper = async ({
|
|
||||||
encryptionVersion,
|
|
||||||
encryptedPrivateKey,
|
|
||||||
iv,
|
|
||||||
tag,
|
|
||||||
password,
|
|
||||||
salt,
|
|
||||||
protectedKey,
|
|
||||||
protectedKeyIV,
|
|
||||||
protectedKeyTag
|
|
||||||
}: {
|
|
||||||
encryptionVersion: number;
|
|
||||||
encryptedPrivateKey: string;
|
|
||||||
iv: string;
|
|
||||||
tag: string;
|
|
||||||
password: string;
|
|
||||||
salt: string;
|
|
||||||
protectedKey?: string;
|
|
||||||
protectedKeyIV?: string;
|
|
||||||
protectedKeyTag?: string;
|
|
||||||
}) => {
|
|
||||||
let privateKey;
|
|
||||||
try {
|
|
||||||
if (encryptionVersion === 1) {
|
|
||||||
privateKey = Aes256Gcm.decrypt({
|
|
||||||
ciphertext: encryptedPrivateKey,
|
|
||||||
iv,
|
|
||||||
tag,
|
|
||||||
secret: password
|
|
||||||
.slice(0, 32)
|
|
||||||
.padStart(32 + (password.slice(0, 32).length - new Blob([password]).size), "0")
|
|
||||||
});
|
|
||||||
} else if (encryptionVersion === 2 && protectedKey && protectedKeyIV && protectedKeyTag) {
|
|
||||||
const derivedKey = await deriveArgonKey({
|
|
||||||
password,
|
|
||||||
salt,
|
|
||||||
mem: 65536,
|
|
||||||
time: 3,
|
|
||||||
parallelism: 1,
|
|
||||||
hashLen: 32
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!derivedKey) throw new Error("Failed to generate derived key");
|
|
||||||
|
|
||||||
const key = Aes256Gcm.decrypt({
|
|
||||||
ciphertext: protectedKey,
|
|
||||||
iv: protectedKeyIV,
|
|
||||||
tag: protectedKeyTag,
|
|
||||||
secret: Buffer.from(derivedKey.hash)
|
|
||||||
});
|
|
||||||
|
|
||||||
// decrypt back the private key
|
|
||||||
privateKey = Aes256Gcm.decrypt({
|
|
||||||
ciphertext: encryptedPrivateKey,
|
|
||||||
iv,
|
|
||||||
tag,
|
|
||||||
secret: Buffer.from(key, "hex")
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error("Insufficient details to decrypt private key");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
throw new Error("Failed to decrypt private key");
|
|
||||||
}
|
|
||||||
|
|
||||||
return privateKey;
|
|
||||||
};
|
|
||||||
|
|
||||||
export { decryptPrivateKeyHelper };
|
|
@@ -6,10 +6,12 @@ import { apiRequest } from "@app/config/request";
|
|||||||
import { accessApprovalKeys } from "./queries";
|
import { accessApprovalKeys } from "./queries";
|
||||||
import {
|
import {
|
||||||
TAccessApproval,
|
TAccessApproval,
|
||||||
|
TAccessApprovalRequest,
|
||||||
TCreateAccessPolicyDTO,
|
TCreateAccessPolicyDTO,
|
||||||
TCreateAccessRequestDTO,
|
TCreateAccessRequestDTO,
|
||||||
TDeleteSecretPolicyDTO,
|
TDeleteSecretPolicyDTO,
|
||||||
TUpdateAccessPolicyDTO
|
TUpdateAccessPolicyDTO,
|
||||||
|
TUpdateAccessRequestDTO
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const useCreateAccessApprovalPolicy = () => {
|
export const useCreateAccessApprovalPolicy = () => {
|
||||||
@@ -26,7 +28,8 @@ export const useCreateAccessApprovalPolicy = () => {
|
|||||||
secretPath,
|
secretPath,
|
||||||
enforcementLevel,
|
enforcementLevel,
|
||||||
allowedSelfApprovals,
|
allowedSelfApprovals,
|
||||||
approvalsRequired
|
approvalsRequired,
|
||||||
|
maxTimePeriod
|
||||||
}) => {
|
}) => {
|
||||||
const { data } = await apiRequest.post("/api/v1/access-approvals/policies", {
|
const { data } = await apiRequest.post("/api/v1/access-approvals/policies", {
|
||||||
environments,
|
environments,
|
||||||
@@ -38,7 +41,8 @@ export const useCreateAccessApprovalPolicy = () => {
|
|||||||
name,
|
name,
|
||||||
enforcementLevel,
|
enforcementLevel,
|
||||||
allowedSelfApprovals,
|
allowedSelfApprovals,
|
||||||
approvalsRequired
|
approvalsRequired,
|
||||||
|
maxTimePeriod
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
@@ -64,7 +68,8 @@ export const useUpdateAccessApprovalPolicy = () => {
|
|||||||
enforcementLevel,
|
enforcementLevel,
|
||||||
allowedSelfApprovals,
|
allowedSelfApprovals,
|
||||||
approvalsRequired,
|
approvalsRequired,
|
||||||
environments
|
environments,
|
||||||
|
maxTimePeriod
|
||||||
}) => {
|
}) => {
|
||||||
const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, {
|
const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, {
|
||||||
approvals,
|
approvals,
|
||||||
@@ -75,7 +80,8 @@ export const useUpdateAccessApprovalPolicy = () => {
|
|||||||
enforcementLevel,
|
enforcementLevel,
|
||||||
allowedSelfApprovals,
|
allowedSelfApprovals,
|
||||||
approvalsRequired,
|
approvalsRequired,
|
||||||
environments
|
environments,
|
||||||
|
maxTimePeriod
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
@@ -130,6 +136,25 @@ export const useCreateAccessRequest = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useUpdateAccessRequest = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<TAccessApprovalRequest, object, TUpdateAccessRequestDTO>({
|
||||||
|
mutationFn: async ({ requestId, ...payload }) => {
|
||||||
|
const { data } = await apiRequest.patch<{ approval: TAccessApprovalRequest }>(
|
||||||
|
`/api/v1/access-approvals/requests/${requestId}`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
return data.approval;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { projectSlug }) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: accessApprovalKeys.getAccessApprovalRequests(projectSlug)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useReviewAccessRequest = () => {
|
export const useReviewAccessRequest = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation<
|
return useMutation<
|
||||||
|
@@ -24,7 +24,7 @@ export const accessApprovalKeys = {
|
|||||||
envSlug?: string,
|
envSlug?: string,
|
||||||
requestedBy?: string,
|
requestedBy?: string,
|
||||||
bypassReason?: string
|
bypassReason?: string
|
||||||
) => [{ projectSlug, envSlug, requestedBy, bypassReason }, "access-approvals-requests"] as const,
|
) => ["access-approvals-requests", projectSlug, envSlug, requestedBy, bypassReason] as const,
|
||||||
getAccessApprovalRequestCount: (projectSlug: string, policyId?: string) =>
|
getAccessApprovalRequestCount: (projectSlug: string, policyId?: string) =>
|
||||||
[{ projectSlug }, "access-approval-request-count", ...(policyId ? [policyId] : [])] as const
|
[{ projectSlug }, "access-approval-request-count", ...(policyId ? [policyId] : [])] as const
|
||||||
};
|
};
|
||||||
|
@@ -18,6 +18,7 @@ export type TAccessApprovalPolicy = {
|
|||||||
approvers?: Approver[];
|
approvers?: Approver[];
|
||||||
bypassers?: Bypasser[];
|
bypassers?: Bypasser[];
|
||||||
allowedSelfApprovals: boolean;
|
allowedSelfApprovals: boolean;
|
||||||
|
maxTimePeriod?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum ApproverType {
|
export enum ApproverType {
|
||||||
@@ -93,6 +94,7 @@ export type TAccessApprovalRequest = {
|
|||||||
enforcementLevel: EnforcementLevel;
|
enforcementLevel: EnforcementLevel;
|
||||||
deletedAt: Date | null;
|
deletedAt: Date | null;
|
||||||
allowedSelfApprovals: boolean;
|
allowedSelfApprovals: boolean;
|
||||||
|
maxTimePeriod?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
reviewers: {
|
reviewers: {
|
||||||
@@ -101,6 +103,8 @@ export type TAccessApprovalRequest = {
|
|||||||
}[];
|
}[];
|
||||||
|
|
||||||
note?: string;
|
note?: string;
|
||||||
|
editNote?: string;
|
||||||
|
editedByUserId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TAccessApproval = {
|
export type TAccessApproval = {
|
||||||
@@ -144,6 +148,13 @@ export type TCreateAccessRequestDTO = {
|
|||||||
note?: string;
|
note?: string;
|
||||||
} & Omit<TProjectUserPrivilege, "id" | "createdAt" | "updatedAt" | "slug" | "projectMembershipId">;
|
} & Omit<TProjectUserPrivilege, "id" | "createdAt" | "updatedAt" | "slug" | "projectMembershipId">;
|
||||||
|
|
||||||
|
export type TUpdateAccessRequestDTO = {
|
||||||
|
requestId: string;
|
||||||
|
editNote: string;
|
||||||
|
temporaryRange: string;
|
||||||
|
projectSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type TGetAccessApprovalRequestsDTO = {
|
export type TGetAccessApprovalRequestsDTO = {
|
||||||
projectSlug: string;
|
projectSlug: string;
|
||||||
policyId?: string;
|
policyId?: string;
|
||||||
@@ -173,6 +184,7 @@ export type TCreateAccessPolicyDTO = {
|
|||||||
enforcementLevel?: EnforcementLevel;
|
enforcementLevel?: EnforcementLevel;
|
||||||
allowedSelfApprovals: boolean;
|
allowedSelfApprovals: boolean;
|
||||||
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
|
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
|
||||||
|
maxTimePeriod?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TUpdateAccessPolicyDTO = {
|
export type TUpdateAccessPolicyDTO = {
|
||||||
@@ -188,6 +200,7 @@ export type TUpdateAccessPolicyDTO = {
|
|||||||
// for invalidating list
|
// for invalidating list
|
||||||
projectSlug: string;
|
projectSlug: string;
|
||||||
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
|
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
|
||||||
|
maxTimePeriod?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TDeleteSecretPolicyDTO = {
|
export type TDeleteSecretPolicyDTO = {
|
||||||
|
@@ -74,15 +74,6 @@ export type TCreateAdminUserDTO = {
|
|||||||
password: string;
|
password: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
protectedKey: string;
|
|
||||||
protectedKeyTag: string;
|
|
||||||
protectedKeyIV: string;
|
|
||||||
encryptedPrivateKey: string;
|
|
||||||
encryptedPrivateKeyIV: string;
|
|
||||||
encryptedPrivateKeyTag: string;
|
|
||||||
publicKey: string;
|
|
||||||
verifier: string;
|
|
||||||
salt: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminGetOrganizationsFilters = {
|
export type AdminGetOrganizationsFilters = {
|
||||||
|
@@ -43,12 +43,15 @@ export const useUpdateDynamicSecret = () => {
|
|||||||
);
|
);
|
||||||
return data.dynamicSecret;
|
return data.dynamicSecret;
|
||||||
},
|
},
|
||||||
onSuccess: (_, { path, environmentSlug, projectSlug }) => {
|
onSuccess: (_, { path, environmentSlug, projectSlug, name }) => {
|
||||||
// TODO: optimize but currently don't pass projectId
|
// TODO: optimize but currently don't pass projectId
|
||||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.all() });
|
queryClient.invalidateQueries({ queryKey: dashboardKeys.all() });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: dynamicSecretKeys.list({ path, projectSlug, environmentSlug })
|
queryKey: dynamicSecretKeys.list({ path, projectSlug, environmentSlug })
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: dynamicSecretKeys.details({ path, projectSlug, environmentSlug, name })
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@@ -37,7 +37,8 @@ export enum DynamicSecretProviders {
|
|||||||
Kubernetes = "kubernetes",
|
Kubernetes = "kubernetes",
|
||||||
Vertica = "vertica",
|
Vertica = "vertica",
|
||||||
GcpIam = "gcp-iam",
|
GcpIam = "gcp-iam",
|
||||||
Github = "github"
|
Github = "github",
|
||||||
|
Couchbase = "couchbase"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum KubernetesDynamicSecretCredentialType {
|
export enum KubernetesDynamicSecretCredentialType {
|
||||||
@@ -353,6 +354,38 @@ export type TDynamicSecretProvider =
|
|||||||
installationId: number;
|
installationId: number;
|
||||||
privateKey: string;
|
privateKey: string;
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: DynamicSecretProviders.Couchbase;
|
||||||
|
inputs: {
|
||||||
|
url: string;
|
||||||
|
orgId: string;
|
||||||
|
projectId: string;
|
||||||
|
clusterId: string;
|
||||||
|
roles: string[];
|
||||||
|
buckets:
|
||||||
|
| string
|
||||||
|
| Array<{
|
||||||
|
name: string;
|
||||||
|
scopes?: Array<{
|
||||||
|
name: string;
|
||||||
|
collections?: string[];
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
passwordRequirements?: {
|
||||||
|
length: number;
|
||||||
|
required: {
|
||||||
|
lowercase: number;
|
||||||
|
uppercase: number;
|
||||||
|
digits: number;
|
||||||
|
symbols: number;
|
||||||
|
};
|
||||||
|
allowedSymbols?: string;
|
||||||
|
};
|
||||||
|
auth: {
|
||||||
|
apiKey: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TCreateDynamicSecretDTO = {
|
export type TCreateDynamicSecretDTO = {
|
||||||
|
@@ -1,77 +0,0 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
|
|
||||||
import jsrp from "jsrp";
|
|
||||||
|
|
||||||
import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm";
|
|
||||||
import { deriveArgonKey, generateKeyPair } from "@app/components/utilities/cryptography/crypto";
|
|
||||||
|
|
||||||
export const generateUserPassKey = async (
|
|
||||||
email: string,
|
|
||||||
password: string,
|
|
||||||
fipsEnabled: boolean
|
|
||||||
) => {
|
|
||||||
// eslint-disable-next-line new-cap
|
|
||||||
const client = new jsrp.client();
|
|
||||||
|
|
||||||
const { publicKey, privateKey } = await generateKeyPair(fipsEnabled);
|
|
||||||
|
|
||||||
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 deriveArgonKey({
|
|
||||||
password,
|
|
||||||
salt,
|
|
||||||
mem: 65536,
|
|
||||||
time: 3,
|
|
||||||
parallelism: 1,
|
|
||||||
hashLen: 32
|
|
||||||
});
|
|
||||||
|
|
||||||
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
|
|
||||||
} = Aes256Gcm.encrypt({
|
|
||||||
text: privateKey,
|
|
||||||
secret: key
|
|
||||||
});
|
|
||||||
|
|
||||||
// create the protected key by encrypting the symmetric key
|
|
||||||
// [key] with the derived key
|
|
||||||
const {
|
|
||||||
ciphertext: protectedKey,
|
|
||||||
iv: protectedKeyIV,
|
|
||||||
tag: protectedKeyTag
|
|
||||||
} = Aes256Gcm.encrypt({
|
|
||||||
text: key.toString("hex"),
|
|
||||||
secret: Buffer.from(derivedKey.hash)
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
protectedKey,
|
|
||||||
protectedKeyTag,
|
|
||||||
protectedKeyIV,
|
|
||||||
encryptedPrivateKey,
|
|
||||||
encryptedPrivateKeyIV,
|
|
||||||
encryptedPrivateKeyTag,
|
|
||||||
publicKey,
|
|
||||||
verifier,
|
|
||||||
salt,
|
|
||||||
privateKey
|
|
||||||
};
|
|
||||||
};
|
|
@@ -12,7 +12,6 @@ import SecurityClient from "@app/components/utilities/SecurityClient";
|
|||||||
import { Button, ContentLoader, FormControl, Input } from "@app/components/v2";
|
import { Button, ContentLoader, FormControl, Input } from "@app/components/v2";
|
||||||
import { useServerConfig } from "@app/context";
|
import { useServerConfig } from "@app/context";
|
||||||
import { useCreateAdminUser, useSelectOrganization } from "@app/hooks/api";
|
import { useCreateAdminUser, useSelectOrganization } from "@app/hooks/api";
|
||||||
import { generateUserPassKey } from "@app/lib/crypto";
|
|
||||||
|
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -48,17 +47,11 @@ export const SignUpPage = () => {
|
|||||||
// avoid multi submission
|
// avoid multi submission
|
||||||
if (isSubmitting) return;
|
if (isSubmitting) return;
|
||||||
try {
|
try {
|
||||||
const { privateKey, ...userPass } = await generateUserPassKey(
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
config.fipsEnabled
|
|
||||||
);
|
|
||||||
const res = await createAdminUser({
|
const res = await createAdminUser({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName
|
||||||
...userPass
|
|
||||||
});
|
});
|
||||||
|
|
||||||
SecurityClient.setToken(res.token);
|
SecurityClient.setToken(res.token);
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { SingleValue } from "react-select";
|
import { SingleValue } from "react-select";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -128,10 +128,10 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
enabled: caType === CaType.ACME
|
enabled: caType === CaType.ACME
|
||||||
});
|
});
|
||||||
|
|
||||||
const availableConnections: TAvailableAppConnection[] = [
|
const availableConnections: TAvailableAppConnection[] = useMemo(
|
||||||
...(availableRoute53Connections || []),
|
() => [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])],
|
||||||
...(availableCloudflareConnections || [])
|
[availableRoute53Connections, availableCloudflareConnections]
|
||||||
];
|
);
|
||||||
|
|
||||||
const isPending = isRoute53Pending || isCloudflarePending;
|
const isPending = isRoute53Pending || isCloudflarePending;
|
||||||
|
|
||||||
|
@@ -4,6 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { RoleOption } from "@app/components/roles";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
FilterableSelect,
|
FilterableSelect,
|
||||||
@@ -45,7 +46,11 @@ const addMemberFormSchema = z.object({
|
|||||||
)
|
)
|
||||||
.default([]),
|
.default([]),
|
||||||
projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG),
|
projectRoleSlug: z.string().min(1).default(DEFAULT_ORG_AND_PROJECT_MEMBER_ROLE_SLUG),
|
||||||
organizationRole: z.object({ name: z.string(), slug: z.string() })
|
organizationRole: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
slug: z.string(),
|
||||||
|
description: z.string().optional()
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
|
||||||
@@ -238,6 +243,7 @@ export const AddOrgMemberModal = ({
|
|||||||
getOptionLabel={(option) => option.name}
|
getOptionLabel={(option) => option.name}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
components={{ Option: RoleOption }}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
)}
|
)}
|
||||||
|
@@ -36,7 +36,8 @@ type GithubFormData = BaseFormData &
|
|||||||
type GithubRadarFormData = BaseFormData &
|
type GithubRadarFormData = BaseFormData &
|
||||||
Pick<TGitHubRadarConnection, "name" | "method" | "description">;
|
Pick<TGitHubRadarConnection, "name" | "method" | "description">;
|
||||||
|
|
||||||
type GitLabFormData = BaseFormData & Pick<TGitLabConnection, "name" | "method" | "description">;
|
type GitLabFormData = BaseFormData &
|
||||||
|
Pick<TGitLabConnection, "name" | "method" | "description" | "credentials">;
|
||||||
|
|
||||||
type AzureKeyVaultFormData = BaseFormData &
|
type AzureKeyVaultFormData = BaseFormData &
|
||||||
Pick<TAzureKeyVaultConnection, "name" | "method" | "description"> &
|
Pick<TAzureKeyVaultConnection, "name" | "method" | "description"> &
|
||||||
@@ -147,7 +148,7 @@ export const OAuthCallbackPage = () => {
|
|||||||
|
|
||||||
clearState(AppConnection.GitLab);
|
clearState(AppConnection.GitLab);
|
||||||
|
|
||||||
const { connectionId, name, description, returnUrl, isUpdate } = formData;
|
const { connectionId, name, description, returnUrl, isUpdate, credentials } = formData;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isUpdate && connectionId) {
|
if (isUpdate && connectionId) {
|
||||||
@@ -155,7 +156,8 @@ export const OAuthCallbackPage = () => {
|
|||||||
app: AppConnection.GitLab,
|
app: AppConnection.GitLab,
|
||||||
connectionId,
|
connectionId,
|
||||||
credentials: {
|
credentials: {
|
||||||
code: code as string
|
code: code as string,
|
||||||
|
instanceUrl: credentials.instanceUrl as string
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -165,7 +167,8 @@ export const OAuthCallbackPage = () => {
|
|||||||
description,
|
description,
|
||||||
method: GitLabConnectionMethod.OAuth,
|
method: GitLabConnectionMethod.OAuth,
|
||||||
credentials: {
|
credentials: {
|
||||||
code: code as string
|
code: code as string,
|
||||||
|
instanceUrl: credentials.instanceUrl as string
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -40,12 +40,20 @@ export const LogsSection = withPermission(
|
|||||||
});
|
});
|
||||||
const [timezone, setTimezone] = useState<Timezone>(Timezone.Local);
|
const [timezone, setTimezone] = useState<Timezone>(Timezone.Local);
|
||||||
|
|
||||||
const [dateFilter, setDateFilter] = useState<TAuditLogDateFilterFormData>({
|
const [dateFilter, setDateFilter] = useState<TAuditLogDateFilterFormData>(
|
||||||
|
presets?.endDate || presets?.startDate
|
||||||
|
? {
|
||||||
|
type: AuditLogDateFilterType.Absolute,
|
||||||
|
startDate: presets?.startDate || new Date(Number(new Date()) - ms("1h")),
|
||||||
|
endDate: presets?.endDate || new Date()
|
||||||
|
}
|
||||||
|
: {
|
||||||
startDate: new Date(Number(new Date()) - ms("1h")),
|
startDate: new Date(Number(new Date()) - ms("1h")),
|
||||||
endDate: new Date(),
|
endDate: new Date(),
|
||||||
type: AuditLogDateFilterType.Relative,
|
type: AuditLogDateFilterType.Relative,
|
||||||
relativeModeValue: "1h"
|
relativeModeValue: "1h"
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (subscription && !subscription.auditLogs) {
|
if (subscription && !subscription.auditLogs) {
|
||||||
|
@@ -6,6 +6,7 @@ import { useNavigate, useSearch } from "@tanstack/react-router";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { RoleOption } from "@app/components/roles";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AlertDescription,
|
AlertDescription,
|
||||||
@@ -320,6 +321,7 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
>
|
>
|
||||||
<FilterableSelect
|
<FilterableSelect
|
||||||
options={roles}
|
options={roles}
|
||||||
|
components={{ Option: RoleOption }}
|
||||||
placeholder="Select roles..."
|
placeholder="Select roles..."
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
@@ -220,6 +220,24 @@ export const SpecificPrivilegeSecretForm = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const policy = policies.find(
|
||||||
|
(p) =>
|
||||||
|
p.environments.find((e) => e.slug === selectedEnvironment) && p.secretPath === secretPath
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
policy?.maxTimePeriod &&
|
||||||
|
(!data.temporaryAccess.isTemporary ||
|
||||||
|
ms(data.temporaryAccess.temporaryRange) > ms(policy.maxTimePeriod))
|
||||||
|
) {
|
||||||
|
createNotification({
|
||||||
|
type: "error",
|
||||||
|
text: `Requested access time range is limited to ${policy.maxTimePeriod} by policy`,
|
||||||
|
title: "Error"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
{ action: ProjectPermissionActions.Read, allowed: data.read },
|
{ action: ProjectPermissionActions.Read, allowed: data.read },
|
||||||
{ action: ProjectPermissionActions.Create, allowed: data.create },
|
{ action: ProjectPermissionActions.Create, allowed: data.create },
|
||||||
|
@@ -21,6 +21,7 @@ import {
|
|||||||
ProjectPermissionPkiSubscriberActions,
|
ProjectPermissionPkiSubscriberActions,
|
||||||
ProjectPermissionPkiTemplateActions,
|
ProjectPermissionPkiTemplateActions,
|
||||||
ProjectPermissionSecretActions,
|
ProjectPermissionSecretActions,
|
||||||
|
ProjectPermissionSecretEventActions,
|
||||||
ProjectPermissionSecretRotationActions,
|
ProjectPermissionSecretRotationActions,
|
||||||
ProjectPermissionSecretScanningConfigActions,
|
ProjectPermissionSecretScanningConfigActions,
|
||||||
ProjectPermissionSecretScanningDataSourceActions,
|
ProjectPermissionSecretScanningDataSourceActions,
|
||||||
@@ -188,6 +189,13 @@ const PkiTemplatePolicyActionSchema = z.object({
|
|||||||
[ProjectPermissionPkiTemplateActions.ListCerts]: z.boolean().optional()
|
[ProjectPermissionPkiTemplateActions.ListCerts]: z.boolean().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const SecretEventsPolicyActionSchema = z.object({
|
||||||
|
[ProjectPermissionSecretEventActions.SubscribeCreated]: z.boolean().optional(),
|
||||||
|
[ProjectPermissionSecretEventActions.SubscribeUpdated]: z.boolean().optional(),
|
||||||
|
[ProjectPermissionSecretEventActions.SubscribeDeleted]: z.boolean().optional(),
|
||||||
|
[ProjectPermissionSecretEventActions.SubscribeImportMutations]: z.boolean().optional()
|
||||||
|
});
|
||||||
|
|
||||||
const SecretRollbackPolicyActionSchema = z.object({
|
const SecretRollbackPolicyActionSchema = z.object({
|
||||||
read: z.boolean().optional(),
|
read: z.boolean().optional(),
|
||||||
create: z.boolean().optional()
|
create: z.boolean().optional()
|
||||||
@@ -356,7 +364,12 @@ export const projectRoleFormSchema = z.object({
|
|||||||
[ProjectPermissionSub.SecretScanningFindings]:
|
[ProjectPermissionSub.SecretScanningFindings]:
|
||||||
SecretScanningFindingPolicyActionSchema.array().default([]),
|
SecretScanningFindingPolicyActionSchema.array().default([]),
|
||||||
[ProjectPermissionSub.SecretScanningConfigs]:
|
[ProjectPermissionSub.SecretScanningConfigs]:
|
||||||
SecretScanningConfigPolicyActionSchema.array().default([])
|
SecretScanningConfigPolicyActionSchema.array().default([]),
|
||||||
|
[ProjectPermissionSub.SecretEvents]: SecretEventsPolicyActionSchema.extend({
|
||||||
|
conditions: ConditionSchema
|
||||||
|
})
|
||||||
|
.array()
|
||||||
|
.default([])
|
||||||
})
|
})
|
||||||
.partial()
|
.partial()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -374,7 +387,8 @@ type TConditionalFields =
|
|||||||
| ProjectPermissionSub.SshHosts
|
| ProjectPermissionSub.SshHosts
|
||||||
| ProjectPermissionSub.SecretRotation
|
| ProjectPermissionSub.SecretRotation
|
||||||
| ProjectPermissionSub.Identity
|
| ProjectPermissionSub.Identity
|
||||||
| ProjectPermissionSub.SecretSyncs;
|
| ProjectPermissionSub.SecretSyncs
|
||||||
|
| ProjectPermissionSub.SecretEvents;
|
||||||
|
|
||||||
export const isConditionalSubjects = (
|
export const isConditionalSubjects = (
|
||||||
subject: ProjectPermissionSub
|
subject: ProjectPermissionSub
|
||||||
@@ -388,7 +402,8 @@ export const isConditionalSubjects = (
|
|||||||
subject === ProjectPermissionSub.SecretRotation ||
|
subject === ProjectPermissionSub.SecretRotation ||
|
||||||
subject === ProjectPermissionSub.PkiSubscribers ||
|
subject === ProjectPermissionSub.PkiSubscribers ||
|
||||||
subject === ProjectPermissionSub.CertificateTemplates ||
|
subject === ProjectPermissionSub.CertificateTemplates ||
|
||||||
subject === ProjectPermissionSub.SecretSyncs;
|
subject === ProjectPermissionSub.SecretSyncs ||
|
||||||
|
subject === ProjectPermissionSub.SecretEvents;
|
||||||
|
|
||||||
const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => {
|
const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => {
|
||||||
const formConditions: z.infer<typeof ConditionSchema> = [];
|
const formConditions: z.infer<typeof ConditionSchema> = [];
|
||||||
@@ -494,7 +509,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
|||||||
ProjectPermissionSub.SshCertificateAuthorities,
|
ProjectPermissionSub.SshCertificateAuthorities,
|
||||||
ProjectPermissionSub.SshCertificates,
|
ProjectPermissionSub.SshCertificates,
|
||||||
ProjectPermissionSub.SshHostGroups,
|
ProjectPermissionSub.SshHostGroups,
|
||||||
ProjectPermissionSub.SecretSyncs
|
ProjectPermissionSub.SecretSyncs,
|
||||||
|
ProjectPermissionSub.SecretEvents
|
||||||
].includes(subject)
|
].includes(subject)
|
||||||
) {
|
) {
|
||||||
// from above statement we are sure it won't be undefined
|
// from above statement we are sure it won't be undefined
|
||||||
@@ -607,6 +623,32 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subject === ProjectPermissionSub.SecretEvents) {
|
||||||
|
const canSubscribeCreate = action.includes(
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeCreated
|
||||||
|
);
|
||||||
|
const canSubscribeUpdate = action.includes(
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeUpdated
|
||||||
|
);
|
||||||
|
const canSubscribeDelete = action.includes(
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeDeleted
|
||||||
|
);
|
||||||
|
const canSubscribeImportMutations = action.includes(
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeImportMutations
|
||||||
|
);
|
||||||
|
|
||||||
|
// from above statement we are sure it won't be undefined
|
||||||
|
formVal[subject]!.push({
|
||||||
|
"subscribe-on-created": canSubscribeCreate,
|
||||||
|
"subscribe-on-deleted": canSubscribeDelete,
|
||||||
|
"subscribe-on-updated": canSubscribeUpdate,
|
||||||
|
"subscribe-on-import-mutations": canSubscribeImportMutations,
|
||||||
|
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : []
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// for other subjects
|
// for other subjects
|
||||||
const canRead = action.includes(ProjectPermissionActions.Read);
|
const canRead = action.includes(ProjectPermissionActions.Read);
|
||||||
const canEdit = action.includes(ProjectPermissionActions.Edit);
|
const canEdit = action.includes(ProjectPermissionActions.Edit);
|
||||||
@@ -1114,8 +1156,7 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
|||||||
{ label: "Read Value", value: ProjectPermissionSecretActions.ReadValue },
|
{ label: "Read Value", value: ProjectPermissionSecretActions.ReadValue },
|
||||||
{ label: "Modify", value: ProjectPermissionSecretActions.Edit },
|
{ label: "Modify", value: ProjectPermissionSecretActions.Edit },
|
||||||
{ label: "Remove", value: ProjectPermissionSecretActions.Delete },
|
{ label: "Remove", value: ProjectPermissionSecretActions.Delete },
|
||||||
{ label: "Create", value: ProjectPermissionSecretActions.Create },
|
{ label: "Create", value: ProjectPermissionSecretActions.Create }
|
||||||
{ label: "Subscribe", value: ProjectPermissionSecretActions.Subscribe }
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
[ProjectPermissionSub.SecretFolders]: {
|
[ProjectPermissionSub.SecretFolders]: {
|
||||||
@@ -1535,6 +1576,27 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
|||||||
value: ProjectPermissionSecretScanningConfigActions.Update
|
value: ProjectPermissionSecretScanningConfigActions.Update
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
[ProjectPermissionSub.SecretEvents]: {
|
||||||
|
title: "Secret Events",
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: "Subscribe on Created",
|
||||||
|
value: ProjectPermissionSecretEventActions.SubscribeCreated
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Subscribe on Deleted",
|
||||||
|
value: ProjectPermissionSecretEventActions.SubscribeDeleted
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Subscribe on Updated",
|
||||||
|
value: ProjectPermissionSecretEventActions.SubscribeUpdated
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Subscribe on Import Mutations",
|
||||||
|
value: ProjectPermissionSecretEventActions.SubscribeImportMutations
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1564,7 +1626,8 @@ const SecretsManagerPermissionSubjects = (enabled = false) => ({
|
|||||||
[ProjectPermissionSub.SecretRollback]: enabled,
|
[ProjectPermissionSub.SecretRollback]: enabled,
|
||||||
[ProjectPermissionSub.SecretRotation]: enabled,
|
[ProjectPermissionSub.SecretRotation]: enabled,
|
||||||
[ProjectPermissionSub.ServiceTokens]: enabled,
|
[ProjectPermissionSub.ServiceTokens]: enabled,
|
||||||
[ProjectPermissionSub.Commits]: enabled
|
[ProjectPermissionSub.Commits]: enabled,
|
||||||
|
[ProjectPermissionSub.SecretEvents]: enabled
|
||||||
});
|
});
|
||||||
|
|
||||||
const KmsPermissionSubjects = (enabled = false) => ({
|
const KmsPermissionSubjects = (enabled = false) => ({
|
||||||
|
@@ -32,6 +32,7 @@ import {
|
|||||||
rolePermission2Form,
|
rolePermission2Form,
|
||||||
TFormSchema
|
TFormSchema
|
||||||
} from "./ProjectRoleModifySection.utils";
|
} from "./ProjectRoleModifySection.utils";
|
||||||
|
import { SecretEventPermissionConditions } from "./SecretEventPermissionConditions";
|
||||||
import { SecretPermissionConditions } from "./SecretPermissionConditions";
|
import { SecretPermissionConditions } from "./SecretPermissionConditions";
|
||||||
import { SecretSyncPermissionConditions } from "./SecretSyncPermissionConditions";
|
import { SecretSyncPermissionConditions } from "./SecretSyncPermissionConditions";
|
||||||
import { SshHostPermissionConditions } from "./SshHostPermissionConditions";
|
import { SshHostPermissionConditions } from "./SshHostPermissionConditions";
|
||||||
@@ -72,6 +73,10 @@ export const renderConditionalComponents = (
|
|||||||
return <SecretSyncPermissionConditions isDisabled={isDisabled} />;
|
return <SecretSyncPermissionConditions isDisabled={isDisabled} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subject === ProjectPermissionSub.SecretEvents) {
|
||||||
|
return <SecretEventPermissionConditions isDisabled={isDisabled} />;
|
||||||
|
}
|
||||||
|
|
||||||
return <GeneralPermissionConditions isDisabled={isDisabled} type={subject} />;
|
return <GeneralPermissionConditions isDisabled={isDisabled} type={subject} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -0,0 +1,22 @@
|
|||||||
|
import { ProjectPermissionSub } from "@app/context/ProjectPermissionContext/types";
|
||||||
|
|
||||||
|
import { ConditionsFields } from "./ConditionsFields";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
position?: number;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SecretEventPermissionConditions = ({ position = 0, isDisabled }: Props) => {
|
||||||
|
return (
|
||||||
|
<ConditionsFields
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
subject={ProjectPermissionSub.SecretEvents}
|
||||||
|
position={position}
|
||||||
|
selectOptions={[
|
||||||
|
{ value: "environment", label: "Environment Slug" },
|
||||||
|
{ value: "secretPath", label: "Secret Path" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@@ -17,8 +17,7 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props)
|
|||||||
{ value: "environment", label: "Environment Slug" },
|
{ value: "environment", label: "Environment Slug" },
|
||||||
{ value: "secretPath", label: "Secret Path" },
|
{ value: "secretPath", label: "Secret Path" },
|
||||||
{ value: "secretName", label: "Secret Name" },
|
{ value: "secretName", label: "Secret Name" },
|
||||||
{ value: "secretTags", label: "Secret Tags" },
|
{ value: "secretTags", label: "Secret Tags" }
|
||||||
{ value: "eventType", label: "Event Type" }
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@@ -24,7 +24,7 @@ export const IntegrationAuditLogsSection = ({ integration }: Props) => {
|
|||||||
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
<div className="mb-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||||
<p className="text-lg font-semibold text-gray-200">Integration Logs</p>
|
<p className="text-lg font-semibold text-gray-200">Integration Logs</p>
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
Displaying audit logs from the last {auditLogsRetentionDays} days
|
Displaying audit logs from the last {Math.min(auditLogsRetentionDays, 60)} days
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<LogsSection
|
<LogsSection
|
||||||
@@ -32,7 +32,9 @@ export const IntegrationAuditLogsSection = ({ integration }: Props) => {
|
|||||||
showFilters={false}
|
showFilters={false}
|
||||||
presets={{
|
presets={{
|
||||||
eventMetadata: { integrationId: integration.id },
|
eventMetadata: { integrationId: integration.id },
|
||||||
startDate: new Date(new Date().setDate(new Date().getDate() - auditLogsRetentionDays)),
|
startDate: new Date(
|
||||||
|
new Date().setDate(new Date().getDate() - Math.min(auditLogsRetentionDays, 60))
|
||||||
|
),
|
||||||
eventType: INTEGRATION_EVENTS
|
eventType: INTEGRATION_EVENTS
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { faCheck, faFolder, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons";
|
import { faCheck, faFolder, faPencil, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
@@ -23,8 +24,16 @@ export const SecretOverviewFolderRow = ({
|
|||||||
onToggleFolderEdit,
|
onToggleFolderEdit,
|
||||||
onClick
|
onClick
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
const [isClicking, setIsClicking] = useState(false);
|
||||||
|
const handleClick = () => {
|
||||||
|
if (isClicking) return;
|
||||||
|
|
||||||
|
setIsClicking(true);
|
||||||
|
onClick(folderName);
|
||||||
|
setTimeout(() => setIsClicking(false), 1000);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<Tr isHoverable isSelectable className="group" onClick={() => onClick(folderName)}>
|
<Tr isHoverable isSelectable className="group" onClick={handleClick}>
|
||||||
<Td className="sticky left-0 z-10 border-0 bg-mineshaft-800 bg-clip-padding p-0 group-hover:bg-mineshaft-700">
|
<Td className="sticky left-0 z-10 border-0 bg-mineshaft-800 bg-clip-padding p-0 group-hover:bg-mineshaft-700">
|
||||||
<div className="flex items-center space-x-5 border-r border-mineshaft-600 px-5 py-2.5">
|
<div className="flex items-center space-x-5 border-r border-mineshaft-600 px-5 py-2.5">
|
||||||
<div className="text-yellow-700">
|
<div className="text-yellow-700">
|
||||||
|
@@ -10,6 +10,7 @@ import {
|
|||||||
ProjectPermissionActions,
|
ProjectPermissionActions,
|
||||||
ProjectPermissionSub,
|
ProjectPermissionSub,
|
||||||
useProjectPermission,
|
useProjectPermission,
|
||||||
|
useSubscription,
|
||||||
useWorkspace
|
useWorkspace
|
||||||
} from "@app/context";
|
} from "@app/context";
|
||||||
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
|
||||||
@@ -51,6 +52,7 @@ export const SelectionPanel = ({
|
|||||||
usedBySecretSyncs = []
|
usedBySecretSyncs = []
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { permission } = useProjectPermission();
|
const { permission } = useProjectPermission();
|
||||||
|
const { subscription } = useSubscription();
|
||||||
|
|
||||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||||
"bulkDeleteEntries",
|
"bulkDeleteEntries",
|
||||||
@@ -101,6 +103,16 @@ export const SelectionPanel = ({
|
|||||||
return "Do you want to delete the selected folders across environments?";
|
return "Do you want to delete the selected folders across environments?";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDeleteModalSubTitle = () => {
|
||||||
|
if (selectedFolderCount > 0) {
|
||||||
|
if (subscription?.pitRecovery) {
|
||||||
|
return "All selected folders and their contents will be removed. You can reverse this action by rolling back to a previous commit.";
|
||||||
|
}
|
||||||
|
return "All selected folders and their contents will be removed. Rolling back to a previous commit isn't available on your current plan. Upgrade to enable this feature.";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const handleBulkDelete = async () => {
|
const handleBulkDelete = async () => {
|
||||||
let processedEntries = 0;
|
let processedEntries = 0;
|
||||||
|
|
||||||
@@ -279,6 +291,7 @@ export const SelectionPanel = ({
|
|||||||
isOpen={popUp.bulkDeleteEntries.isOpen}
|
isOpen={popUp.bulkDeleteEntries.isOpen}
|
||||||
deleteKey="delete"
|
deleteKey="delete"
|
||||||
title={getDeleteModalTitle()}
|
title={getDeleteModalTitle()}
|
||||||
|
subTitle={getDeleteModalSubTitle()}
|
||||||
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
|
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
|
||||||
onDeleteApproved={handleBulkDelete}
|
onDeleteApproved={handleBulkDelete}
|
||||||
formContent={
|
formContent={
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user