mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-11 18:29:06 +00:00
Merge remote-tracking branch 'origin/main' into fix/samlDuplicateAccounts
This commit is contained in:
@@ -1,123 +0,0 @@
|
|||||||
name: Release production images (frontend, backend)
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "infisical/v*.*.*"
|
|
||||||
- "!infisical/v*.*.*-postgres"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
backend-image:
|
|
||||||
name: Build backend image
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Extract version from tag
|
|
||||||
id: extract_version
|
|
||||||
run: echo "::set-output name=version::${GITHUB_REF_NAME#infisical/}"
|
|
||||||
- name: ☁️ Checkout source
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
- name: 📦 Install dependencies to test all dependencies
|
|
||||||
run: npm ci --only-production
|
|
||||||
working-directory: backend
|
|
||||||
# - name: 🧪 Run tests
|
|
||||||
# run: npm run test:ci
|
|
||||||
# working-directory: backend
|
|
||||||
- name: Save commit hashes for tag
|
|
||||||
id: commit
|
|
||||||
uses: pr-mpt/actions-commit-hash@v2
|
|
||||||
- name: 🔧 Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
- name: 🐋 Login to Docker Hub
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
- name: Set up Depot CLI
|
|
||||||
uses: depot/setup-action@v1
|
|
||||||
- name: 📦 Build backend and export to Docker
|
|
||||||
uses: depot/build-push-action@v1
|
|
||||||
with:
|
|
||||||
project: 64mmf0n610
|
|
||||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
|
||||||
load: true
|
|
||||||
context: backend
|
|
||||||
tags: infisical/infisical:test
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
- name: ⏻ Spawn backend container and dependencies
|
|
||||||
run: |
|
|
||||||
docker compose -f .github/resources/docker-compose.be-test.yml up --wait --quiet-pull
|
|
||||||
- name: 🧪 Test backend image
|
|
||||||
run: |
|
|
||||||
./.github/resources/healthcheck.sh infisical-backend-test
|
|
||||||
- name: ⏻ Shut down backend container and dependencies
|
|
||||||
run: |
|
|
||||||
docker compose -f .github/resources/docker-compose.be-test.yml down
|
|
||||||
- name: 🏗️ Build backend and push
|
|
||||||
uses: depot/build-push-action@v1
|
|
||||||
with:
|
|
||||||
project: 64mmf0n610
|
|
||||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
|
||||||
push: true
|
|
||||||
context: backend
|
|
||||||
tags: |
|
|
||||||
infisical/backend:${{ steps.commit.outputs.short }}
|
|
||||||
infisical/backend:latest
|
|
||||||
infisical/backend:${{ steps.extract_version.outputs.version }}
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
|
|
||||||
frontend-image:
|
|
||||||
name: Build frontend image
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Extract version from tag
|
|
||||||
id: extract_version
|
|
||||||
run: echo "::set-output name=version::${GITHUB_REF_NAME#infisical/}"
|
|
||||||
- name: ☁️ Checkout source
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
- name: Save commit hashes for tag
|
|
||||||
id: commit
|
|
||||||
uses: pr-mpt/actions-commit-hash@v2
|
|
||||||
- name: 🔧 Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v2
|
|
||||||
- name: 🐋 Login to Docker Hub
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
- name: Set up Depot CLI
|
|
||||||
uses: depot/setup-action@v1
|
|
||||||
- name: 📦 Build frontend and export to Docker
|
|
||||||
uses: depot/build-push-action@v1
|
|
||||||
with:
|
|
||||||
load: true
|
|
||||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
|
||||||
project: 64mmf0n610
|
|
||||||
context: frontend
|
|
||||||
tags: infisical/frontend:test
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
build-args: |
|
|
||||||
POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }}
|
|
||||||
NEXT_INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }}
|
|
||||||
- name: ⏻ Spawn frontend container
|
|
||||||
run: |
|
|
||||||
docker run -d --rm --name infisical-frontend-test infisical/frontend:test
|
|
||||||
- name: 🧪 Test frontend image
|
|
||||||
run: |
|
|
||||||
./.github/resources/healthcheck.sh infisical-frontend-test
|
|
||||||
- name: ⏻ Shut down frontend container
|
|
||||||
run: |
|
|
||||||
docker stop infisical-frontend-test
|
|
||||||
- name: 🏗️ Build frontend and push
|
|
||||||
uses: depot/build-push-action@v1
|
|
||||||
with:
|
|
||||||
project: 64mmf0n610
|
|
||||||
push: true
|
|
||||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
|
||||||
context: frontend
|
|
||||||
tags: |
|
|
||||||
infisical/frontend:${{ steps.commit.outputs.short }}
|
|
||||||
infisical/frontend:latest
|
|
||||||
infisical/frontend:${{ steps.extract_version.outputs.version }}
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
build-args: |
|
|
||||||
POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }}
|
|
||||||
NEXT_INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }}
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
name: Generate Nightly Tag
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * *' # Run daily at midnight UTC
|
||||||
|
workflow_dispatch: # Allow manual triggering for testing
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create-nightly-tag:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Fetch all history for tags
|
||||||
|
token: ${{ secrets.GO_RELEASER_GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Configure Git
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Generate nightly tag
|
||||||
|
run: |
|
||||||
|
# Get the latest infisical production tag
|
||||||
|
LATEST_STABLE_TAG=$(git tag --list | grep "^v[0-9].*$" | grep -v "nightly" | sort -V | tail -n1)
|
||||||
|
|
||||||
|
if [ -z "$LATEST_STABLE_TAG" ]; then
|
||||||
|
echo "No infisical production tags found, using v0.1.0"
|
||||||
|
LATEST_STABLE_TAG="v0.1.0"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Latest production tag: $LATEST_STABLE_TAG"
|
||||||
|
|
||||||
|
# Get current date in YYYYMMDD format
|
||||||
|
DATE=$(date +%Y%m%d)
|
||||||
|
|
||||||
|
# Base nightly tag name
|
||||||
|
BASE_TAG="${LATEST_STABLE_TAG}-nightly-${DATE}"
|
||||||
|
|
||||||
|
# Check if this exact tag already exists
|
||||||
|
if git tag --list | grep -q "^${BASE_TAG}$"; then
|
||||||
|
echo "Base tag ${BASE_TAG} already exists, finding next increment"
|
||||||
|
|
||||||
|
# Find existing tags for this date and get the highest increment
|
||||||
|
EXISTING_TAGS=$(git tag --list | grep "^${BASE_TAG}" | grep -E '\.[0-9]+$' || true)
|
||||||
|
|
||||||
|
if [ -z "$EXISTING_TAGS" ]; then
|
||||||
|
# No incremental tags exist, create .1
|
||||||
|
NIGHTLY_TAG="${BASE_TAG}.1"
|
||||||
|
else
|
||||||
|
# Find the highest increment
|
||||||
|
HIGHEST_INCREMENT=$(echo "$EXISTING_TAGS" | sed "s|^${BASE_TAG}\.||" | sort -n | tail -n1)
|
||||||
|
NEXT_INCREMENT=$((HIGHEST_INCREMENT + 1))
|
||||||
|
NIGHTLY_TAG="${BASE_TAG}.${NEXT_INCREMENT}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Base tag doesn't exist, use it
|
||||||
|
NIGHTLY_TAG="$BASE_TAG"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Generated nightly tag: $NIGHTLY_TAG"
|
||||||
|
echo "NIGHTLY_TAG=$NIGHTLY_TAG" >> $GITHUB_ENV
|
||||||
|
echo "LATEST_PRODUCTION_TAG=$LATEST_STABLE_TAG" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
git tag "$NIGHTLY_TAG"
|
||||||
|
git push origin "$NIGHTLY_TAG"
|
||||||
|
echo "✅ Created and pushed nightly tag: $NIGHTLY_TAG"
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ env.NIGHTLY_TAG }}
|
||||||
|
name: ${{ env.NIGHTLY_TAG }}
|
||||||
|
draft: false
|
||||||
|
prerelease: true
|
||||||
|
generate_release_notes: true
|
||||||
|
make_latest: false
|
||||||
@@ -2,7 +2,9 @@ name: Release standalone docker image
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "infisical/v*.*.*-postgres"
|
- "v*.*.*"
|
||||||
|
- "v*.*.*-nightly-*"
|
||||||
|
- "v*.*.*-nightly-*.*"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
infisical-tests:
|
infisical-tests:
|
||||||
@@ -17,7 +19,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Extract version from tag
|
- name: Extract version from tag
|
||||||
id: extract_version
|
id: extract_version
|
||||||
run: echo "::set-output name=version::${GITHUB_REF_NAME#infisical/}"
|
run: echo "::set-output name=version::${GITHUB_REF_NAME}"
|
||||||
- name: ☁️ Checkout source
|
- name: ☁️ Checkout source
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
@@ -53,7 +55,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
context: .
|
context: .
|
||||||
tags: |
|
tags: |
|
||||||
infisical/infisical:latest-postgres
|
infisical/infisical:latest
|
||||||
infisical/infisical:${{ steps.commit.outputs.short }}
|
infisical/infisical:${{ steps.commit.outputs.short }}
|
||||||
infisical/infisical:${{ steps.extract_version.outputs.version }}
|
infisical/infisical:${{ steps.extract_version.outputs.version }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
@@ -69,7 +71,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Extract version from tag
|
- name: Extract version from tag
|
||||||
id: extract_version
|
id: extract_version
|
||||||
run: echo "::set-output name=version::${GITHUB_REF_NAME#infisical/}"
|
run: echo "::set-output name=version::${GITHUB_REF_NAME}"
|
||||||
- name: ☁️ Checkout source
|
- name: ☁️ Checkout source
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
@@ -105,7 +107,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
context: .
|
context: .
|
||||||
tags: |
|
tags: |
|
||||||
infisical/infisical-fips:latest-postgres
|
infisical/infisical-fips:latest
|
||||||
infisical/infisical-fips:${{ steps.commit.outputs.short }}
|
infisical/infisical-fips:${{ steps.commit.outputs.short }}
|
||||||
infisical/infisical-fips:${{ steps.extract_version.outputs.version }}
|
infisical/infisical-fips:${{ steps.extract_version.outputs.version }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|||||||
@@ -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:[email protected]:5432/infisical?sslmode=disable
|
DB_CONNECTION_URI: postgres://infisical:[email protected]: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
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
Vendored
+1
@@ -148,6 +148,7 @@ declare module "fastify" {
|
|||||||
interface Session {
|
interface Session {
|
||||||
callbackPort: string;
|
callbackPort: string;
|
||||||
isAdminLogin: boolean;
|
isAdminLogin: boolean;
|
||||||
|
orgSlug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FastifyRequest {
|
interface FastifyRequest {
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ const up = async (knex: Knex): Promise<void> => {
|
|||||||
t.index("expiresAt");
|
t.index("expiresAt");
|
||||||
t.index("orgId");
|
t.index("orgId");
|
||||||
t.index("projectId");
|
t.index("projectId");
|
||||||
|
t.index("eventType");
|
||||||
|
t.index("userAgentType");
|
||||||
|
t.index("actor");
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Adding GIN indices...");
|
console.log("Adding GIN indices...");
|
||||||
@@ -119,8 +122,8 @@ const up = async (knex: Knex): Promise<void> => {
|
|||||||
console.log("Creating audit log partitions ahead of time... next date:", nextDateStr);
|
console.log("Creating audit log partitions ahead of time... next date:", nextDateStr);
|
||||||
await createAuditLogPartition(knex, nextDate, new Date(nextDate.getFullYear(), nextDate.getMonth() + 1));
|
await createAuditLogPartition(knex, nextDate, new Date(nextDate.getFullYear(), nextDate.getMonth() + 1));
|
||||||
|
|
||||||
// create partitions 4 years ahead
|
// create partitions 20 years ahead
|
||||||
const partitionMonths = 4 * 12;
|
const partitionMonths = 20 * 12;
|
||||||
const partitionPromises: Promise<void>[] = [];
|
const partitionPromises: Promise<void>[] = [];
|
||||||
for (let x = 1; x <= partitionMonths; x += 1) {
|
for (let x = 1; x <= partitionMonths; x += 1) {
|
||||||
partitionPromises.push(
|
partitionPromises.push(
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "../schemas";
|
||||||
|
|
||||||
|
const GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME = "googleSsoAuthEnforced";
|
||||||
|
const GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME = "googleSsoAuthLastUsed";
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
const hasGoogleSsoAuthEnforcedColumn = await knex.schema.hasColumn(
|
||||||
|
TableName.Organization,
|
||||||
|
GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME
|
||||||
|
);
|
||||||
|
const hasGoogleSsoAuthLastUsedColumn = await knex.schema.hasColumn(
|
||||||
|
TableName.Organization,
|
||||||
|
GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME
|
||||||
|
);
|
||||||
|
|
||||||
|
await knex.schema.alterTable(TableName.Organization, (table) => {
|
||||||
|
if (!hasGoogleSsoAuthEnforcedColumn)
|
||||||
|
table.boolean(GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME).defaultTo(false).notNullable();
|
||||||
|
if (!hasGoogleSsoAuthLastUsedColumn) table.timestamp(GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME).nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
const hasGoogleSsoAuthEnforcedColumn = await knex.schema.hasColumn(
|
||||||
|
TableName.Organization,
|
||||||
|
GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasGoogleSsoAuthLastUsedColumn = await knex.schema.hasColumn(
|
||||||
|
TableName.Organization,
|
||||||
|
GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME
|
||||||
|
);
|
||||||
|
|
||||||
|
await knex.schema.alterTable(TableName.Organization, (table) => {
|
||||||
|
if (hasGoogleSsoAuthEnforcedColumn) table.dropColumn(GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME);
|
||||||
|
if (hasGoogleSsoAuthLastUsedColumn) table.dropColumn(GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export const OrganizationsSchema = z.object({
|
|||||||
scannerProductEnabled: z.boolean().default(true).nullable().optional(),
|
scannerProductEnabled: z.boolean().default(true).nullable().optional(),
|
||||||
shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(),
|
shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(),
|
||||||
maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(),
|
maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(),
|
||||||
maxSharedSecretViewLimit: z.number().nullable().optional()
|
maxSharedSecretViewLimit: z.number().nullable().optional(),
|
||||||
|
googleSsoAuthEnforced: z.boolean().default(false),
|
||||||
|
googleSsoAuthLastUsed: z.date().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TOrganizations = z.infer<typeof OrganizationsSchema>;
|
export type TOrganizations = z.infer<typeof OrganizationsSchema>;
|
||||||
|
|||||||
@@ -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({
|
||||||
@@ -116,6 +133,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
|
|||||||
approvals: z.number(),
|
approvals: z.number(),
|
||||||
approvers: z
|
approvers: z
|
||||||
.object({
|
.object({
|
||||||
|
isOrgMembershipActive: z.boolean().nullable().optional(),
|
||||||
userId: z.string().nullable().optional(),
|
userId: z.string().nullable().optional(),
|
||||||
sequence: z.number().nullable().optional(),
|
sequence: z.number().nullable().optional(),
|
||||||
approvalsRequired: z.number().nullable().optional(),
|
approvalsRequired: z.number().nullable().optional(),
|
||||||
@@ -128,10 +146,12 @@ 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({
|
||||||
|
isOrgMembershipActive: z.boolean().nullable().optional(),
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
status: z.string()
|
status: z.string()
|
||||||
})
|
})
|
||||||
@@ -189,4 +209,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 };
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -294,12 +294,13 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
|
|||||||
200: z.object({
|
200: z.object({
|
||||||
approval: SecretApprovalRequestsSchema.merge(
|
approval: SecretApprovalRequestsSchema.merge(
|
||||||
z.object({
|
z.object({
|
||||||
// secretPath: z.string(),
|
|
||||||
policy: z.object({
|
policy: z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
approvals: z.number(),
|
approvals: z.number(),
|
||||||
approvers: approvalRequestUser.array(),
|
approvers: approvalRequestUser
|
||||||
|
.extend({ isOrgMembershipActive: z.boolean().nullable().optional() })
|
||||||
|
.array(),
|
||||||
bypassers: approvalRequestUser.array(),
|
bypassers: approvalRequestUser.array(),
|
||||||
secretPath: z.string().optional().nullable(),
|
secretPath: z.string().optional().nullable(),
|
||||||
enforcementLevel: z.string(),
|
enforcementLevel: z.string(),
|
||||||
@@ -309,7 +310,13 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
|
|||||||
environment: z.string(),
|
environment: z.string(),
|
||||||
statusChangedByUser: approvalRequestUser.optional(),
|
statusChangedByUser: approvalRequestUser.optional(),
|
||||||
committerUser: approvalRequestUser.nullish(),
|
committerUser: approvalRequestUser.nullish(),
|
||||||
reviewers: approvalRequestUser.extend({ status: z.string(), comment: z.string().optional() }).array(),
|
reviewers: approvalRequestUser
|
||||||
|
.extend({
|
||||||
|
status: z.string(),
|
||||||
|
comment: z.string().optional(),
|
||||||
|
isOrgMembershipActive: z.boolean().nullable().optional()
|
||||||
|
})
|
||||||
|
.array(),
|
||||||
secretPath: z.string(),
|
secretPath: z.string(),
|
||||||
commits: secretRawSchema
|
commits: secretRawSchema
|
||||||
.omit({ _id: true, environment: true, workspace: true, type: true, version: true, secretValue: true })
|
.omit({ _id: true, environment: true, workspace: true, type: true, version: true, secretValue: true })
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
AccessApprovalRequestsSchema,
|
AccessApprovalRequestsSchema,
|
||||||
TableName,
|
TableName,
|
||||||
TAccessApprovalRequests,
|
TAccessApprovalRequests,
|
||||||
|
TOrgMemberships,
|
||||||
TUserGroupMembership,
|
TUserGroupMembership,
|
||||||
TUsers
|
TUsers
|
||||||
} from "@app/db/schemas";
|
} from "@app/db/schemas";
|
||||||
@@ -63,6 +64,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[];
|
||||||
@@ -143,6 +145,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
|
|||||||
approvalsRequired: number | null | undefined;
|
approvalsRequired: number | null | undefined;
|
||||||
email: string | null | undefined;
|
email: string | null | undefined;
|
||||||
username: string;
|
username: string;
|
||||||
|
isOrgMembershipActive: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -150,6 +153,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
|
|||||||
approvalsRequired: number | null | undefined;
|
approvalsRequired: number | null | undefined;
|
||||||
email: string | null | undefined;
|
email: string | null | undefined;
|
||||||
username: string;
|
username: string;
|
||||||
|
isOrgMembershipActive: boolean;
|
||||||
}
|
}
|
||||||
)[];
|
)[];
|
||||||
bypassers: string[];
|
bypassers: string[];
|
||||||
@@ -161,6 +165,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;
|
||||||
@@ -200,6 +205,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
|
|||||||
reviewers: {
|
reviewers: {
|
||||||
userId: string;
|
userId: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
isOrgMembershipActive: boolean;
|
||||||
}[];
|
}[];
|
||||||
approvers: (
|
approvers: (
|
||||||
| {
|
| {
|
||||||
@@ -208,6 +214,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
|
|||||||
approvalsRequired: number | null | undefined;
|
approvalsRequired: number | null | undefined;
|
||||||
email: string | null | undefined;
|
email: string | null | undefined;
|
||||||
username: string;
|
username: string;
|
||||||
|
isOrgMembershipActive: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -215,6 +222,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit<TOrmify<TableName
|
|||||||
approvalsRequired: number | null | undefined;
|
approvalsRequired: number | null | undefined;
|
||||||
email: string | null | undefined;
|
email: string | null | undefined;
|
||||||
username: string;
|
username: string;
|
||||||
|
isOrgMembershipActive: boolean;
|
||||||
}
|
}
|
||||||
)[];
|
)[];
|
||||||
bypassers: string[];
|
bypassers: string[];
|
||||||
@@ -286,6 +294,24 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
|
|||||||
`requestedByUser.id`
|
`requestedByUser.id`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
.leftJoin<TOrgMemberships>(
|
||||||
|
db(TableName.OrgMembership).as("approverOrgMembership"),
|
||||||
|
`${TableName.AccessApprovalPolicyApprover}.approverUserId`,
|
||||||
|
`approverOrgMembership.userId`
|
||||||
|
)
|
||||||
|
|
||||||
|
.leftJoin<TOrgMemberships>(
|
||||||
|
db(TableName.OrgMembership).as("approverGroupOrgMembership"),
|
||||||
|
`${TableName.Users}.id`,
|
||||||
|
`approverGroupOrgMembership.userId`
|
||||||
|
)
|
||||||
|
|
||||||
|
.leftJoin<TOrgMemberships>(
|
||||||
|
db(TableName.OrgMembership).as("reviewerOrgMembership"),
|
||||||
|
`${TableName.AccessApprovalRequestReviewer}.reviewerUserId`,
|
||||||
|
`reviewerOrgMembership.userId`
|
||||||
|
)
|
||||||
|
|
||||||
.leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`)
|
.leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`)
|
||||||
|
|
||||||
.select(selectAllTableCols(TableName.AccessApprovalRequest))
|
.select(selectAllTableCols(TableName.AccessApprovalRequest))
|
||||||
@@ -297,7 +323,12 @@ 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("isActive").withSchema("approverOrgMembership").as("approverIsOrgMembershipActive"),
|
||||||
|
db.ref("isActive").withSchema("approverGroupOrgMembership").as("approverGroupIsOrgMembershipActive"),
|
||||||
|
db.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive"),
|
||||||
|
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 +395,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,
|
||||||
@@ -392,17 +424,26 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
|
|||||||
{
|
{
|
||||||
key: "reviewerUserId",
|
key: "reviewerUserId",
|
||||||
label: "reviewers" as const,
|
label: "reviewers" as const,
|
||||||
mapper: ({ reviewerUserId: userId, reviewerStatus: status }) => (userId ? { userId, status } : undefined)
|
mapper: ({ reviewerUserId: userId, reviewerStatus: status, reviewerIsOrgMembershipActive }) =>
|
||||||
|
userId ? { userId, status, isOrgMembershipActive: reviewerIsOrgMembershipActive } : undefined
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "approverUserId",
|
key: "approverUserId",
|
||||||
label: "approvers" as const,
|
label: "approvers" as const,
|
||||||
mapper: ({ approverUserId, approverSequence, approvalsRequired, approverUsername, approverEmail }) => ({
|
mapper: ({
|
||||||
|
approverUserId,
|
||||||
|
approverSequence,
|
||||||
|
approvalsRequired,
|
||||||
|
approverUsername,
|
||||||
|
approverEmail,
|
||||||
|
approverIsOrgMembershipActive
|
||||||
|
}) => ({
|
||||||
userId: approverUserId,
|
userId: approverUserId,
|
||||||
sequence: approverSequence,
|
sequence: approverSequence,
|
||||||
approvalsRequired,
|
approvalsRequired,
|
||||||
email: approverEmail,
|
email: approverEmail,
|
||||||
username: approverUsername
|
username: approverUsername,
|
||||||
|
isOrgMembershipActive: approverIsOrgMembershipActive
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -413,13 +454,15 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR
|
|||||||
approverSequence,
|
approverSequence,
|
||||||
approvalsRequired,
|
approvalsRequired,
|
||||||
approverGroupEmail,
|
approverGroupEmail,
|
||||||
approverGroupUsername
|
approverGroupUsername,
|
||||||
|
approverGroupIsOrgMembershipActive
|
||||||
}) => ({
|
}) => ({
|
||||||
userId: approverGroupUserId,
|
userId: approverGroupUserId,
|
||||||
sequence: approverSequence,
|
sequence: approverSequence,
|
||||||
approvalsRequired,
|
approvalsRequired,
|
||||||
email: approverGroupEmail,
|
email: approverGroupEmail,
|
||||||
username: approverGroupUsername
|
username: approverGroupUsername,
|
||||||
|
isOrgMembershipActive: approverGroupIsOrgMembershipActive
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{ key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId },
|
{ key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId },
|
||||||
@@ -574,7 +617,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 +639,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,
|
||||||
|
|||||||
+160
-1
@@ -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: {
|
||||||
@@ -64,6 +87,7 @@ export interface TAccessApprovalRequestServiceFactory {
|
|||||||
approvalsRequired: number | null | undefined;
|
approvalsRequired: number | null | undefined;
|
||||||
email: string | null | undefined;
|
email: string | null | undefined;
|
||||||
username: string;
|
username: string;
|
||||||
|
isOrgMembershipActive: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -71,6 +95,7 @@ export interface TAccessApprovalRequestServiceFactory {
|
|||||||
approvalsRequired: number | null | undefined;
|
approvalsRequired: number | null | undefined;
|
||||||
email: string | null | undefined;
|
email: string | null | undefined;
|
||||||
username: string;
|
username: string;
|
||||||
|
isOrgMembershipActive: boolean;
|
||||||
}
|
}
|
||||||
)[];
|
)[];
|
||||||
bypassers: string[];
|
bypassers: string[];
|
||||||
@@ -82,6 +107,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;
|
||||||
@@ -121,6 +147,7 @@ export interface TAccessApprovalRequestServiceFactory {
|
|||||||
reviewers: {
|
reviewers: {
|
||||||
userId: string;
|
userId: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
isOrgMembershipActive: boolean;
|
||||||
}[];
|
}[];
|
||||||
approvers: (
|
approvers: (
|
||||||
| {
|
| {
|
||||||
@@ -129,6 +156,7 @@ export interface TAccessApprovalRequestServiceFactory {
|
|||||||
approvalsRequired: number | null | undefined;
|
approvalsRequired: number | null | undefined;
|
||||||
email: string | null | undefined;
|
email: string | null | undefined;
|
||||||
username: string;
|
username: string;
|
||||||
|
isOrgMembershipActive: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -136,6 +164,7 @@ export interface TAccessApprovalRequestServiceFactory {
|
|||||||
approvalsRequired: number | null | undefined;
|
approvalsRequired: number | null | undefined;
|
||||||
email: string | null | undefined;
|
email: string | null | undefined;
|
||||||
username: string;
|
username: string;
|
||||||
|
isOrgMembershipActive: boolean;
|
||||||
}
|
}
|
||||||
)[];
|
)[];
|
||||||
bypassers: string[];
|
bypassers: string[];
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { ActorType } from "@app/services/auth/auth-type";
|
|||||||
import { EventType, filterableSecretEvents } from "./audit-log-types";
|
import { EventType, filterableSecretEvents } from "./audit-log-types";
|
||||||
|
|
||||||
export interface TAuditLogDALFactory extends Omit<TOrmify<TableName.AuditLog>, "find"> {
|
export interface TAuditLogDALFactory extends Omit<TOrmify<TableName.AuditLog>, "find"> {
|
||||||
pruneAuditLog: (tx?: knex.Knex) => Promise<void>;
|
pruneAuditLog: () => Promise<void>;
|
||||||
find: (
|
find: (
|
||||||
arg: Omit<TFindQuery, "actor" | "eventType"> & {
|
arg: Omit<TFindQuery, "actor" | "eventType"> & {
|
||||||
actorId?: string | undefined;
|
actorId?: string | undefined;
|
||||||
@@ -41,6 +41,10 @@ type TFindQuery = {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000;
|
||||||
|
const MAX_RETRY_ON_FAILURE = 3;
|
||||||
|
|
||||||
export const auditLogDALFactory = (db: TDbClient) => {
|
export const auditLogDALFactory = (db: TDbClient) => {
|
||||||
const auditLogOrm = ormify(db, TableName.AuditLog);
|
const auditLogOrm = ormify(db, TableName.AuditLog);
|
||||||
|
|
||||||
@@ -151,20 +155,20 @@ export const auditLogDALFactory = (db: TDbClient) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// delete all audit log that have expired
|
// delete all audit log that have expired
|
||||||
const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async (tx) => {
|
const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async () => {
|
||||||
const runPrune = async (dbClient: knex.Knex) => {
|
const today = new Date();
|
||||||
const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000;
|
let deletedAuditLogIds: { id: string }[] = [];
|
||||||
const MAX_RETRY_ON_FAILURE = 3;
|
let numberOfRetryOnFailure = 0;
|
||||||
|
let isRetrying = false;
|
||||||
|
|
||||||
const today = new Date();
|
logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`);
|
||||||
let deletedAuditLogIds: { id: string }[] = [];
|
do {
|
||||||
let numberOfRetryOnFailure = 0;
|
try {
|
||||||
let isRetrying = false;
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
deletedAuditLogIds = await db.transaction(async (trx) => {
|
||||||
|
await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`);
|
||||||
|
|
||||||
logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`);
|
const findExpiredLogSubQuery = trx(TableName.AuditLog)
|
||||||
do {
|
|
||||||
try {
|
|
||||||
const findExpiredLogSubQuery = dbClient(TableName.AuditLog)
|
|
||||||
.where("expiresAt", "<", today)
|
.where("expiresAt", "<", today)
|
||||||
.where("createdAt", "<", today) // to use audit log partition
|
.where("createdAt", "<", today) // to use audit log partition
|
||||||
.orderBy(`${TableName.AuditLog}.createdAt`, "desc")
|
.orderBy(`${TableName.AuditLog}.createdAt`, "desc")
|
||||||
@@ -172,34 +176,25 @@ export const auditLogDALFactory = (db: TDbClient) => {
|
|||||||
.limit(AUDIT_LOG_PRUNE_BATCH_SIZE);
|
.limit(AUDIT_LOG_PRUNE_BATCH_SIZE);
|
||||||
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
deletedAuditLogIds = await dbClient(TableName.AuditLog)
|
const results = await trx(TableName.AuditLog).whereIn("id", findExpiredLogSubQuery).del().returning("id");
|
||||||
.whereIn("id", findExpiredLogSubQuery)
|
|
||||||
.del()
|
|
||||||
.returning("id");
|
|
||||||
numberOfRetryOnFailure = 0; // reset
|
|
||||||
} catch (error) {
|
|
||||||
numberOfRetryOnFailure += 1;
|
|
||||||
logger.error(error, "Failed to delete audit log on pruning");
|
|
||||||
} finally {
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, 10); // time to breathe for db
|
|
||||||
});
|
|
||||||
}
|
|
||||||
isRetrying = numberOfRetryOnFailure > 0;
|
|
||||||
} while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE));
|
|
||||||
logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (tx) {
|
return results;
|
||||||
await runPrune(tx);
|
});
|
||||||
} else {
|
|
||||||
const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
numberOfRetryOnFailure = 0; // reset
|
||||||
await db.transaction(async (trx) => {
|
} catch (error) {
|
||||||
await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`);
|
numberOfRetryOnFailure += 1;
|
||||||
await runPrune(trx);
|
deletedAuditLogIds = [];
|
||||||
});
|
logger.error(error, "Failed to delete audit log on pruning");
|
||||||
}
|
} finally {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, 10); // time to breathe for db
|
||||||
|
});
|
||||||
|
}
|
||||||
|
isRetrying = numberOfRetryOnFailure > 0;
|
||||||
|
} while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE));
|
||||||
|
logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const create: TAuditLogDALFactory["create"] = async (tx) => {
|
const create: TAuditLogDALFactory["create"] = async (tx) => {
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
@@ -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 ?? "",
|
||||||
? {
|
secretPath: { $glob: secretPath }
|
||||||
environment: r.conditions?.environmentSlug ?? "",
|
}
|
||||||
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");
|
||||||
@@ -126,7 +123,7 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien
|
|||||||
|
|
||||||
await redis.set(key, "1", "EX", 60);
|
await redis.set(key, "1", "EX", 60);
|
||||||
|
|
||||||
stream.push("1");
|
send({ type: "ping" });
|
||||||
};
|
};
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
|
|||||||
@@ -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
|
switch (input) {
|
||||||
| EventType.DELETE_SECRET
|
case BusEventName.CreateSecret:
|
||||||
| EventType.DELETE_SECRETS
|
return ProjectPermissionSecretEventActions.SubscribeCreated;
|
||||||
| EventType.UPDATE_SECRETS
|
case BusEventName.DeleteSecret:
|
||||||
| EventType.UPDATE_SECRET;
|
return ProjectPermissionSecretEventActions.SubscribeDeleted;
|
||||||
|
case BusEventName.ImportMutation:
|
||||||
export function toBusEventName(input: EventType) {
|
return ProjectPermissionSecretEventActions.SubscribeImportMutations;
|
||||||
switch (input) {
|
case BusEventName.UpdateSecret:
|
||||||
case EventType.CREATE_SECRET:
|
return ProjectPermissionSecretEventActions.SubscribeUpdated;
|
||||||
case EventType.CREATE_SECRETS:
|
default:
|
||||||
return BusEventName.CreateSecret;
|
throw new Error("Unknown bus event name");
|
||||||
case EventType.UPDATE_SECRET:
|
|
||||||
case EventType.UPDATE_SECRETS:
|
|
||||||
return BusEventName.UpdateSecret;
|
|
||||||
case EventType.DELETE_SECRET:
|
|
||||||
case EventType.DELETE_SECRETS:
|
|
||||||
return BusEventName.DeleteSecret;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
|
|||||||
auditLogStreams: false,
|
auditLogStreams: false,
|
||||||
auditLogStreamLimit: 3,
|
auditLogStreamLimit: 3,
|
||||||
samlSSO: false,
|
samlSSO: false,
|
||||||
|
enforceGoogleSSO: false,
|
||||||
hsm: false,
|
hsm: false,
|
||||||
oidcSSO: false,
|
oidcSSO: false,
|
||||||
scim: false,
|
scim: false,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export type TFeatureSet = {
|
|||||||
auditLogStreamLimit: 3;
|
auditLogStreamLimit: 3;
|
||||||
githubOrgSync: false;
|
githubOrgSync: false;
|
||||||
samlSSO: false;
|
samlSSO: false;
|
||||||
|
enforceGoogleSSO: false;
|
||||||
hsm: false;
|
hsm: false;
|
||||||
oidcSSO: false;
|
oidcSSO: false;
|
||||||
secretAccessInsights: false;
|
secretAccessInsights: false;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ProjectPermissionPkiSubscriberActions,
|
ProjectPermissionPkiSubscriberActions,
|
||||||
ProjectPermissionPkiTemplateActions,
|
ProjectPermissionPkiTemplateActions,
|
||||||
ProjectPermissionSecretActions,
|
ProjectPermissionSecretActions,
|
||||||
|
ProjectPermissionSecretEventActions,
|
||||||
ProjectPermissionSecretRotationActions,
|
ProjectPermissionSecretRotationActions,
|
||||||
ProjectPermissionSecretScanningConfigActions,
|
ProjectPermissionSecretScanningConfigActions,
|
||||||
ProjectPermissionSecretScanningDataSourceActions,
|
ProjectPermissionSecretScanningDataSourceActions,
|
||||||
@@ -161,8 +162,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
|
||||||
);
|
);
|
||||||
@@ -253,6 +253,16 @@ const buildAdminPermissionRules = () => {
|
|||||||
ProjectPermissionSub.SecretScanningConfigs
|
ProjectPermissionSub.SecretScanningConfigs
|
||||||
);
|
);
|
||||||
|
|
||||||
|
can(
|
||||||
|
[
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeCreated,
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeDeleted,
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeUpdated,
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeImportMutations
|
||||||
|
],
|
||||||
|
ProjectPermissionSub.SecretEvents
|
||||||
|
);
|
||||||
|
|
||||||
return rules;
|
return rules;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -266,8 +276,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
|
||||||
);
|
);
|
||||||
@@ -457,6 +466,16 @@ const buildMemberPermissionRules = () => {
|
|||||||
|
|
||||||
can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs);
|
can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs);
|
||||||
|
|
||||||
|
can(
|
||||||
|
[
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeCreated,
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeDeleted,
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeUpdated,
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeImportMutations
|
||||||
|
],
|
||||||
|
ProjectPermissionSub.SecretEvents
|
||||||
|
);
|
||||||
|
|
||||||
return rules;
|
return rules;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -507,6 +526,16 @@ const buildViewerPermissionRules = () => {
|
|||||||
|
|
||||||
can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs);
|
can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs);
|
||||||
|
|
||||||
|
can(
|
||||||
|
[
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeCreated,
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeDeleted,
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeUpdated,
|
||||||
|
ProjectPermissionSecretEventActions.SubscribeImportMutations
|
||||||
|
],
|
||||||
|
ProjectPermissionSub.SecretEvents
|
||||||
|
);
|
||||||
|
|
||||||
return rules;
|
return rules;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface TPermissionDALFactory {
|
|||||||
projectFavorites?: string[] | null | undefined;
|
projectFavorites?: string[] | null | undefined;
|
||||||
customRoleSlug?: string | null | undefined;
|
customRoleSlug?: string | null | undefined;
|
||||||
orgAuthEnforced?: boolean | null | undefined;
|
orgAuthEnforced?: boolean | null | undefined;
|
||||||
|
orgGoogleSsoAuthEnforced: boolean;
|
||||||
} & {
|
} & {
|
||||||
groups: {
|
groups: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -87,6 +88,7 @@ export interface TPermissionDALFactory {
|
|||||||
}[];
|
}[];
|
||||||
orgId: string;
|
orgId: string;
|
||||||
orgAuthEnforced: boolean | null | undefined;
|
orgAuthEnforced: boolean | null | undefined;
|
||||||
|
orgGoogleSsoAuthEnforced: boolean;
|
||||||
orgRole: OrgMembershipRole;
|
orgRole: OrgMembershipRole;
|
||||||
userId: string;
|
userId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -350,6 +352,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
|||||||
db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"),
|
db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"),
|
||||||
db.ref("permissions").withSchema(TableName.OrgRoles),
|
db.ref("permissions").withSchema(TableName.OrgRoles),
|
||||||
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
|
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
|
||||||
|
db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"),
|
||||||
db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
|
db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
|
||||||
db.ref("groupId").withSchema("userGroups"),
|
db.ref("groupId").withSchema("userGroups"),
|
||||||
db.ref("groupOrgId").withSchema("userGroups"),
|
db.ref("groupOrgId").withSchema("userGroups"),
|
||||||
@@ -369,6 +372,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
|||||||
OrgMembershipsSchema.extend({
|
OrgMembershipsSchema.extend({
|
||||||
permissions: z.unknown(),
|
permissions: z.unknown(),
|
||||||
orgAuthEnforced: z.boolean().optional().nullable(),
|
orgAuthEnforced: z.boolean().optional().nullable(),
|
||||||
|
orgGoogleSsoAuthEnforced: z.boolean(),
|
||||||
bypassOrgAuthEnabled: z.boolean(),
|
bypassOrgAuthEnabled: z.boolean(),
|
||||||
customRoleSlug: z.string().optional().nullable(),
|
customRoleSlug: z.string().optional().nullable(),
|
||||||
shouldUseNewPrivilegeSystem: z.boolean()
|
shouldUseNewPrivilegeSystem: z.boolean()
|
||||||
@@ -988,6 +992,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
|||||||
db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"),
|
db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"),
|
||||||
db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"),
|
db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"),
|
||||||
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
|
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
|
||||||
|
db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"),
|
||||||
db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
|
db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
|
||||||
db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"),
|
db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"),
|
||||||
db.ref("orgId").withSchema(TableName.Project),
|
db.ref("orgId").withSchema(TableName.Project),
|
||||||
@@ -1003,6 +1008,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
|||||||
orgId,
|
orgId,
|
||||||
username,
|
username,
|
||||||
orgAuthEnforced,
|
orgAuthEnforced,
|
||||||
|
orgGoogleSsoAuthEnforced,
|
||||||
orgRole,
|
orgRole,
|
||||||
membershipId,
|
membershipId,
|
||||||
groupMembershipId,
|
groupMembershipId,
|
||||||
@@ -1016,6 +1022,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
|||||||
}) => ({
|
}) => ({
|
||||||
orgId,
|
orgId,
|
||||||
orgAuthEnforced,
|
orgAuthEnforced,
|
||||||
|
orgGoogleSsoAuthEnforced,
|
||||||
orgRole: orgRole as OrgMembershipRole,
|
orgRole: orgRole as OrgMembershipRole,
|
||||||
userId,
|
userId,
|
||||||
projectId,
|
projectId,
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) {
|
|||||||
function validateOrgSSO(
|
function validateOrgSSO(
|
||||||
actorAuthMethod: ActorAuthMethod,
|
actorAuthMethod: ActorAuthMethod,
|
||||||
isOrgSsoEnforced: TOrganizations["authEnforced"],
|
isOrgSsoEnforced: TOrganizations["authEnforced"],
|
||||||
|
isOrgGoogleSsoEnforced: TOrganizations["googleSsoAuthEnforced"],
|
||||||
isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"],
|
isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"],
|
||||||
orgRole: OrgMembershipRole
|
orgRole: OrgMembershipRole
|
||||||
) {
|
) {
|
||||||
@@ -128,10 +129,16 @@ function validateOrgSSO(
|
|||||||
throw new UnauthorizedError({ name: "No auth method defined" });
|
throw new UnauthorizedError({ name: "No auth method defined" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOrgSsoEnforced && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) {
|
if ((isOrgSsoEnforced || isOrgGoogleSsoEnforced) && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// case: google sso is enforced, but the actor is not using google sso
|
||||||
|
if (isOrgGoogleSsoEnforced && actorAuthMethod !== null && actorAuthMethod !== AuthMethod.GOOGLE) {
|
||||||
|
throw new ForbiddenRequestError({ name: "Org auth enforced. Cannot access org-scoped resource" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// case: SAML SSO is enforced, but the actor is not using SAML SSO
|
||||||
if (
|
if (
|
||||||
isOrgSsoEnforced &&
|
isOrgSsoEnforced &&
|
||||||
actorAuthMethod !== null &&
|
actorAuthMethod !== null &&
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export const permissionServiceFactory = ({
|
|||||||
validateOrgSSO(
|
validateOrgSSO(
|
||||||
authMethod,
|
authMethod,
|
||||||
membership.orgAuthEnforced,
|
membership.orgAuthEnforced,
|
||||||
|
membership.orgGoogleSsoAuthEnforced,
|
||||||
membership.bypassOrgAuthEnabled,
|
membership.bypassOrgAuthEnabled,
|
||||||
membership.role as OrgMembershipRole
|
membership.role as OrgMembershipRole
|
||||||
);
|
);
|
||||||
@@ -238,6 +239,7 @@ export const permissionServiceFactory = ({
|
|||||||
validateOrgSSO(
|
validateOrgSSO(
|
||||||
authMethod,
|
authMethod,
|
||||||
userProjectPermission.orgAuthEnforced,
|
userProjectPermission.orgAuthEnforced,
|
||||||
|
userProjectPermission.orgGoogleSsoAuthEnforced,
|
||||||
userProjectPermission.bypassOrgAuthEnabled,
|
userProjectPermission.bypassOrgAuthEnabled,
|
||||||
userProjectPermission.orgRole
|
userProjectPermission.orgRole
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { TDbClient } from "@app/db";
|
|||||||
import {
|
import {
|
||||||
SecretApprovalRequestsSchema,
|
SecretApprovalRequestsSchema,
|
||||||
TableName,
|
TableName,
|
||||||
|
TOrgMemberships,
|
||||||
TSecretApprovalRequests,
|
TSecretApprovalRequests,
|
||||||
TSecretApprovalRequestsSecrets,
|
TSecretApprovalRequestsSecrets,
|
||||||
TUserGroupMembership,
|
TUserGroupMembership,
|
||||||
@@ -107,11 +108,32 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
|||||||
`${TableName.SecretApprovalRequestReviewer}.reviewerUserId`,
|
`${TableName.SecretApprovalRequestReviewer}.reviewerUserId`,
|
||||||
`secretApprovalReviewerUser.id`
|
`secretApprovalReviewerUser.id`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
.leftJoin<TOrgMemberships>(
|
||||||
|
db(TableName.OrgMembership).as("approverOrgMembership"),
|
||||||
|
`${TableName.SecretApprovalPolicyApprover}.approverUserId`,
|
||||||
|
`approverOrgMembership.userId`
|
||||||
|
)
|
||||||
|
|
||||||
|
.leftJoin<TOrgMemberships>(
|
||||||
|
db(TableName.OrgMembership).as("approverGroupOrgMembership"),
|
||||||
|
`secretApprovalPolicyGroupApproverUser.id`,
|
||||||
|
`approverGroupOrgMembership.userId`
|
||||||
|
)
|
||||||
|
|
||||||
|
.leftJoin<TOrgMemberships>(
|
||||||
|
db(TableName.OrgMembership).as("reviewerOrgMembership"),
|
||||||
|
`${TableName.SecretApprovalRequestReviewer}.reviewerUserId`,
|
||||||
|
`reviewerOrgMembership.userId`
|
||||||
|
)
|
||||||
|
|
||||||
.select(selectAllTableCols(TableName.SecretApprovalRequest))
|
.select(selectAllTableCols(TableName.SecretApprovalRequest))
|
||||||
.select(
|
.select(
|
||||||
tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
|
tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover),
|
||||||
tx.ref("userId").withSchema("approverUserGroupMembership").as("approverGroupUserId"),
|
tx.ref("userId").withSchema("approverUserGroupMembership").as("approverGroupUserId"),
|
||||||
tx.ref("email").withSchema("secretApprovalPolicyApproverUser").as("approverEmail"),
|
tx.ref("email").withSchema("secretApprovalPolicyApproverUser").as("approverEmail"),
|
||||||
|
tx.ref("isActive").withSchema("approverOrgMembership").as("approverIsOrgMembershipActive"),
|
||||||
|
tx.ref("isActive").withSchema("approverGroupOrgMembership").as("approverGroupIsOrgMembershipActive"),
|
||||||
tx.ref("email").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupEmail"),
|
tx.ref("email").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupEmail"),
|
||||||
tx.ref("username").withSchema("secretApprovalPolicyApproverUser").as("approverUsername"),
|
tx.ref("username").withSchema("secretApprovalPolicyApproverUser").as("approverUsername"),
|
||||||
tx.ref("username").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupUsername"),
|
tx.ref("username").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupUsername"),
|
||||||
@@ -148,6 +170,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
|||||||
tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"),
|
tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"),
|
||||||
tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"),
|
tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"),
|
||||||
tx.ref("lastName").withSchema("secretApprovalReviewerUser").as("reviewerLastName"),
|
tx.ref("lastName").withSchema("secretApprovalReviewerUser").as("reviewerLastName"),
|
||||||
|
tx.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive"),
|
||||||
tx.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"),
|
tx.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"),
|
||||||
tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"),
|
tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"),
|
||||||
tx.ref("projectId").withSchema(TableName.Environment),
|
tx.ref("projectId").withSchema(TableName.Environment),
|
||||||
@@ -211,9 +234,21 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
|||||||
reviewerLastName: lastName,
|
reviewerLastName: lastName,
|
||||||
reviewerUsername: username,
|
reviewerUsername: username,
|
||||||
reviewerFirstName: firstName,
|
reviewerFirstName: firstName,
|
||||||
reviewerComment: comment
|
reviewerComment: comment,
|
||||||
|
reviewerIsOrgMembershipActive: isOrgMembershipActive
|
||||||
}) =>
|
}) =>
|
||||||
userId ? { userId, status, email, firstName, lastName, username, comment: comment ?? "" } : undefined
|
userId
|
||||||
|
? {
|
||||||
|
userId,
|
||||||
|
status,
|
||||||
|
email,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
username,
|
||||||
|
comment: comment ?? "",
|
||||||
|
isOrgMembershipActive
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "approverUserId",
|
key: "approverUserId",
|
||||||
@@ -223,13 +258,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
|||||||
approverEmail: email,
|
approverEmail: email,
|
||||||
approverUsername: username,
|
approverUsername: username,
|
||||||
approverLastName: lastName,
|
approverLastName: lastName,
|
||||||
approverFirstName: firstName
|
approverFirstName: firstName,
|
||||||
|
approverIsOrgMembershipActive: isOrgMembershipActive
|
||||||
}) => ({
|
}) => ({
|
||||||
userId,
|
userId,
|
||||||
email,
|
email,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
username
|
username,
|
||||||
|
isOrgMembershipActive
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -240,13 +277,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
|||||||
approverGroupEmail: email,
|
approverGroupEmail: email,
|
||||||
approverGroupUsername: username,
|
approverGroupUsername: username,
|
||||||
approverGroupLastName: lastName,
|
approverGroupLastName: lastName,
|
||||||
approverGroupFirstName: firstName
|
approverGroupFirstName: firstName,
|
||||||
|
approverGroupIsOrgMembershipActive: isOrgMembershipActive
|
||||||
}) => ({
|
}) => ({
|
||||||
userId,
|
userId,
|
||||||
email,
|
email,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
username
|
username,
|
||||||
|
isOrgMembershipActive
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ export const secretApprovalRequestServiceFactory = ({
|
|||||||
if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" });
|
if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" });
|
||||||
|
|
||||||
const secretApprovalRequest = await secretApprovalRequestDAL.findById(id);
|
const secretApprovalRequest = await secretApprovalRequestDAL.findById(id);
|
||||||
|
|
||||||
if (!secretApprovalRequest)
|
if (!secretApprovalRequest)
|
||||||
throw new NotFoundError({ message: `Secret approval request with ID '${id}' not found` });
|
throw new NotFoundError({ message: `Secret approval request with ID '${id}' not found` });
|
||||||
|
|
||||||
@@ -952,13 +953,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) {
|
||||||
@@ -1421,6 +1448,7 @@ export const secretApprovalRequestServiceFactory = ({
|
|||||||
|
|
||||||
const commits: Omit<TSecretApprovalRequestsSecretsV2Insert, "requestId">[] = [];
|
const commits: Omit<TSecretApprovalRequestsSecretsV2Insert, "requestId">[] = [];
|
||||||
const commitTagIds: Record<string, string[]> = {};
|
const commitTagIds: Record<string, string[]> = {};
|
||||||
|
const existingTagIds: Record<string, string[]> = {};
|
||||||
|
|
||||||
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
|
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
|
||||||
type: KmsDataKey.SecretManager,
|
type: KmsDataKey.SecretManager,
|
||||||
@@ -1486,6 +1514,11 @@ export const secretApprovalRequestServiceFactory = ({
|
|||||||
type: SecretType.Shared
|
type: SecretType.Shared
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
secretsToUpdateStoredInDB.forEach((el) => {
|
||||||
|
if (el.tags?.length) existingTagIds[el.key] = el.tags.map((i) => i.id);
|
||||||
|
});
|
||||||
|
|
||||||
if (secretsToUpdateStoredInDB.length !== secretsToUpdate.length)
|
if (secretsToUpdateStoredInDB.length !== secretsToUpdate.length)
|
||||||
throw new NotFoundError({
|
throw new NotFoundError({
|
||||||
message: `Secret does not exist: ${secretsToUpdateStoredInDB.map((el) => el.key).join(",")}`
|
message: `Secret does not exist: ${secretsToUpdateStoredInDB.map((el) => el.key).join(",")}`
|
||||||
@@ -1529,7 +1562,10 @@ export const secretApprovalRequestServiceFactory = ({
|
|||||||
secretMetadata
|
secretMetadata
|
||||||
}) => {
|
}) => {
|
||||||
const secretId = updatingSecretsGroupByKey[secretKey][0].id;
|
const secretId = updatingSecretsGroupByKey[secretKey][0].id;
|
||||||
if (tagIds?.length) commitTagIds[newSecretName ?? secretKey] = tagIds;
|
if (tagIds?.length || existingTagIds[secretKey]?.length) {
|
||||||
|
commitTagIds[newSecretName ?? secretKey] = tagIds || existingTagIds[secretKey];
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...latestSecretVersions[secretId],
|
...latestSecretVersions[secretId],
|
||||||
secretMetadata,
|
secretMetadata,
|
||||||
|
|||||||
@@ -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,14 +66,34 @@ export const secretRotationV2QueueServiceFactory = async ({
|
|||||||
rotation.lastRotatedAt
|
rotation.lastRotatedAt
|
||||||
).toISOString()}] [rotateAt=${new Date(rotation.nextRotationAt!).toISOString()}]`
|
).toISOString()}] [rotateAt=${new Date(rotation.nextRotationAt!).toISOString()}]`
|
||||||
);
|
);
|
||||||
await queueService.queuePg(
|
|
||||||
QueueJobs.SecretRotationV2RotateSecrets,
|
const data = {
|
||||||
{
|
rotationId: rotation.id,
|
||||||
rotationId: rotation.id,
|
queuedAt: currentTime
|
||||||
queuedAt: currentTime
|
} as TSecretRotationRotateSecretsJobPayload;
|
||||||
},
|
|
||||||
getSecretRotationRotateSecretJobOptions(rotation)
|
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(
|
||||||
|
QueueJobs.SecretRotationV2RotateSecrets,
|
||||||
|
{
|
||||||
|
rotationId: rotation.id,
|
||||||
|
queuedAt: currentTime
|
||||||
|
},
|
||||||
|
getSecretRotationRotateSecretJobOptions(rotation)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error, "secretRotationV2Queue: Queue Rotations Error:");
|
logger.error(error, "secretRotationV2Queue: Queue Rotations 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.`,
|
||||||
|
|||||||
@@ -2491,6 +2491,7 @@ export const SecretSyncs = {
|
|||||||
},
|
},
|
||||||
RENDER: {
|
RENDER: {
|
||||||
serviceId: "The ID of the Render service to sync secrets to.",
|
serviceId: "The ID of the Render service to sync secrets to.",
|
||||||
|
environmentGroupId: "The ID of the Render environment group to sync secrets to.",
|
||||||
scope: "The Render scope that secrets should be synced to.",
|
scope: "The Render scope that secrets should be synced to.",
|
||||||
type: "The Render resource type to sync secrets to."
|
type: "The Render resource type to sync secrets to."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Safely retrieves a value from a nested object using dot notation path
|
* Safely retrieves a value from a nested object using dot notation path
|
||||||
*/
|
*/
|
||||||
export const getStringValueByDot = (
|
export const getValueByDot = (
|
||||||
obj: Record<string, unknown> | null | undefined,
|
obj: Record<string, unknown> | null | undefined,
|
||||||
path: string,
|
path: string,
|
||||||
defaultValue?: string
|
defaultValue?: string | number | boolean
|
||||||
): string | undefined => {
|
): string | number | boolean | undefined => {
|
||||||
// Handle null or undefined input
|
// Handle null or undefined input
|
||||||
if (!obj) {
|
if (!obj) {
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
@@ -26,7 +26,7 @@ export const getStringValueByDot = (
|
|||||||
current = (current as Record<string, unknown>)[part];
|
current = (current as Record<string, unknown>)[part];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof current !== "string") {
|
if (typeof current !== "string" && typeof current !== "number" && typeof current !== "boolean") {
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
@@ -1122,7 +1121,9 @@ export const registerRoutes = async (
|
|||||||
resourceMetadataDAL,
|
resourceMetadataDAL,
|
||||||
folderCommitService,
|
folderCommitService,
|
||||||
secretSyncQueue,
|
secretSyncQueue,
|
||||||
reminderService
|
reminderService,
|
||||||
|
eventBusService,
|
||||||
|
licenseService
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectService = projectServiceFactory({
|
const projectService = projectServiceFactory({
|
||||||
@@ -1973,7 +1974,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({
|
||||||
|
|||||||
@@ -49,4 +49,32 @@ export const registerRenderConnectionRouter = async (server: FastifyZodProvider)
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "GET",
|
||||||
|
url: `/:connectionId/environment-groups`,
|
||||||
|
config: {
|
||||||
|
rateLimit: readLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
connectionId: z.string().uuid()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string()
|
||||||
|
})
|
||||||
|
.array()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { connectionId } = req.params;
|
||||||
|
const groups = await server.services.appConnection.render.listEnvironmentGroups(connectionId, req.permission);
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT]),
|
onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }),
|
||||||
handler: () => ({ message: "Authenticated" as const })
|
handler: () => ({ message: "Authenticated" as const })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 } };
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
|||||||
name: GenericResourceNameSchema.optional(),
|
name: GenericResourceNameSchema.optional(),
|
||||||
slug: slugSchema({ max: 64 }).optional(),
|
slug: slugSchema({ max: 64 }).optional(),
|
||||||
authEnforced: z.boolean().optional(),
|
authEnforced: z.boolean().optional(),
|
||||||
|
googleSsoAuthEnforced: z.boolean().optional(),
|
||||||
scimEnabled: z.boolean().optional(),
|
scimEnabled: z.boolean().optional(),
|
||||||
defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(),
|
defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(),
|
||||||
enforceMfa: z.boolean().optional(),
|
enforceMfa: z.boolean().optional(),
|
||||||
|
|||||||
@@ -108,7 +108,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
id: true
|
id: true
|
||||||
}).merge(UserEncryptionKeysSchema.pick({ publicKey: true })),
|
})
|
||||||
|
.merge(UserEncryptionKeysSchema.pick({ publicKey: true }))
|
||||||
|
.extend({
|
||||||
|
isOrgMembershipActive: z.boolean()
|
||||||
|
}),
|
||||||
project: SanitizedProjectSchema.pick({ name: true, id: true }),
|
project: SanitizedProjectSchema.pick({ name: true, id: true }),
|
||||||
roles: z.array(
|
roles: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
|
|||||||
try {
|
try {
|
||||||
// @ts-expect-error this is because this is express type and not fastify
|
// @ts-expect-error this is because this is express type and not fastify
|
||||||
const callbackPort = req.session.get("callbackPort");
|
const callbackPort = req.session.get("callbackPort");
|
||||||
|
// @ts-expect-error this is because this is express type and not fastify
|
||||||
|
const orgSlug = req.session.get("orgSlug");
|
||||||
|
|
||||||
const email = profile?.emails?.[0]?.value;
|
const email = profile?.emails?.[0]?.value;
|
||||||
if (!email)
|
if (!email)
|
||||||
@@ -67,7 +69,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
|
|||||||
firstName: profile?.name?.givenName || "",
|
firstName: profile?.name?.givenName || "",
|
||||||
lastName: profile?.name?.familyName || "",
|
lastName: profile?.name?.familyName || "",
|
||||||
authMethod: AuthMethod.GOOGLE,
|
authMethod: AuthMethod.GOOGLE,
|
||||||
callbackPort
|
callbackPort,
|
||||||
|
orgSlug
|
||||||
});
|
});
|
||||||
cb(null, { isUserCompleted, providerAuthToken });
|
cb(null, { isUserCompleted, providerAuthToken });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -215,6 +218,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
|||||||
schema: {
|
schema: {
|
||||||
querystring: z.object({
|
querystring: z.object({
|
||||||
callback_port: z.string().optional(),
|
callback_port: z.string().optional(),
|
||||||
|
org_slug: z.string().optional(),
|
||||||
is_admin_login: z
|
is_admin_login: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -223,12 +227,15 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
|||||||
},
|
},
|
||||||
preValidation: [
|
preValidation: [
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const { callback_port: callbackPort, is_admin_login: isAdminLogin } = req.query;
|
const { callback_port: callbackPort, is_admin_login: isAdminLogin, org_slug: orgSlug } = req.query;
|
||||||
// ensure fresh session state per login attempt
|
// ensure fresh session state per login attempt
|
||||||
await req.session.regenerate();
|
await req.session.regenerate();
|
||||||
if (callbackPort) {
|
if (callbackPort) {
|
||||||
req.session.set("callbackPort", callbackPort);
|
req.session.set("callbackPort", callbackPort);
|
||||||
}
|
}
|
||||||
|
if (orgSlug) {
|
||||||
|
req.session.set("orgSlug", orgSlug);
|
||||||
|
}
|
||||||
if (isAdminLogin) {
|
if (isAdminLogin) {
|
||||||
req.session.set("isAdminLogin", isAdminLogin);
|
req.session.set("isAdminLogin", isAdminLogin);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,6 +283,14 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
rateLimit: readLimit
|
rateLimit: readLimit
|
||||||
},
|
},
|
||||||
schema: {
|
schema: {
|
||||||
|
hide: false,
|
||||||
|
tags: [ApiDocsTags.Projects],
|
||||||
|
description: "Get project details by slug",
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
bearerAuth: []
|
||||||
|
}
|
||||||
|
],
|
||||||
params: z.object({
|
params: z.object({
|
||||||
slug: slugSchema({ max: 36 }).describe("The slug of the project to get.")
|
slug: slugSchema({ max: 36 }).describe("The slug of the project to get.")
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { createAppAuth } from "@octokit/auth-app";
|
|
||||||
import { request } from "@octokit/request";
|
|
||||||
import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";
|
import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";
|
||||||
import https from "https";
|
import https from "https";
|
||||||
import RE2 from "re2";
|
import RE2 from "re2";
|
||||||
@@ -8,6 +6,7 @@ import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic
|
|||||||
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
|
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
|
||||||
import { getConfig } from "@app/lib/config/env";
|
import { getConfig } from "@app/lib/config/env";
|
||||||
import { request as httpRequest } from "@app/lib/config/request";
|
import { request as httpRequest } from "@app/lib/config/request";
|
||||||
|
import { crypto } from "@app/lib/crypto";
|
||||||
import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors";
|
import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors";
|
||||||
import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
|
import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
|
||||||
import { logger } from "@app/lib/logger";
|
import { logger } from "@app/lib/logger";
|
||||||
@@ -114,10 +113,13 @@ export const requestWithGitHubGateway = async <T>(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) => {
|
export const getGitHubAppAuthToken = async (
|
||||||
|
appConnection: TGitHubConnection,
|
||||||
|
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
|
||||||
|
) => {
|
||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
const appId = appCfg.INF_APP_CONNECTION_GITHUB_APP_ID;
|
const appId = appCfg.INF_APP_CONNECTION_GITHUB_APP_ID;
|
||||||
const appPrivateKey = appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY;
|
let appPrivateKey = appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY;
|
||||||
|
|
||||||
if (!appId || !appPrivateKey) {
|
if (!appId || !appPrivateKey) {
|
||||||
throw new InternalServerError({
|
throw new InternalServerError({
|
||||||
@@ -125,33 +127,65 @@ export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) =>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appPrivateKey = appPrivateKey
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
if (appConnection.method !== GitHubConnectionMethod.App) {
|
if (appConnection.method !== GitHubConnectionMethod.App) {
|
||||||
throw new InternalServerError({ message: "Cannot generate GitHub App token for non-app connection" });
|
throw new InternalServerError({ message: "Cannot generate GitHub App token for non-app connection" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const appAuth = createAppAuth({
|
const now = Math.floor(Date.now() / 1000);
|
||||||
appId,
|
const payload = {
|
||||||
privateKey: appPrivateKey,
|
iat: now,
|
||||||
installationId: appConnection.credentials.installationId,
|
exp: now + 5 * 60,
|
||||||
request: request.defaults({
|
iss: appId
|
||||||
baseUrl: `https://${await getGitHubInstanceApiUrl(appConnection)}`
|
};
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const { token } = await appAuth({ type: "installation" });
|
const appJwt = crypto.jwt().sign(payload, appPrivateKey, { algorithm: "RS256" });
|
||||||
return token;
|
|
||||||
|
const apiBaseUrl = await getGitHubInstanceApiUrl(appConnection);
|
||||||
|
const { installationId } = appConnection.credentials;
|
||||||
|
|
||||||
|
const response = await requestWithGitHubGateway<{ token: string; expires_at: string }>(
|
||||||
|
appConnection,
|
||||||
|
gatewayService,
|
||||||
|
{
|
||||||
|
url: `https://${apiBaseUrl}/app/installations/${installationId}/access_tokens`,
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
Authorization: `Bearer ${appJwt}`,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data.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[]>(
|
||||||
@@ -163,28 +197,86 @@ export const makePaginatedGitHubRequest = async <T, R = T[]>(
|
|||||||
const { credentials, method } = appConnection;
|
const { credentials, method } = appConnection;
|
||||||
|
|
||||||
const token =
|
const token =
|
||||||
method === GitHubConnectionMethod.OAuth ? credentials.accessToken : await getGitHubAppAuthToken(appConnection);
|
method === GitHubConnectionMethod.OAuth
|
||||||
let url: string | null = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`;
|
? credentials.accessToken
|
||||||
|
: await getGitHubAppAuthToken(appConnection, gatewayService);
|
||||||
|
|
||||||
|
const baseUrl = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`;
|
||||||
|
const initialUrlObj = new URL(baseUrl);
|
||||||
|
initialUrlObj.searchParams.set("per_page", "100");
|
||||||
|
|
||||||
let results: T[] = [];
|
let results: T[] = [];
|
||||||
let i = 0;
|
const maxIterations = 1000;
|
||||||
|
|
||||||
while (url && i < 1000) {
|
// Make initial request to get link header
|
||||||
// eslint-disable-next-line no-await-in-loop
|
const firstResponse: AxiosResponse<R> = await requestWithGitHubGateway<R>(appConnection, gatewayService, {
|
||||||
const response: AxiosResponse<R> = await requestWithGitHubGateway<R>(appConnection, gatewayService, {
|
url: initialUrlObj.toString(),
|
||||||
url,
|
method: "GET",
|
||||||
method: "GET",
|
headers: {
|
||||||
headers: {
|
Accept: "application/vnd.github+json",
|
||||||
Accept: "application/vnd.github+json",
|
Authorization: `Bearer ${token}`,
|
||||||
Authorization: `Bearer ${token}`,
|
"X-GitHub-Api-Version": "2022-11-28"
|
||||||
"X-GitHub-Api-Version": "2022-11-28"
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]);
|
const firstPageItems = dataMapper ? dataMapper(firstResponse.data) : (firstResponse.data as unknown as T[]);
|
||||||
results = results.concat(items);
|
results = results.concat(firstPageItems);
|
||||||
|
|
||||||
url = extractNextPageUrl(response.headers.link as string | undefined);
|
const linkHeader = parseGitHubLinkHeader(firstResponse.headers.link as string | undefined);
|
||||||
i += 1;
|
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
|
||||||
|
const response: AxiosResponse<R> = await requestWithGitHubGateway<R>(appConnection, gatewayService, {
|
||||||
|
url,
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = dataMapper ? dataMapper(response.data) : (response.data as unknown as T[]);
|
||||||
|
results = results.concat(items);
|
||||||
|
|
||||||
|
url = extractNextPageUrl(response.headers.link as string | undefined);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import { IntegrationUrls } from "@app/services/integration-auth/integration-list
|
|||||||
import { AppConnection } from "../app-connection-enums";
|
import { AppConnection } from "../app-connection-enums";
|
||||||
import { RenderConnectionMethod } from "./render-connection-enums";
|
import { RenderConnectionMethod } from "./render-connection-enums";
|
||||||
import {
|
import {
|
||||||
|
TRawRenderEnvironmentGroup,
|
||||||
TRawRenderService,
|
TRawRenderService,
|
||||||
TRenderConnection,
|
TRenderConnection,
|
||||||
TRenderConnectionConfig,
|
TRenderConnectionConfig,
|
||||||
|
TRenderEnvironmentGroup,
|
||||||
TRenderService
|
TRenderService
|
||||||
} from "./render-connection-types";
|
} from "./render-connection-types";
|
||||||
|
|
||||||
@@ -32,7 +34,11 @@ export const listRenderServices = async (appConnection: TRenderConnection): Prom
|
|||||||
const perPage = 100;
|
const perPage = 100;
|
||||||
let cursor;
|
let cursor;
|
||||||
|
|
||||||
|
let maxIterations = 10;
|
||||||
|
|
||||||
while (hasMorePages) {
|
while (hasMorePages) {
|
||||||
|
if (maxIterations <= 0) break;
|
||||||
|
|
||||||
const res: TRawRenderService[] = (
|
const res: TRawRenderService[] = (
|
||||||
await request.get<TRawRenderService[]>(`${IntegrationUrls.RENDER_API_URL}/v1/services`, {
|
await request.get<TRawRenderService[]>(`${IntegrationUrls.RENDER_API_URL}/v1/services`, {
|
||||||
params: new URLSearchParams({
|
params: new URLSearchParams({
|
||||||
@@ -59,6 +65,8 @@ export const listRenderServices = async (appConnection: TRenderConnection): Prom
|
|||||||
} else {
|
} else {
|
||||||
cursor = res[res.length - 1].cursor;
|
cursor = res[res.length - 1].cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maxIterations -= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
@@ -86,3 +94,52 @@ export const validateRenderConnectionCredentials = async (config: TRenderConnect
|
|||||||
|
|
||||||
return inputCredentials;
|
return inputCredentials;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const listRenderEnvironmentGroups = async (
|
||||||
|
appConnection: TRenderConnection
|
||||||
|
): Promise<TRenderEnvironmentGroup[]> => {
|
||||||
|
const {
|
||||||
|
credentials: { apiKey }
|
||||||
|
} = appConnection;
|
||||||
|
|
||||||
|
const groups: TRenderEnvironmentGroup[] = [];
|
||||||
|
let hasMorePages = true;
|
||||||
|
const perPage = 100;
|
||||||
|
let cursor;
|
||||||
|
let maxIterations = 10;
|
||||||
|
|
||||||
|
while (hasMorePages) {
|
||||||
|
if (maxIterations <= 0) break;
|
||||||
|
|
||||||
|
const res: TRawRenderEnvironmentGroup[] = (
|
||||||
|
await request.get<TRawRenderEnvironmentGroup[]>(`${IntegrationUrls.RENDER_API_URL}/v1/env-groups`, {
|
||||||
|
params: new URLSearchParams({
|
||||||
|
...(cursor ? { cursor: String(cursor) } : {}),
|
||||||
|
limit: String(perPage)
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
Accept: "application/json",
|
||||||
|
"Accept-Encoding": "application/json"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).data;
|
||||||
|
|
||||||
|
res.forEach((item) => {
|
||||||
|
groups.push({
|
||||||
|
name: item.envGroup.name,
|
||||||
|
id: item.envGroup.id
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.length < perPage) {
|
||||||
|
hasMorePages = false;
|
||||||
|
} else {
|
||||||
|
cursor = res[res.length - 1].cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
maxIterations -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { logger } from "@app/lib/logger";
|
|||||||
import { OrgServiceActor } from "@app/lib/types";
|
import { OrgServiceActor } from "@app/lib/types";
|
||||||
|
|
||||||
import { AppConnection } from "../app-connection-enums";
|
import { AppConnection } from "../app-connection-enums";
|
||||||
import { listRenderServices } from "./render-connection-fns";
|
import { listRenderEnvironmentGroups, listRenderServices } from "./render-connection-fns";
|
||||||
import { TRenderConnection } from "./render-connection-types";
|
import { TRenderConnection } from "./render-connection-types";
|
||||||
|
|
||||||
type TGetAppConnectionFunc = (
|
type TGetAppConnectionFunc = (
|
||||||
@@ -24,7 +24,20 @@ export const renderConnectionService = (getAppConnection: TGetAppConnectionFunc)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const listEnvironmentGroups = async (connectionId: string, actor: OrgServiceActor) => {
|
||||||
|
const appConnection = await getAppConnection(AppConnection.Render, connectionId, actor);
|
||||||
|
try {
|
||||||
|
const groups = await listRenderEnvironmentGroups(appConnection);
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, "Failed to list environment groups for Render connection");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
listServices
|
listServices,
|
||||||
|
listEnvironmentGroups
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,3 +33,16 @@ export type TRawRenderService = {
|
|||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TRenderEnvironmentGroup = {
|
||||||
|
name: string;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TRawRenderEnvironmentGroup = {
|
||||||
|
cursor: string;
|
||||||
|
envGroup: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -448,7 +448,9 @@ export const authLoginServiceFactory = ({
|
|||||||
|
|
||||||
// Check if the user actually has access to the specified organization.
|
// Check if the user actually has access to the specified organization.
|
||||||
const userOrgs = await orgDAL.findAllOrgsByUserId(user.id);
|
const userOrgs = await orgDAL.findAllOrgsByUserId(user.id);
|
||||||
const hasOrganizationMembership = userOrgs.some((org) => org.id === organizationId && org.userStatus !== "invited");
|
|
||||||
|
const selectedOrgMembership = userOrgs.find((org) => org.id === organizationId && org.userStatus !== "invited");
|
||||||
|
|
||||||
const selectedOrg = await orgDAL.findById(organizationId);
|
const selectedOrg = await orgDAL.findById(organizationId);
|
||||||
|
|
||||||
// Check if authEnforced is true, if that's the case, throw an error
|
// Check if authEnforced is true, if that's the case, throw an error
|
||||||
@@ -458,12 +460,29 @@ export const authLoginServiceFactory = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasOrganizationMembership) {
|
if (!selectedOrgMembership) {
|
||||||
throw new ForbiddenRequestError({
|
throw new ForbiddenRequestError({
|
||||||
message: `User does not have access to the organization named ${selectedOrg?.name}`
|
message: `User does not have access to the organization named ${selectedOrg?.name}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) {
|
||||||
|
const canBypass = selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin;
|
||||||
|
|
||||||
|
if (!canBypass) {
|
||||||
|
throw new ForbiddenRequestError({
|
||||||
|
message: "Google SSO is enforced for this organization. Please use Google SSO to login.",
|
||||||
|
error: "GoogleSsoEnforced"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decodedToken.authMethod === AuthMethod.GOOGLE) {
|
||||||
|
await orgDAL.updateById(selectedOrg.id, {
|
||||||
|
googleSsoAuthLastUsed: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled;
|
const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled;
|
||||||
const orgMfaMethod = selectedOrg.enforceMfa ? (selectedOrg.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined;
|
const orgMfaMethod = selectedOrg.enforceMfa ? (selectedOrg.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined;
|
||||||
const userMfaMethod = user.isMfaEnabled ? (user.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined;
|
const userMfaMethod = user.isMfaEnabled ? (user.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined;
|
||||||
@@ -509,7 +528,8 @@ export const authLoginServiceFactory = ({
|
|||||||
selectedOrg.authEnforced &&
|
selectedOrg.authEnforced &&
|
||||||
selectedOrg.bypassOrgAuthEnabled &&
|
selectedOrg.bypassOrgAuthEnabled &&
|
||||||
!isAuthMethodSaml(decodedToken.authMethod) &&
|
!isAuthMethodSaml(decodedToken.authMethod) &&
|
||||||
decodedToken.authMethod !== AuthMethod.OIDC
|
decodedToken.authMethod !== AuthMethod.OIDC &&
|
||||||
|
decodedToken.authMethod !== AuthMethod.GOOGLE
|
||||||
) {
|
) {
|
||||||
await auditLogService.createAuditLog({
|
await auditLogService.createAuditLog({
|
||||||
orgId: organizationId,
|
orgId: organizationId,
|
||||||
@@ -712,7 +732,7 @@ export const authLoginServiceFactory = ({
|
|||||||
/*
|
/*
|
||||||
* OAuth2 login for google,github, and other oauth2 provider
|
* OAuth2 login for google,github, and other oauth2 provider
|
||||||
* */
|
* */
|
||||||
const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => {
|
const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort, orgSlug }: TOauthLoginDTO) => {
|
||||||
// akhilmhdh: case sensitive email resolution
|
// akhilmhdh: case sensitive email resolution
|
||||||
const usersByUsername = await userDAL.findUserByUsername(email);
|
const usersByUsername = await userDAL.findUserByUsername(email);
|
||||||
let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
|
let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
|
||||||
@@ -766,6 +786,8 @@ export const authLoginServiceFactory = ({
|
|||||||
|
|
||||||
const appCfg = getConfig();
|
const appCfg = getConfig();
|
||||||
|
|
||||||
|
let orgId = "";
|
||||||
|
let orgName: undefined | string;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// Create a new user based on oAuth
|
// Create a new user based on oAuth
|
||||||
if (!serverCfg?.allowSignUp) throw new BadRequestError({ message: "Sign up disabled", name: "Oauth 2 login" });
|
if (!serverCfg?.allowSignUp) throw new BadRequestError({ message: "Sign up disabled", name: "Oauth 2 login" });
|
||||||
@@ -791,7 +813,6 @@ export const authLoginServiceFactory = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (authMethod === AuthMethod.GITHUB && serverCfg.defaultAuthOrgId && !appCfg.isCloud) {
|
if (authMethod === AuthMethod.GITHUB && serverCfg.defaultAuthOrgId && !appCfg.isCloud) {
|
||||||
let orgId = "";
|
|
||||||
const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId);
|
const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId);
|
||||||
if (!defaultOrg) {
|
if (!defaultOrg) {
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
@@ -831,11 +852,39 @@ export const authLoginServiceFactory = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!orgId && orgSlug) {
|
||||||
|
const org = await orgDAL.findOrgBySlug(orgSlug);
|
||||||
|
|
||||||
|
if (org) {
|
||||||
|
// checks for the membership and only sets the orgId / orgName if the user is a member of the specified org
|
||||||
|
const orgMembership = await orgDAL.findMembership({
|
||||||
|
[`${TableName.OrgMembership}.userId` as "userId"]: user.id,
|
||||||
|
[`${TableName.OrgMembership}.orgId` as "orgId"]: org.id,
|
||||||
|
[`${TableName.OrgMembership}.isActive` as "isActive"]: true,
|
||||||
|
[`${TableName.OrgMembership}.status` as "status"]: OrgMembershipStatus.Accepted
|
||||||
|
});
|
||||||
|
|
||||||
|
if (orgMembership) {
|
||||||
|
orgId = org.id;
|
||||||
|
orgName = org.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isUserCompleted = user.isAccepted;
|
const isUserCompleted = user.isAccepted;
|
||||||
const providerAuthToken = crypto.jwt().sign(
|
const providerAuthToken = crypto.jwt().sign(
|
||||||
{
|
{
|
||||||
authTokenType: AuthTokenType.PROVIDER_TOKEN,
|
authTokenType: AuthTokenType.PROVIDER_TOKEN,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|
||||||
|
...(orgId && orgSlug && orgName !== undefined
|
||||||
|
? {
|
||||||
|
organizationId: orgId,
|
||||||
|
organizationName: orgName,
|
||||||
|
organizationSlug: orgSlug
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
|
||||||
username: user.username,
|
username: user.username,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
isEmailVerified: user.isEmailVerified,
|
isEmailVerified: user.isEmailVerified,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export type TOauthLoginDTO = {
|
|||||||
lastName?: string;
|
lastName?: string;
|
||||||
authMethod: AuthMethod;
|
authMethod: AuthMethod;
|
||||||
callbackPort?: string;
|
callbackPort?: string;
|
||||||
|
orgSlug?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TOauthTokenExchangeDTO = {
|
export type TOauthTokenExchangeDTO = {
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export const groupProjectDALFactory = (db: TDbClient) => {
|
|||||||
`${TableName.GroupProjectMembershipRole}.customRoleId`,
|
`${TableName.GroupProjectMembershipRole}.customRoleId`,
|
||||||
`${TableName.ProjectRoles}.id`
|
`${TableName.ProjectRoles}.id`
|
||||||
)
|
)
|
||||||
|
.join(TableName.OrgMembership, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`)
|
||||||
.select(
|
.select(
|
||||||
db.ref("id").withSchema(TableName.UserGroupMembership),
|
db.ref("id").withSchema(TableName.UserGroupMembership),
|
||||||
db.ref("createdAt").withSchema(TableName.UserGroupMembership),
|
db.ref("createdAt").withSchema(TableName.UserGroupMembership),
|
||||||
@@ -176,7 +177,8 @@ export const groupProjectDALFactory = (db: TDbClient) => {
|
|||||||
db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole),
|
db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole),
|
||||||
db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole),
|
db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole),
|
||||||
db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole),
|
db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole),
|
||||||
db.ref("name").as("projectName").withSchema(TableName.Project)
|
db.ref("name").as("projectName").withSchema(TableName.Project),
|
||||||
|
db.ref("isActive").withSchema(TableName.OrgMembership)
|
||||||
)
|
)
|
||||||
.where({ isGhost: false });
|
.where({ isGhost: false });
|
||||||
|
|
||||||
@@ -192,7 +194,8 @@ export const groupProjectDALFactory = (db: TDbClient) => {
|
|||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
projectName,
|
projectName,
|
||||||
createdAt
|
createdAt,
|
||||||
|
isActive
|
||||||
}) => ({
|
}) => ({
|
||||||
isGroupMember: true,
|
isGroupMember: true,
|
||||||
id,
|
id,
|
||||||
@@ -202,7 +205,7 @@ export const groupProjectDALFactory = (db: TDbClient) => {
|
|||||||
id: projectId,
|
id: projectId,
|
||||||
name: projectName
|
name: projectName
|
||||||
},
|
},
|
||||||
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost },
|
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost, isOrgMembershipActive: isActive },
|
||||||
createdAt
|
createdAt
|
||||||
}),
|
}),
|
||||||
key: "id",
|
key: "id",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
UnauthorizedError
|
UnauthorizedError
|
||||||
} from "@app/lib/errors";
|
} from "@app/lib/errors";
|
||||||
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
|
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
|
||||||
import { getStringValueByDot } from "@app/lib/template/dot-access";
|
import { getValueByDot } from "@app/lib/template/dot-access";
|
||||||
|
|
||||||
import { ActorType, AuthTokenType } from "../auth/auth-type";
|
import { ActorType, AuthTokenType } from "../auth/auth-type";
|
||||||
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
|
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
|
||||||
@@ -189,7 +189,7 @@ export const identityJwtAuthServiceFactory = ({
|
|||||||
if (identityJwtAuth.boundClaims) {
|
if (identityJwtAuth.boundClaims) {
|
||||||
Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => {
|
Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => {
|
||||||
const claimValue = (identityJwtAuth.boundClaims as Record<string, string>)[claimKey];
|
const claimValue = (identityJwtAuth.boundClaims as Record<string, string>)[claimKey];
|
||||||
const value = getStringValueByDot(tokenData, claimKey) || "";
|
const value = getValueByDot(tokenData, claimKey);
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new UnauthorizedError({
|
throw new UnauthorizedError({
|
||||||
@@ -198,9 +198,7 @@ export const identityJwtAuthServiceFactory = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handle both single and multi-valued claims
|
// handle both single and multi-valued claims
|
||||||
if (
|
if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(value, claimEntry))) {
|
||||||
!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(tokenData[claimKey], claimEntry))
|
|
||||||
) {
|
|
||||||
throw new UnauthorizedError({
|
throw new UnauthorizedError({
|
||||||
message: `Access denied: claim mismatch for field ${claimKey}`
|
message: `Access denied: claim mismatch for field ${claimKey}`
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import picomatch from "picomatch";
|
import picomatch from "picomatch";
|
||||||
|
|
||||||
export const doesFieldValueMatchOidcPolicy = (fieldValue: string, policyValue: string) =>
|
export const doesFieldValueMatchOidcPolicy = (fieldValue: string | number | boolean, policyValue: string) => {
|
||||||
policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue);
|
if (typeof fieldValue === "boolean") {
|
||||||
|
return fieldValue === (policyValue === "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof fieldValue === "number") {
|
||||||
|
return fieldValue === parseInt(policyValue, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue);
|
||||||
|
};
|
||||||
|
|
||||||
export const doesAudValueMatchOidcPolicy = (fieldValue: string | string[], policyValue: string) => {
|
export const doesAudValueMatchOidcPolicy = (fieldValue: string | string[], policyValue: string) => {
|
||||||
if (Array.isArray(fieldValue)) {
|
if (Array.isArray(fieldValue)) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
UnauthorizedError
|
UnauthorizedError
|
||||||
} from "@app/lib/errors";
|
} from "@app/lib/errors";
|
||||||
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
|
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
|
||||||
import { getStringValueByDot } from "@app/lib/template/dot-access";
|
import { getValueByDot } from "@app/lib/template/dot-access";
|
||||||
|
|
||||||
import { ActorType, AuthTokenType } from "../auth/auth-type";
|
import { ActorType, AuthTokenType } from "../auth/auth-type";
|
||||||
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
|
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
|
||||||
@@ -146,7 +146,7 @@ export const identityOidcAuthServiceFactory = ({
|
|||||||
if (identityOidcAuth.boundClaims) {
|
if (identityOidcAuth.boundClaims) {
|
||||||
Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => {
|
Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => {
|
||||||
const claimValue = (identityOidcAuth.boundClaims as Record<string, string>)[claimKey];
|
const claimValue = (identityOidcAuth.boundClaims as Record<string, string>)[claimKey];
|
||||||
const value = getStringValueByDot(tokenData, claimKey) || "";
|
const value = getValueByDot(tokenData, claimKey);
|
||||||
|
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new UnauthorizedError({
|
throw new UnauthorizedError({
|
||||||
@@ -167,13 +167,13 @@ export const identityOidcAuthServiceFactory = ({
|
|||||||
if (identityOidcAuth.claimMetadataMapping) {
|
if (identityOidcAuth.claimMetadataMapping) {
|
||||||
Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => {
|
Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => {
|
||||||
const claimKey = (identityOidcAuth.claimMetadataMapping as Record<string, string>)[permissionKey];
|
const claimKey = (identityOidcAuth.claimMetadataMapping as Record<string, string>)[permissionKey];
|
||||||
const value = getStringValueByDot(tokenData, claimKey) || "";
|
const value = getValueByDot(tokenData, claimKey);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
throw new UnauthorizedError({
|
throw new UnauthorizedError({
|
||||||
message: `Access denied: token has no ${claimKey} field`
|
message: `Access denied: token has no ${claimKey} field`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
filteredClaims[permissionKey] = value;
|
filteredClaims[permissionKey] = value.toString();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
authEnforced: true,
|
authEnforced: true,
|
||||||
|
googleSsoAuthEnforced: true,
|
||||||
scimEnabled: true,
|
scimEnabled: true,
|
||||||
kmsDefaultKeyId: true,
|
kmsDefaultKeyId: true,
|
||||||
defaultMembershipRole: true,
|
defaultMembershipRole: true,
|
||||||
|
|||||||
@@ -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
|
||||||
* */
|
* */
|
||||||
@@ -355,6 +364,7 @@ export const orgServiceFactory = ({
|
|||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
authEnforced,
|
authEnforced,
|
||||||
|
googleSsoAuthEnforced,
|
||||||
scimEnabled,
|
scimEnabled,
|
||||||
defaultMembershipRoleSlug,
|
defaultMembershipRoleSlug,
|
||||||
enforceMfa,
|
enforceMfa,
|
||||||
@@ -421,6 +431,21 @@ export const orgServiceFactory = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (googleSsoAuthEnforced !== undefined) {
|
||||||
|
if (!plan.enforceGoogleSSO) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Failed to enforce Google SSO due to plan restriction. Upgrade plan to enforce Google SSO."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authEnforced && googleSsoAuthEnforced) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "SAML/OIDC auth enforcement and Google SSO auth enforcement cannot be enabled at the same time."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (authEnforced) {
|
if (authEnforced) {
|
||||||
const samlCfg = await samlConfigDAL.findOne({
|
const samlCfg = await samlConfigDAL.findOne({
|
||||||
orgId,
|
orgId,
|
||||||
@@ -451,6 +476,21 @@ export const orgServiceFactory = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (googleSsoAuthEnforced) {
|
||||||
|
if (googleSsoAuthEnforced && currentOrg.authEnforced) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Google SSO auth enforcement cannot be enabled when SAML/OIDC auth enforcement is enabled."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentOrg.googleSsoAuthLastUsed) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message:
|
||||||
|
"Google SSO auth enforcement cannot be enabled because Google SSO has not been used yet. Please log in via Google SSO at least once before enforcing it for your organization."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let defaultMembershipRole: string | undefined;
|
let defaultMembershipRole: string | undefined;
|
||||||
if (defaultMembershipRoleSlug) {
|
if (defaultMembershipRoleSlug) {
|
||||||
defaultMembershipRole = await getDefaultOrgMembershipRoleForUpdateOrg({
|
defaultMembershipRole = await getDefaultOrgMembershipRoleForUpdateOrg({
|
||||||
@@ -465,6 +505,7 @@ export const orgServiceFactory = ({
|
|||||||
name,
|
name,
|
||||||
slug: slug ? slugify(slug) : undefined,
|
slug: slug ? slugify(slug) : undefined,
|
||||||
authEnforced,
|
authEnforced,
|
||||||
|
googleSsoAuthEnforced,
|
||||||
scimEnabled,
|
scimEnabled,
|
||||||
defaultMembershipRole,
|
defaultMembershipRole,
|
||||||
enforceMfa,
|
enforceMfa,
|
||||||
@@ -1403,6 +1444,7 @@ export const orgServiceFactory = ({
|
|||||||
findOrganizationById,
|
findOrganizationById,
|
||||||
findAllOrgMembers,
|
findAllOrgMembers,
|
||||||
findAllOrganizationOfUser,
|
findAllOrganizationOfUser,
|
||||||
|
findIdentityOrganization,
|
||||||
inviteUserToOrganization,
|
inviteUserToOrganization,
|
||||||
verifyUserToOrg,
|
verifyUserToOrg,
|
||||||
updateOrg,
|
updateOrg,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export type TUpdateOrgDTO = {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
authEnforced: boolean;
|
authEnforced: boolean;
|
||||||
|
googleSsoAuthEnforced: boolean;
|
||||||
scimEnabled: boolean;
|
scimEnabled: boolean;
|
||||||
defaultMembershipRoleSlug: string;
|
defaultMembershipRoleSlug: string;
|
||||||
enforceMfa: boolean;
|
enforceMfa: boolean;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
|
|||||||
.where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId })
|
.where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId })
|
||||||
.join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`)
|
.join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`)
|
||||||
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
|
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
|
||||||
|
.join(TableName.OrgMembership, (qb) => {
|
||||||
|
qb.on(`${TableName.Users}.id`, "=", `${TableName.OrgMembership}.userId`).andOn(
|
||||||
|
`${TableName.OrgMembership}.orgId`,
|
||||||
|
"=",
|
||||||
|
`${TableName.Project}.orgId`
|
||||||
|
);
|
||||||
|
})
|
||||||
|
|
||||||
.where((qb) => {
|
.where((qb) => {
|
||||||
if (filter.usernames) {
|
if (filter.usernames) {
|
||||||
void qb.whereIn("username", filter.usernames);
|
void qb.whereIn("username", filter.usernames);
|
||||||
@@ -90,7 +98,8 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
|
|||||||
db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole),
|
db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole),
|
||||||
db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole),
|
db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole),
|
||||||
db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole),
|
db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole),
|
||||||
db.ref("name").as("projectName").withSchema(TableName.Project)
|
db.ref("name").as("projectName").withSchema(TableName.Project),
|
||||||
|
db.ref("isActive").withSchema(TableName.OrgMembership)
|
||||||
)
|
)
|
||||||
.where({ isGhost: false })
|
.where({ isGhost: false })
|
||||||
.orderBy(`${TableName.Users}.username` as "username");
|
.orderBy(`${TableName.Users}.username` as "username");
|
||||||
@@ -107,12 +116,22 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
|
|||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
projectName,
|
projectName,
|
||||||
createdAt
|
createdAt,
|
||||||
|
isActive
|
||||||
}) => ({
|
}) => ({
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
projectId,
|
projectId,
|
||||||
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost },
|
user: {
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
id: userId,
|
||||||
|
publicKey,
|
||||||
|
isGhost,
|
||||||
|
isOrgMembershipActive: isActive
|
||||||
|
},
|
||||||
project: {
|
project: {
|
||||||
id: projectId,
|
id: projectId,
|
||||||
name: projectName
|
name: projectName
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ export const projectMembershipServiceFactory = ({
|
|||||||
|
|
||||||
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId, { roles });
|
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId, { roles });
|
||||||
|
|
||||||
// projectMembers[0].project
|
|
||||||
if (includeGroupMembers) {
|
if (includeGroupMembers) {
|
||||||
const groupMembers = await groupProjectDAL.findAllProjectGroupMembers(projectId);
|
const groupMembers = await groupProjectDAL.findAllProjectGroupMembers(projectId);
|
||||||
const allMembers = [
|
const allMembers = [
|
||||||
|
|||||||
@@ -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,32 +42,19 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
|
|||||||
serviceTokenService,
|
serviceTokenService,
|
||||||
orgService
|
orgService
|
||||||
}: TDailyResourceCleanUpQueueServiceFactoryDep) => {
|
}: TDailyResourceCleanUpQueueServiceFactoryDep) => {
|
||||||
queueService.start(QueueName.DailyResourceCleanUp, async () => {
|
const appCfg = getConfig();
|
||||||
logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`);
|
|
||||||
await identityAccessTokenDAL.removeExpiredTokens();
|
|
||||||
await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets();
|
|
||||||
await secretSharingDAL.pruneExpiredSharedSecrets();
|
|
||||||
await secretSharingDAL.pruneExpiredSecretRequests();
|
|
||||||
await snapshotDAL.pruneExcessSnapshots();
|
|
||||||
await secretVersionDAL.pruneExcessVersions();
|
|
||||||
await secretVersionV2DAL.pruneExcessVersions();
|
|
||||||
await secretFolderVersionDAL.pruneExcessVersions();
|
|
||||||
await serviceTokenService.notifyExpiringTokens();
|
|
||||||
await orgService.notifyInvitedUsers();
|
|
||||||
await auditLogDAL.pruneAuditLog();
|
|
||||||
logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// we do a repeat cron job in utc timezone at 12 Midnight each day
|
if (appCfg.isDailyResourceCleanUpDevelopmentMode) {
|
||||||
const startCleanUp = async () => {
|
logger.warn("Daily Resource Clean Up is in development mode.");
|
||||||
// TODO(akhilmhdh): remove later
|
}
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
await queueService.stopRepeatableJob(
|
await queueService.stopRepeatableJob(
|
||||||
QueueName.AuditLogPrune,
|
QueueName.AuditLogPrune,
|
||||||
QueueJobs.AuditLogPrune,
|
QueueJobs.AuditLogPrune,
|
||||||
{ pattern: "0 0 * * *", utc: true },
|
{ pattern: "0 0 * * *", utc: true },
|
||||||
QueueName.AuditLogPrune // just a job id
|
QueueName.AuditLogPrune // just a job id
|
||||||
);
|
);
|
||||||
// clear previous job
|
|
||||||
await queueService.stopRepeatableJob(
|
await queueService.stopRepeatableJob(
|
||||||
QueueName.DailyResourceCleanUp,
|
QueueName.DailyResourceCleanUp,
|
||||||
QueueJobs.DailyResourceCleanUp,
|
QueueJobs.DailyResourceCleanUp,
|
||||||
@@ -74,18 +62,43 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
|
|||||||
QueueName.DailyResourceCleanUp // just a job id
|
QueueName.DailyResourceCleanUp // just a job id
|
||||||
);
|
);
|
||||||
|
|
||||||
await queueService.queue(QueueName.DailyResourceCleanUp, QueueJobs.DailyResourceCleanUp, undefined, {
|
await queueService.startPg<QueueName.DailyResourceCleanUp>(
|
||||||
delay: 5000,
|
QueueJobs.DailyResourceCleanUp,
|
||||||
jobId: QueueName.DailyResourceCleanUp,
|
async () => {
|
||||||
repeat: { pattern: "0 0 * * *", utc: true }
|
try {
|
||||||
});
|
logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`);
|
||||||
|
await identityAccessTokenDAL.removeExpiredTokens();
|
||||||
|
await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets();
|
||||||
|
await secretSharingDAL.pruneExpiredSharedSecrets();
|
||||||
|
await secretSharingDAL.pruneExpiredSecretRequests();
|
||||||
|
await snapshotDAL.pruneExcessSnapshots();
|
||||||
|
await secretVersionDAL.pruneExcessVersions();
|
||||||
|
await secretVersionV2DAL.pruneExcessVersions();
|
||||||
|
await secretFolderVersionDAL.pruneExcessVersions();
|
||||||
|
await serviceTokenService.notifyExpiringTokens();
|
||||||
|
await orgService.notifyInvitedUsers();
|
||||||
|
await auditLogDAL.pruneAuditLog();
|
||||||
|
logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, `${QueueName.DailyResourceCleanUp}: resource cleanup failed`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
batchSize: 1,
|
||||||
|
workerCount: 1,
|
||||||
|
pollingIntervalSeconds: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await queueService.schedulePg(
|
||||||
|
QueueJobs.DailyResourceCleanUp,
|
||||||
|
appCfg.isDailyResourceCleanUpDevelopmentMode ? "*/5 * * * *" : "0 0 * * *",
|
||||||
|
undefined,
|
||||||
|
{ tz: "UTC" }
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
+73
-12
@@ -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[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export const GithubSyncFns = {
|
|||||||
const token =
|
const token =
|
||||||
connection.method === GitHubConnectionMethod.OAuth
|
connection.method === GitHubConnectionMethod.OAuth
|
||||||
? connection.credentials.accessToken
|
? connection.credentials.accessToken
|
||||||
: await getGitHubAppAuthToken(connection);
|
: await getGitHubAppAuthToken(connection, gatewayService);
|
||||||
|
|
||||||
const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService);
|
const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService);
|
||||||
const publicKey = await getPublicKey(secretSync, gatewayService, token);
|
const publicKey = await getPublicKey(secretSync, gatewayService, token);
|
||||||
@@ -264,7 +264,7 @@ export const GithubSyncFns = {
|
|||||||
const token =
|
const token =
|
||||||
connection.method === GitHubConnectionMethod.OAuth
|
connection.method === GitHubConnectionMethod.OAuth
|
||||||
? connection.credentials.accessToken
|
? connection.credentials.accessToken
|
||||||
: await getGitHubAppAuthToken(connection);
|
: await getGitHubAppAuthToken(connection, gatewayService);
|
||||||
|
|
||||||
const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService);
|
const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export enum RenderSyncScope {
|
export enum RenderSyncScope {
|
||||||
Service = "service"
|
Service = "service",
|
||||||
|
EnvironmentGroup = "environment-group"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum RenderSyncType {
|
export enum RenderSyncType {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
/* eslint-disable no-await-in-loop */
|
/* eslint-disable no-await-in-loop */
|
||||||
import { isAxiosError } from "axios";
|
import { AxiosRequestConfig, isAxiosError } from "axios";
|
||||||
|
|
||||||
import { request } from "@app/lib/config/request";
|
import { request } from "@app/lib/config/request";
|
||||||
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
|
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
|
||||||
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
|
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
|
||||||
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
|
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
|
||||||
|
|
||||||
|
import { RenderSyncScope } from "./render-sync-enums";
|
||||||
import { TRenderSecret, TRenderSyncWithCredentials } from "./render-sync-types";
|
import { TRenderSecret, TRenderSyncWithCredentials } from "./render-sync-types";
|
||||||
|
|
||||||
const MAX_RETRIES = 5;
|
const MAX_RETRIES = 5;
|
||||||
@@ -27,6 +29,80 @@ const makeRequestWithRetry = async <T>(requestFn: () => Promise<T>, attempt = 0)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function getSecrets(input: { destination: TRenderSyncWithCredentials["destinationConfig"]; token: string }) {
|
||||||
|
const req: AxiosRequestConfig = {
|
||||||
|
baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`,
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${input.token}`,
|
||||||
|
Accept: "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (input.destination.scope) {
|
||||||
|
case RenderSyncScope.Service: {
|
||||||
|
req.url = `/services/${input.destination.serviceId}/env-vars`;
|
||||||
|
|
||||||
|
const allSecrets: TRenderSecret[] = [];
|
||||||
|
let cursor: string | undefined;
|
||||||
|
|
||||||
|
do {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
||||||
|
const { data } = await makeRequestWithRetry(() =>
|
||||||
|
request.request<
|
||||||
|
{
|
||||||
|
envVar: {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
cursor: string;
|
||||||
|
}[]
|
||||||
|
>({
|
||||||
|
...req,
|
||||||
|
params: {
|
||||||
|
cursor
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const secrets = data.map((item) => ({
|
||||||
|
key: item.envVar.key,
|
||||||
|
value: item.envVar.value
|
||||||
|
}));
|
||||||
|
|
||||||
|
allSecrets.push(...secrets);
|
||||||
|
|
||||||
|
if (data.length > 0 && data[data.length - 1]?.cursor) {
|
||||||
|
cursor = data[data.length - 1].cursor;
|
||||||
|
} else {
|
||||||
|
cursor = undefined;
|
||||||
|
}
|
||||||
|
} while (cursor);
|
||||||
|
|
||||||
|
return allSecrets;
|
||||||
|
}
|
||||||
|
case RenderSyncScope.EnvironmentGroup: {
|
||||||
|
req.url = `/env-groups/${input.destination.environmentGroupId}`;
|
||||||
|
|
||||||
|
const res = await makeRequestWithRetry(() =>
|
||||||
|
request.request<{
|
||||||
|
envVars: {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}[];
|
||||||
|
}>(req)
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.data.envVars.map((item) => ({
|
||||||
|
key: item.key,
|
||||||
|
value: item.value
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new BadRequestError({ message: "Unknown render sync destination scope" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredentials): Promise<TRenderSecret[]> => {
|
const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredentials): Promise<TRenderSecret[]> => {
|
||||||
const {
|
const {
|
||||||
destinationConfig,
|
destinationConfig,
|
||||||
@@ -35,45 +111,12 @@ const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredential
|
|||||||
}
|
}
|
||||||
} = secretSync;
|
} = secretSync;
|
||||||
|
|
||||||
const baseUrl = `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`;
|
const secrets = await getSecrets({
|
||||||
const allSecrets: TRenderSecret[] = [];
|
destination: destinationConfig,
|
||||||
let cursor: string | undefined;
|
token: apiKey
|
||||||
|
});
|
||||||
|
|
||||||
do {
|
return secrets;
|
||||||
const url = cursor ? `${baseUrl}?cursor=${cursor}` : baseUrl;
|
|
||||||
|
|
||||||
const { data } = await makeRequestWithRetry(() =>
|
|
||||||
request.get<
|
|
||||||
{
|
|
||||||
envVar: {
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
cursor: string;
|
|
||||||
}[]
|
|
||||||
>(url, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
Accept: "application/json"
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const secrets = data.map((item) => ({
|
|
||||||
key: item.envVar.key,
|
|
||||||
value: item.envVar.value
|
|
||||||
}));
|
|
||||||
|
|
||||||
allSecrets.push(...secrets);
|
|
||||||
|
|
||||||
if (data.length > 0 && data[data.length - 1]?.cursor) {
|
|
||||||
cursor = data[data.length - 1].cursor;
|
|
||||||
} else {
|
|
||||||
cursor = undefined;
|
|
||||||
}
|
|
||||||
} while (cursor);
|
|
||||||
|
|
||||||
return allSecrets;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const batchUpdateEnvironmentSecrets = async (
|
const batchUpdateEnvironmentSecrets = async (
|
||||||
@@ -87,14 +130,91 @@ const batchUpdateEnvironmentSecrets = async (
|
|||||||
}
|
}
|
||||||
} = secretSync;
|
} = secretSync;
|
||||||
|
|
||||||
await makeRequestWithRetry(() =>
|
const req: AxiosRequestConfig = {
|
||||||
request.put(`${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`, envVars, {
|
baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`,
|
||||||
headers: {
|
method: "PUT",
|
||||||
Authorization: `Bearer ${apiKey}`,
|
headers: {
|
||||||
Accept: "application/json"
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
Accept: "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (destinationConfig.scope) {
|
||||||
|
case RenderSyncScope.Service: {
|
||||||
|
await makeRequestWithRetry(() =>
|
||||||
|
request.request({
|
||||||
|
...req,
|
||||||
|
url: `/services/${destinationConfig.serviceId}/env-vars`,
|
||||||
|
data: envVars
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case RenderSyncScope.EnvironmentGroup: {
|
||||||
|
for await (const variable of envVars) {
|
||||||
|
await makeRequestWithRetry(() =>
|
||||||
|
request.request({
|
||||||
|
...req,
|
||||||
|
url: `/env-groups/${destinationConfig.environmentGroupId}/env-vars/${variable.key}`,
|
||||||
|
data: {
|
||||||
|
value: variable.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})
|
break;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new BadRequestError({ message: "Unknown render sync destination scope" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteEnvironmentSecret = async (
|
||||||
|
secretSync: TRenderSyncWithCredentials,
|
||||||
|
envVar: { key: string; value: string }
|
||||||
|
): Promise<void> => {
|
||||||
|
const {
|
||||||
|
destinationConfig,
|
||||||
|
connection: {
|
||||||
|
credentials: { apiKey }
|
||||||
|
}
|
||||||
|
} = secretSync;
|
||||||
|
|
||||||
|
const req: AxiosRequestConfig = {
|
||||||
|
baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`,
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
Accept: "application/json"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (destinationConfig.scope) {
|
||||||
|
case RenderSyncScope.Service: {
|
||||||
|
await makeRequestWithRetry(() =>
|
||||||
|
request.request({
|
||||||
|
...req,
|
||||||
|
url: `/services/${destinationConfig.serviceId}/env-vars/${envVar.key}`
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case RenderSyncScope.EnvironmentGroup: {
|
||||||
|
await makeRequestWithRetry(() =>
|
||||||
|
request.request({
|
||||||
|
...req,
|
||||||
|
url: `/env-groups/${destinationConfig.environmentGroupId}/env-vars/${envVar.key}`
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new BadRequestError({ message: "Unknown render sync destination scope" });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const redeployService = async (secretSync: TRenderSyncWithCredentials) => {
|
const redeployService = async (secretSync: TRenderSyncWithCredentials) => {
|
||||||
@@ -105,18 +225,50 @@ const redeployService = async (secretSync: TRenderSyncWithCredentials) => {
|
|||||||
}
|
}
|
||||||
} = secretSync;
|
} = secretSync;
|
||||||
|
|
||||||
await makeRequestWithRetry(() =>
|
const req: AxiosRequestConfig = {
|
||||||
request.post(
|
baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`,
|
||||||
`${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/deploys`,
|
headers: {
|
||||||
{},
|
Authorization: `Bearer ${apiKey}`,
|
||||||
{
|
Accept: "application/json"
|
||||||
headers: {
|
}
|
||||||
Authorization: `Bearer ${apiKey}`,
|
};
|
||||||
Accept: "application/json"
|
|
||||||
}
|
switch (destinationConfig.scope) {
|
||||||
|
case RenderSyncScope.Service: {
|
||||||
|
await makeRequestWithRetry(() =>
|
||||||
|
request.request({
|
||||||
|
...req,
|
||||||
|
method: "POST",
|
||||||
|
url: `/services/${destinationConfig.serviceId}/deploys`,
|
||||||
|
data: {}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case RenderSyncScope.EnvironmentGroup: {
|
||||||
|
const { data } = await request.request<{ serviceLinks: { id: string }[] }>({
|
||||||
|
...req,
|
||||||
|
method: "GET",
|
||||||
|
url: `/env-groups/${destinationConfig.environmentGroupId}`
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const link of data.serviceLinks) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
||||||
|
await makeRequestWithRetry(() =>
|
||||||
|
request.request({
|
||||||
|
...req,
|
||||||
|
url: `/services/${link.id}/deploys`,
|
||||||
|
data: {}
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
)
|
break;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new BadRequestError({ message: "Unknown render sync destination scope" });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RenderSyncFns = {
|
export const RenderSyncFns = {
|
||||||
@@ -169,14 +321,15 @@ export const RenderSyncFns = {
|
|||||||
const finalEnvVars: Array<{ key: string; value: string }> = [];
|
const finalEnvVars: Array<{ key: string; value: string }> = [];
|
||||||
|
|
||||||
for (const renderSecret of renderSecrets) {
|
for (const renderSecret of renderSecrets) {
|
||||||
if (!(renderSecret.key in secretMap)) {
|
if (renderSecret.key in secretMap) {
|
||||||
finalEnvVars.push({
|
finalEnvVars.push({
|
||||||
key: renderSecret.key,
|
key: renderSecret.key,
|
||||||
value: renderSecret.value
|
value: renderSecret.value
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await batchUpdateEnvironmentSecrets(secretSync, finalEnvVars);
|
|
||||||
|
await Promise.all(finalEnvVars.map((el) => deleteEnvironmentSecret(secretSync, el)));
|
||||||
|
|
||||||
if (secretSync.syncOptions.autoRedeployServices) {
|
if (secretSync.syncOptions.autoRedeployServices) {
|
||||||
await redeployService(secretSync);
|
await redeployService(secretSync);
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ const RenderSyncDestinationConfigSchema = z.discriminatedUnion("scope", [
|
|||||||
scope: z.literal(RenderSyncScope.Service).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope),
|
scope: z.literal(RenderSyncScope.Service).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope),
|
||||||
serviceId: z.string().min(1, "Service ID is required").describe(SecretSyncs.DESTINATION_CONFIG.RENDER.serviceId),
|
serviceId: z.string().min(1, "Service ID is required").describe(SecretSyncs.DESTINATION_CONFIG.RENDER.serviceId),
|
||||||
type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type)
|
type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type)
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
scope: z.literal(RenderSyncScope.EnvironmentGroup).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope),
|
||||||
|
environmentGroupId: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Environment Group ID is required")
|
||||||
|
.describe(SecretSyncs.DESTINATION_CONFIG.RENDER.environmentGroupId),
|
||||||
|
type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type)
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -684,9 +684,9 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
|
|||||||
throw new BadRequestError({ message: "Missing personal user id" });
|
throw new BadRequestError({ message: "Missing personal user id" });
|
||||||
}
|
}
|
||||||
void bd.orWhere({
|
void bd.orWhere({
|
||||||
key: el.key,
|
[`${TableName.SecretV2}.key` as "key"]: el.key,
|
||||||
type: el.type,
|
[`${TableName.SecretV2}.type` as "type"]: el.type,
|
||||||
userId: el.type === SecretType.Personal ? el.userId : null
|
[`${TableName.SecretV2}.userId` as "userId"]: el.type === SecretType.Personal ? el.userId : null
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -695,12 +695,60 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
|
|||||||
`${TableName.SecretV2}.id`,
|
`${TableName.SecretV2}.id`,
|
||||||
`${TableName.SecretRotationV2SecretMapping}.secretId`
|
`${TableName.SecretRotationV2SecretMapping}.secretId`
|
||||||
)
|
)
|
||||||
|
|
||||||
|
.leftJoin(
|
||||||
|
TableName.SecretV2JnTag,
|
||||||
|
`${TableName.SecretV2}.id`,
|
||||||
|
`${TableName.SecretV2JnTag}.${TableName.SecretV2}Id`
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
TableName.SecretTag,
|
||||||
|
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
|
||||||
|
`${TableName.SecretTag}.id`
|
||||||
|
)
|
||||||
|
.leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`)
|
||||||
|
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
|
||||||
|
.select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
|
||||||
|
.select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"))
|
||||||
|
.select(
|
||||||
|
db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
|
||||||
|
db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
|
||||||
|
db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue")
|
||||||
|
)
|
||||||
.select(selectAllTableCols(TableName.SecretV2))
|
.select(selectAllTableCols(TableName.SecretV2))
|
||||||
.select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping));
|
.select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping));
|
||||||
return secrets.map((secret) => ({
|
|
||||||
...secret,
|
const docs = sqlNestRelationships({
|
||||||
isRotatedSecret: Boolean(secret.rotationId)
|
data: secrets,
|
||||||
}));
|
key: "id",
|
||||||
|
parentMapper: (secret) => ({
|
||||||
|
...secret,
|
||||||
|
isRotatedSecret: Boolean(secret.rotationId)
|
||||||
|
}),
|
||||||
|
childrenMapper: [
|
||||||
|
{
|
||||||
|
key: "tagId",
|
||||||
|
label: "tags" as const,
|
||||||
|
mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({
|
||||||
|
id,
|
||||||
|
color,
|
||||||
|
slug,
|
||||||
|
name: slug
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "metadataId",
|
||||||
|
label: "secretMetadata" as const,
|
||||||
|
mapper: ({ metadataKey, metadataValue, metadataId }) => ({
|
||||||
|
id: metadataId,
|
||||||
|
key: metadataKey,
|
||||||
|
value: metadataValue
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
return docs;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new DatabaseError({ error, name: "find by secret keys" });
|
throw new DatabaseError({ error, name: "find by secret keys" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: "[email protected]",
|
||||||
|
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: "[email protected]",
|
||||||
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user