mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge branch 'Infisical:main' into main
This commit is contained in:
@@ -65,6 +65,15 @@ jobs:
|
||||
INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }}
|
||||
DD_GIT_REPOSITORY_URL=${{ github.server_url }}/${{ github.repository }}
|
||||
DD_GIT_COMMIT_SHA=${{ github.sha }}
|
||||
- name: Snyk to check Docker image for vulnerabilities
|
||||
continue-on-error: true
|
||||
uses: snyk/actions/docker@master
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
with:
|
||||
image: infisical/infisical:${{ steps.extract_version.outputs.version }}
|
||||
command: monitor
|
||||
args: --file=Dockerfile.standalone-infisical --project-name="infisical-core-docker-image"
|
||||
|
||||
infisical-fips-standalone:
|
||||
name: Build infisical standalone image postgres
|
||||
@@ -141,4 +150,4 @@ jobs:
|
||||
echo "Successfully created tag $TAG_NAME"
|
||||
fi
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.OMNIBUS_RELEASE_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.OMNIBUS_RELEASE_TOKEN }}
|
||||
|
||||
@@ -3,7 +3,10 @@ ARG POSTHOG_API_KEY=posthog-api-key
|
||||
ARG INTERCOM_ID=intercom-id
|
||||
ARG CAPTCHA_SITE_KEY=captcha-site-key
|
||||
|
||||
FROM node:20-slim AS base
|
||||
FROM node:20.19.5-trixie-slim AS base
|
||||
|
||||
# Fixes NPM vulnerability: https://security.snyk.io/vuln/SNYK-JS-CROSSSPAWN-8303230
|
||||
RUN npm install -g npm@11
|
||||
|
||||
FROM base AS frontend-dependencies
|
||||
WORKDIR /app
|
||||
@@ -155,7 +158,7 @@ RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \
|
||||
|
||||
# Install Infisical CLI
|
||||
RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \
|
||||
&& apt-get update && apt-get install -y infisical=0.41.89 \
|
||||
&& apt-get update && apt-get install -y infisical=0.42.6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd -r -g 1001 nodejs && useradd -r -u 1001 -g nodejs non-root-user
|
||||
|
||||
@@ -3,7 +3,10 @@ ARG POSTHOG_API_KEY=posthog-api-key
|
||||
ARG INTERCOM_ID=intercom-id
|
||||
ARG CAPTCHA_SITE_KEY=captcha-site-key
|
||||
|
||||
FROM node:20-slim AS base
|
||||
FROM node:20.19.5-trixie-slim AS base
|
||||
|
||||
# Fixes NPM vulnerability: https://security.snyk.io/vuln/SNYK-JS-CROSSSPAWN-8303230
|
||||
RUN npm install -g npm@11
|
||||
|
||||
FROM base AS frontend-dependencies
|
||||
|
||||
@@ -139,7 +142,7 @@ RUN apt-get update && apt-get install -y \
|
||||
|
||||
# Install Infisical CLI
|
||||
RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \
|
||||
&& apt-get update && apt-get install -y infisical=0.41.89 \
|
||||
&& apt-get update && apt-get install -y infisical=0.42.6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM node:20-slim AS build
|
||||
FROM node:20.19.5-trixie-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -26,7 +26,7 @@ COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-slim
|
||||
FROM node:20.19.5-trixie-slim
|
||||
WORKDIR /app
|
||||
|
||||
ENV npm_config_cache /home/node/.npm
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-slim
|
||||
FROM node:20.19.5-trixie-slim
|
||||
|
||||
# ? Setup a test SoftHSM module. In production a real HSM is used.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-slim
|
||||
FROM node:20.19.5-trixie-slim
|
||||
|
||||
# ? Setup a test SoftHSM module. In production a real HSM is used.
|
||||
|
||||
|
||||
4874
backend/package-lock.json
generated
4874
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -79,12 +79,17 @@
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"overrides": {
|
||||
"cipher-base": "1.0.5",
|
||||
"sha.js": "2.4.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.18.10",
|
||||
"@babel/core": "^7.18.10",
|
||||
"@babel/plugin-syntax-import-attributes": "^7.24.7",
|
||||
"@babel/preset-env": "^7.18.10",
|
||||
"@babel/preset-react": "^7.24.7",
|
||||
"@react-email/preview-server": "^4.3.0",
|
||||
"@smithy/types": "^4.3.1",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/jmespath": "^0.15.2",
|
||||
@@ -120,7 +125,7 @@
|
||||
"nodemon": "^3.0.2",
|
||||
"pino-pretty": "^10.2.3",
|
||||
"prompt-sync": "^4.2.0",
|
||||
"react-email": "4.0.7",
|
||||
"react-email": "^4.3.0",
|
||||
"rimraf": "^5.0.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsc-alias": "^1.8.8",
|
||||
@@ -138,7 +143,7 @@
|
||||
"@aws-sdk/client-secrets-manager": "^3.504.0",
|
||||
"@aws-sdk/client-sts": "^3.600.0",
|
||||
"@casl/ability": "^6.5.0",
|
||||
"@elastic/elasticsearch": "^8.15.0",
|
||||
"@elastic/elasticsearch": "^9.1.1",
|
||||
"@fastify/cookie": "^9.3.1",
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/etag": "^5.1.0",
|
||||
@@ -185,7 +190,7 @@
|
||||
"ajv": "^8.12.0",
|
||||
"argon2": "^0.31.2",
|
||||
"aws-sdk": "^2.1553.0",
|
||||
"axios": "^1.11.0",
|
||||
"axios": "^1.12.0",
|
||||
"axios-ntlm": "^1.4.4",
|
||||
"axios-retry": "^4.0.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
@@ -196,7 +201,7 @@
|
||||
"cron": "^3.1.7",
|
||||
"dd-trace": "^5.40.0",
|
||||
"dotenv": "^16.4.1",
|
||||
"fastify": "^4.28.1",
|
||||
"fastify": "^4.29.1",
|
||||
"fastify-plugin": "^4.5.1",
|
||||
"google-auth-library": "^9.9.0",
|
||||
"googleapis": "^137.1.0",
|
||||
|
||||
8
backend/src/@types/fastify.d.ts
vendored
8
backend/src/@types/fastify.d.ts
vendored
@@ -28,6 +28,10 @@ import { TKmipServiceFactory } from "@app/ee/services/kmip/kmip-service";
|
||||
import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service";
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { TOidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service";
|
||||
import { TPamAccountServiceFactory } from "@app/ee/services/pam-account/pam-account-service";
|
||||
import { TPamFolderServiceFactory } from "@app/ee/services/pam-folder/pam-folder-service";
|
||||
import { TPamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-resource-service";
|
||||
import { TPamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import { TPitServiceFactory } from "@app/ee/services/pit/pit-service";
|
||||
import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-types";
|
||||
@@ -315,6 +319,10 @@ declare module "fastify" {
|
||||
identityAuthTemplate: TIdentityAuthTemplateServiceFactory;
|
||||
notification: TNotificationServiceFactory;
|
||||
offlineUsageReport: TOfflineUsageReportServiceFactory;
|
||||
pamFolder: TPamFolderServiceFactory;
|
||||
pamResource: TPamResourceServiceFactory;
|
||||
pamAccount: TPamAccountServiceFactory;
|
||||
pamSession: TPamSessionServiceFactory;
|
||||
upgradePath: TUpgradePathService;
|
||||
};
|
||||
// this is exclusive use for middlewares in which we need to inject data
|
||||
|
||||
8
backend/src/@types/knex.d.ts
vendored
8
backend/src/@types/knex.d.ts
vendored
@@ -530,6 +530,10 @@ import {
|
||||
TMicrosoftTeamsIntegrationsInsert,
|
||||
TMicrosoftTeamsIntegrationsUpdate
|
||||
} from "@app/db/schemas/microsoft-teams-integrations";
|
||||
import { TPamAccounts, TPamAccountsInsert, TPamAccountsUpdate } from "@app/db/schemas/pam-accounts";
|
||||
import { TPamFolders, TPamFoldersInsert, TPamFoldersUpdate } from "@app/db/schemas/pam-folders";
|
||||
import { TPamResources, TPamResourcesInsert, TPamResourcesUpdate } from "@app/db/schemas/pam-resources";
|
||||
import { TPamSessions, TPamSessionsInsert, TPamSessionsUpdate } from "@app/db/schemas/pam-sessions";
|
||||
import {
|
||||
TProjectMicrosoftTeamsConfigs,
|
||||
TProjectMicrosoftTeamsConfigsInsert,
|
||||
@@ -1308,5 +1312,9 @@ declare module "knex/types/tables" {
|
||||
TKeyValueStoreInsert,
|
||||
TKeyValueStoreUpdate
|
||||
>;
|
||||
[TableName.PamFolder]: KnexOriginal.CompositeTableType<TPamFolders, TPamFoldersInsert, TPamFoldersUpdate>;
|
||||
[TableName.PamResource]: KnexOriginal.CompositeTableType<TPamResources, TPamResourcesInsert, TPamResourcesUpdate>;
|
||||
[TableName.PamAccount]: KnexOriginal.CompositeTableType<TPamAccounts, TPamAccountsInsert, TPamAccountsUpdate>;
|
||||
[TableName.PamSession]: KnexOriginal.CompositeTableType<TPamSessions, TPamSessionsInsert, TPamSessionsUpdate>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
// Insert New Commits in batches of 9000
|
||||
const newCommits = foldersCommitsList.map((folderCommit) => folderCommit.commit);
|
||||
const commitBatches = chunkArray(newCommits, 9000);
|
||||
|
||||
let pendingDeepTreeCommitResources: TFolderCommits[] = [];
|
||||
let j = 0;
|
||||
for (const commitBatch of commitBatches) {
|
||||
j += 1;
|
||||
@@ -265,12 +265,14 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// Create folder commit changes
|
||||
const currentBatchFolderIds = new Set(newCommitsInserted.map((commit) => commit.folderId));
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex.batchInsert(
|
||||
TableName.FolderCommitChanges,
|
||||
foldersCommitsList
|
||||
.map((folderCommit) => folderCommit.changes)
|
||||
.flat()
|
||||
.filter((change) => currentBatchFolderIds.has(change.folderId))
|
||||
.map((change) => ({
|
||||
folderCommitId: newCommitsMap[change.folderId],
|
||||
changeType: change.changeType,
|
||||
@@ -289,6 +291,7 @@ export async function up(knex: Knex): Promise<void> {
|
||||
foldersCommitsList
|
||||
.map((folderCommit) => folderCommit.changes)
|
||||
.flat()
|
||||
.filter((change) => currentBatchFolderIds.has(change.folderId))
|
||||
.map((change) => ({
|
||||
folderCheckpointId: newCheckpointsMap[change.folderId],
|
||||
folderVersionId: change.folderVersionId,
|
||||
@@ -303,9 +306,11 @@ export async function up(knex: Knex): Promise<void> {
|
||||
const newTreeCheckpoints = (await knex
|
||||
.batchInsert(
|
||||
TableName.FolderTreeCheckpoint,
|
||||
Object.keys(rootFoldersMap).map((folderId) => ({
|
||||
folderCommitId: newCommitsMap[folderId]
|
||||
}))
|
||||
Object.keys(rootFoldersMap)
|
||||
.filter((folderId) => currentBatchFolderIds.has(folderId))
|
||||
.map((folderId) => ({
|
||||
folderCommitId: newCommitsMap[folderId]
|
||||
}))
|
||||
)
|
||||
.returning("*")) as TFolderTreeCheckpoints[];
|
||||
|
||||
@@ -317,18 +322,31 @@ export async function up(knex: Knex): Promise<void> {
|
||||
});
|
||||
|
||||
// Create Folder Tree Checkpoint Resources
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex
|
||||
.batchInsert(
|
||||
TableName.FolderTreeCheckpointResources,
|
||||
newCommitsInserted.map((folderCommit) => ({
|
||||
folderTreeCheckpointId: newTreeCheckpointsMap[folderCommit.envId],
|
||||
folderId: folderCommit.folderId,
|
||||
folderCommitId: folderCommit.id
|
||||
}))
|
||||
)
|
||||
.returning("*");
|
||||
const commitsToProcess = pendingDeepTreeCommitResources.concat(newCommitsInserted);
|
||||
const unprocessableCommits: TFolderCommits[] = [];
|
||||
const processableCommits = commitsToProcess.filter((folderCommit) => {
|
||||
const isProcessable = newTreeCheckpointsMap[folderCommit.envId];
|
||||
if (!isProcessable) {
|
||||
unprocessableCommits.push(folderCommit);
|
||||
}
|
||||
return isProcessable;
|
||||
});
|
||||
|
||||
if (processableCommits.length > 0) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex
|
||||
.batchInsert(
|
||||
TableName.FolderTreeCheckpointResources,
|
||||
processableCommits.map((folderCommit) => ({
|
||||
folderTreeCheckpointId: newTreeCheckpointsMap[folderCommit.envId],
|
||||
folderId: folderCommit.folderId,
|
||||
folderCommitId: folderCommit.id
|
||||
}))
|
||||
)
|
||||
.returning("*");
|
||||
}
|
||||
|
||||
pendingDeepTreeCommitResources = unprocessableCommits;
|
||||
logger.info(`Finished inserting folder tree checkpoint resources - batch ${j} of ${commitBatches.length}`);
|
||||
}
|
||||
}
|
||||
|
||||
165
backend/src/db/migrations/20250917052037_pam.ts
Normal file
165
backend/src/db/migrations/20250917052037_pam.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// PAM Folders
|
||||
if (!(await knex.schema.hasTable(TableName.PamFolder))) {
|
||||
await knex.schema.createTable(TableName.PamFolder, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
|
||||
t.string("projectId").notNullable();
|
||||
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
|
||||
t.index("projectId");
|
||||
|
||||
t.uuid("parentId").nullable();
|
||||
t.foreign("parentId").references("id").inTable(TableName.PamFolder).onDelete("CASCADE");
|
||||
t.index("parentId");
|
||||
|
||||
t.string("name").notNullable();
|
||||
t.index("name");
|
||||
|
||||
// Enforce uniqueness for sub-folders
|
||||
t.unique(["projectId", "parentId", "name"], {
|
||||
indexName: "uidx_pam_folder_children_name",
|
||||
predicate: knex.whereNotNull("parentId")
|
||||
});
|
||||
|
||||
// Enforce uniqueness for root-level folders
|
||||
t.unique(["projectId", "name"], {
|
||||
indexName: "uidx_pam_folder_root_name",
|
||||
predicate: knex.whereNull("parentId")
|
||||
});
|
||||
|
||||
t.text("description").nullable();
|
||||
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
}
|
||||
|
||||
// PAM Resources
|
||||
if (!(await knex.schema.hasTable(TableName.PamResource))) {
|
||||
await knex.schema.createTable(TableName.PamResource, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
|
||||
t.string("projectId").notNullable();
|
||||
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
|
||||
t.index("projectId");
|
||||
|
||||
t.string("name").notNullable();
|
||||
t.index("name");
|
||||
|
||||
t.uuid("gatewayId").notNullable();
|
||||
t.foreign("gatewayId").references("id").inTable(TableName.GatewayV2);
|
||||
t.index("gatewayId");
|
||||
|
||||
t.string("resourceType").notNullable();
|
||||
t.index("resourceType");
|
||||
|
||||
t.binary("encryptedConnectionDetails").notNullable();
|
||||
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
}
|
||||
|
||||
// PAM Accounts
|
||||
if (!(await knex.schema.hasTable(TableName.PamAccount))) {
|
||||
await knex.schema.createTable(TableName.PamAccount, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
|
||||
t.string("projectId").notNullable();
|
||||
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
|
||||
t.index("projectId");
|
||||
|
||||
t.uuid("folderId").nullable();
|
||||
t.foreign("folderId").references("id").inTable(TableName.PamFolder).onDelete("CASCADE");
|
||||
t.index("folderId");
|
||||
|
||||
t.uuid("resourceId").notNullable();
|
||||
t.foreign("resourceId").references("id").inTable(TableName.PamResource);
|
||||
t.index("resourceId");
|
||||
|
||||
t.string("name").notNullable();
|
||||
t.index("name");
|
||||
|
||||
// Enforce uniqueness for folders
|
||||
t.unique(["projectId", "folderId", "name"], {
|
||||
indexName: "uidx_pam_account_children_name",
|
||||
predicate: knex.whereNotNull("folderId")
|
||||
});
|
||||
|
||||
// Enforce uniqueness for root-level
|
||||
t.unique(["projectId", "name"], {
|
||||
indexName: "uidx_pam_account_root_name",
|
||||
predicate: knex.whereNull("folderId")
|
||||
});
|
||||
|
||||
t.text("description").nullable();
|
||||
t.binary("encryptedCredentials").notNullable();
|
||||
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
}
|
||||
|
||||
// PAM Sessions
|
||||
if (!(await knex.schema.hasTable(TableName.PamSession))) {
|
||||
await knex.schema.createTable(TableName.PamSession, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
|
||||
t.string("projectId").notNullable();
|
||||
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
|
||||
t.index("projectId");
|
||||
|
||||
t.uuid("accountId").nullable();
|
||||
t.foreign("accountId").references("id").inTable(TableName.PamAccount).onDelete("SET NULL");
|
||||
t.index("accountId");
|
||||
|
||||
// To be used in the event of an account deletion
|
||||
t.string("resourceType").notNullable();
|
||||
t.string("resourceName").notNullable();
|
||||
t.string("accountName").notNullable();
|
||||
|
||||
t.uuid("userId").nullable();
|
||||
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("SET NULL");
|
||||
t.index("userId");
|
||||
|
||||
// To be used in the event of user deletion
|
||||
t.string("actorName").notNullable();
|
||||
t.string("actorEmail").notNullable();
|
||||
|
||||
t.string("actorIp").notNullable();
|
||||
t.string("actorUserAgent").notNullable();
|
||||
|
||||
t.string("status").notNullable();
|
||||
t.index("status");
|
||||
|
||||
t.binary("encryptedLogsBlob").nullable();
|
||||
|
||||
t.datetime("expiresAt").notNullable();
|
||||
|
||||
t.datetime("startedAt").nullable(); // Not when the row is created, but when the end-to-end connection between user and resource is established
|
||||
t.datetime("endedAt").nullable();
|
||||
t.index(["startedAt", "endedAt"]);
|
||||
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
}
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.PamFolder);
|
||||
await createOnUpdateTrigger(knex, TableName.PamResource);
|
||||
await createOnUpdateTrigger(knex, TableName.PamAccount);
|
||||
await createOnUpdateTrigger(knex, TableName.PamSession);
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists(TableName.PamSession);
|
||||
await knex.schema.dropTableIfExists(TableName.PamAccount);
|
||||
await knex.schema.dropTableIfExists(TableName.PamResource);
|
||||
await knex.schema.dropTableIfExists(TableName.PamFolder);
|
||||
|
||||
await dropOnUpdateTrigger(knex, TableName.PamSession);
|
||||
await dropOnUpdateTrigger(knex, TableName.PamAccount);
|
||||
await dropOnUpdateTrigger(knex, TableName.PamResource);
|
||||
await dropOnUpdateTrigger(knex, TableName.PamFolder);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasEnableGroupSyncCol = await knex.schema.hasColumn(TableName.SamlConfig, "enableGroupSync");
|
||||
|
||||
if (!hasEnableGroupSyncCol) {
|
||||
await knex.schema.alterTable(TableName.SamlConfig, (tb) => {
|
||||
tb.boolean("enableGroupSync").notNullable().defaultTo(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasEnableGroupSyncCol = await knex.schema.hasColumn(TableName.SamlConfig, "enableGroupSync");
|
||||
|
||||
if (hasEnableGroupSyncCol) {
|
||||
await knex.schema.alterTable(TableName.SamlConfig, (t) => {
|
||||
t.dropColumn("enableGroupSync");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasColumn(TableName.GatewayV2, "encryptedPamSessionKey"))) {
|
||||
await knex.schema.alterTable(TableName.GatewayV2, (t) => {
|
||||
t.binary("encryptedPamSessionKey");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasColumn(TableName.GatewayV2, "encryptedPamSessionKey")) {
|
||||
await knex.schema.alterTable(TableName.GatewayV2, (t) => {
|
||||
t.dropColumn("encryptedPamSessionKey");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { zodBuffer } from "@app/lib/zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const GatewaysV2Schema = z.object({
|
||||
@@ -15,7 +17,8 @@ export const GatewaysV2Schema = z.object({
|
||||
identityId: z.string().uuid(),
|
||||
relayId: z.string().uuid().nullable().optional(),
|
||||
name: z.string(),
|
||||
heartbeat: z.date().nullable().optional()
|
||||
heartbeat: z.date().nullable().optional(),
|
||||
encryptedPamSessionKey: zodBuffer.nullable().optional()
|
||||
});
|
||||
|
||||
export type TGatewaysV2 = z.infer<typeof GatewaysV2Schema>;
|
||||
|
||||
@@ -83,6 +83,10 @@ export * from "./org-memberships";
|
||||
export * from "./org-relay-config";
|
||||
export * from "./org-roles";
|
||||
export * from "./organizations";
|
||||
export * from "./pam-accounts";
|
||||
export * from "./pam-folders";
|
||||
export * from "./pam-resources";
|
||||
export * from "./pam-sessions";
|
||||
export * from "./pki-alerts";
|
||||
export * from "./pki-collection-items";
|
||||
export * from "./pki-collections";
|
||||
|
||||
@@ -189,7 +189,13 @@ export enum TableName {
|
||||
Relay = "relays",
|
||||
GatewayV2 = "gateways_v2",
|
||||
|
||||
KeyValueStore = "key_value_store"
|
||||
KeyValueStore = "key_value_store",
|
||||
|
||||
// PAM
|
||||
PamFolder = "pam_folders",
|
||||
PamResource = "pam_resources",
|
||||
PamAccount = "pam_accounts",
|
||||
PamSession = "pam_sessions"
|
||||
}
|
||||
|
||||
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId";
|
||||
@@ -281,7 +287,8 @@ export enum ProjectType {
|
||||
CertificateManager = "cert-manager",
|
||||
KMS = "kms",
|
||||
SSH = "ssh",
|
||||
SecretScanning = "secret-scanning"
|
||||
SecretScanning = "secret-scanning",
|
||||
PAM = "pam"
|
||||
}
|
||||
|
||||
export enum ActionProjectType {
|
||||
@@ -290,6 +297,7 @@ export enum ActionProjectType {
|
||||
KMS = ProjectType.KMS,
|
||||
SSH = ProjectType.SSH,
|
||||
SecretScanning = ProjectType.SecretScanning,
|
||||
PAM = ProjectType.PAM,
|
||||
// project operations that happen on all types
|
||||
Any = "any"
|
||||
}
|
||||
|
||||
26
backend/src/db/schemas/pam-accounts.ts
Normal file
26
backend/src/db/schemas/pam-accounts.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { zodBuffer } from "@app/lib/zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const PamAccountsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
projectId: z.string(),
|
||||
folderId: z.string().uuid().nullable().optional(),
|
||||
resourceId: z.string().uuid(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
encryptedCredentials: zodBuffer,
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPamAccounts = z.infer<typeof PamAccountsSchema>;
|
||||
export type TPamAccountsInsert = Omit<z.input<typeof PamAccountsSchema>, TImmutableDBKeys>;
|
||||
export type TPamAccountsUpdate = Partial<Omit<z.input<typeof PamAccountsSchema>, TImmutableDBKeys>>;
|
||||
22
backend/src/db/schemas/pam-folders.ts
Normal file
22
backend/src/db/schemas/pam-folders.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const PamFoldersSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
projectId: z.string(),
|
||||
parentId: z.string().uuid().nullable().optional(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPamFolders = z.infer<typeof PamFoldersSchema>;
|
||||
export type TPamFoldersInsert = Omit<z.input<typeof PamFoldersSchema>, TImmutableDBKeys>;
|
||||
export type TPamFoldersUpdate = Partial<Omit<z.input<typeof PamFoldersSchema>, TImmutableDBKeys>>;
|
||||
25
backend/src/db/schemas/pam-resources.ts
Normal file
25
backend/src/db/schemas/pam-resources.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { zodBuffer } from "@app/lib/zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const PamResourcesSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
projectId: z.string(),
|
||||
name: z.string(),
|
||||
gatewayId: z.string().uuid(),
|
||||
resourceType: z.string(),
|
||||
encryptedConnectionDetails: zodBuffer,
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPamResources = z.infer<typeof PamResourcesSchema>;
|
||||
export type TPamResourcesInsert = Omit<z.input<typeof PamResourcesSchema>, TImmutableDBKeys>;
|
||||
export type TPamResourcesUpdate = Partial<Omit<z.input<typeof PamResourcesSchema>, TImmutableDBKeys>>;
|
||||
35
backend/src/db/schemas/pam-sessions.ts
Normal file
35
backend/src/db/schemas/pam-sessions.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { zodBuffer } from "@app/lib/zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const PamSessionsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
projectId: z.string(),
|
||||
accountId: z.string().uuid().nullable().optional(),
|
||||
resourceType: z.string(),
|
||||
resourceName: z.string(),
|
||||
accountName: z.string(),
|
||||
userId: z.string().uuid().nullable().optional(),
|
||||
actorName: z.string(),
|
||||
actorEmail: z.string(),
|
||||
actorIp: z.string(),
|
||||
actorUserAgent: z.string(),
|
||||
status: z.string(),
|
||||
encryptedLogsBlob: zodBuffer.nullable().optional(),
|
||||
expiresAt: z.date(),
|
||||
startedAt: z.date().nullable().optional(),
|
||||
endedAt: z.date().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPamSessions = z.infer<typeof PamSessionsSchema>;
|
||||
export type TPamSessionsInsert = Omit<z.input<typeof PamSessionsSchema>, TImmutableDBKeys>;
|
||||
export type TPamSessionsUpdate = Partial<Omit<z.input<typeof PamSessionsSchema>, TImmutableDBKeys>>;
|
||||
@@ -28,7 +28,8 @@ export const SamlConfigsSchema = z.object({
|
||||
lastUsed: z.date().nullable().optional(),
|
||||
encryptedSamlEntryPoint: zodBuffer,
|
||||
encryptedSamlIssuer: zodBuffer,
|
||||
encryptedSamlCertificate: zodBuffer
|
||||
encryptedSamlCertificate: zodBuffer,
|
||||
enableGroupSync: z.boolean().default(false)
|
||||
});
|
||||
|
||||
export type TSamlConfigs = z.infer<typeof SamlConfigsSchema>;
|
||||
|
||||
@@ -23,6 +23,12 @@ import { registerLdapRouter } from "./ldap-router";
|
||||
import { registerLicenseRouter } from "./license-router";
|
||||
import { registerOidcRouter } from "./oidc-router";
|
||||
import { registerOrgRoleRouter } from "./org-role-router";
|
||||
import { PAM_ACCOUNT_REGISTER_ROUTER_MAP } from "./pam-account-routers";
|
||||
import { registerPamAccountRouter } from "./pam-account-routers/pam-account-router";
|
||||
import { registerPamFolderRouter } from "./pam-folder-router";
|
||||
import { PAM_RESOURCE_REGISTER_ROUTER_MAP } from "./pam-resource-routers";
|
||||
import { registerPamResourceRouter } from "./pam-resource-routers/pam-resource-router";
|
||||
import { registerPamSessionRouter } from "./pam-session-router";
|
||||
import { registerPITRouter } from "./pit-router";
|
||||
import { registerProjectRoleRouter } from "./project-role-router";
|
||||
import { registerProjectRouter } from "./project-router";
|
||||
@@ -166,4 +172,40 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
{ prefix: "/kmip" }
|
||||
);
|
||||
|
||||
await server.register(
|
||||
async (pamRouter) => {
|
||||
await pamRouter.register(registerPamFolderRouter, { prefix: "/folders" });
|
||||
await pamRouter.register(registerPamSessionRouter, { prefix: "/sessions" });
|
||||
|
||||
await pamRouter.register(
|
||||
async (pamAccountRouter) => {
|
||||
await pamAccountRouter.register(registerPamAccountRouter);
|
||||
|
||||
// Provider-specific endpoints
|
||||
await Promise.all(
|
||||
Object.entries(PAM_ACCOUNT_REGISTER_ROUTER_MAP).map(([provider, router]) =>
|
||||
pamAccountRouter.register(router, { prefix: `/${provider}` })
|
||||
)
|
||||
);
|
||||
},
|
||||
{ prefix: "/accounts" }
|
||||
);
|
||||
|
||||
await pamRouter.register(
|
||||
async (pamResourceRouter) => {
|
||||
await pamResourceRouter.register(registerPamResourceRouter);
|
||||
|
||||
// Provider-specific endpoints
|
||||
await Promise.all(
|
||||
Object.entries(PAM_RESOURCE_REGISTER_ROUTER_MAP).map(([provider, router]) =>
|
||||
pamResourceRouter.register(router, { prefix: `/${provider}` })
|
||||
)
|
||||
);
|
||||
},
|
||||
{ prefix: "/resources" }
|
||||
);
|
||||
},
|
||||
{ prefix: "/pam" }
|
||||
);
|
||||
};
|
||||
|
||||
20
backend/src/ee/routes/v1/pam-account-routers/index.ts
Normal file
20
backend/src/ee/routes/v1/pam-account-routers/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
|
||||
import {
|
||||
CreatePostgresAccountSchema,
|
||||
SanitizedPostgresAccountWithResourceSchema,
|
||||
UpdatePostgresAccountSchema
|
||||
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
|
||||
import { registerPamResourceEndpoints } from "./pam-account-endpoints";
|
||||
|
||||
export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record<PamResource, (server: FastifyZodProvider) => Promise<void>> = {
|
||||
[PamResource.Postgres]: async (server: FastifyZodProvider) => {
|
||||
registerPamResourceEndpoints({
|
||||
server,
|
||||
resourceType: PamResource.Postgres,
|
||||
accountResponseSchema: SanitizedPostgresAccountWithResourceSchema,
|
||||
createAccountSchema: CreatePostgresAccountSchema,
|
||||
updateAccountSchema: UpdatePostgresAccountSchema
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
|
||||
import { TPamAccount } from "@app/ee/services/pam-resource/pam-resource-types";
|
||||
import { writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerPamResourceEndpoints = <C extends TPamAccount>({
|
||||
server,
|
||||
resourceType,
|
||||
createAccountSchema,
|
||||
updateAccountSchema,
|
||||
accountResponseSchema
|
||||
}: {
|
||||
server: FastifyZodProvider;
|
||||
resourceType: PamResource;
|
||||
createAccountSchema: z.ZodType<{
|
||||
credentials: C["credentials"];
|
||||
resourceId: C["resourceId"];
|
||||
folderId?: C["folderId"];
|
||||
name: C["name"];
|
||||
description?: C["description"];
|
||||
}>;
|
||||
updateAccountSchema: z.ZodType<{
|
||||
credentials?: C["credentials"];
|
||||
name?: C["name"];
|
||||
description?: C["description"];
|
||||
}>;
|
||||
accountResponseSchema: z.ZodTypeAny;
|
||||
}) => {
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Create PAM account",
|
||||
body: createAccountSchema,
|
||||
response: {
|
||||
200: z.object({
|
||||
account: accountResponseSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const account = await server.services.pamAccount.create(req.body, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: account.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_ACCOUNT_CREATE,
|
||||
metadata: {
|
||||
resourceId: req.body.resourceId,
|
||||
resourceType,
|
||||
folderId: req.body.folderId,
|
||||
name: req.body.name,
|
||||
description: req.body.description
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { account };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/:accountId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Update PAM account",
|
||||
params: z.object({
|
||||
accountId: z.string().uuid()
|
||||
}),
|
||||
body: updateAccountSchema,
|
||||
response: {
|
||||
200: z.object({
|
||||
account: accountResponseSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const account = await server.services.pamAccount.updateById(
|
||||
{
|
||||
...req.body,
|
||||
accountId: req.params.accountId
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: account.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_ACCOUNT_UPDATE,
|
||||
metadata: {
|
||||
accountId: req.params.accountId,
|
||||
resourceId: account.resourceId,
|
||||
resourceType,
|
||||
name: req.body.name,
|
||||
description: req.body.description
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { account };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/:accountId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Delete PAM account",
|
||||
params: z.object({
|
||||
accountId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
account: accountResponseSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const account = await server.services.pamAccount.deleteById(req.params.accountId, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: account.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_ACCOUNT_DELETE,
|
||||
metadata: {
|
||||
accountId: req.params.accountId,
|
||||
accountName: account.name,
|
||||
resourceId: account.resourceId,
|
||||
resourceType
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { account };
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamFoldersSchema } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
|
||||
import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
// Use z.union([...]) when more resources are added
|
||||
const SanitizedAccountSchema = SanitizedPostgresAccountWithResourceSchema;
|
||||
|
||||
export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "List PAM accounts",
|
||||
querystring: z.object({
|
||||
projectId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
accounts: SanitizedAccountSchema.array(),
|
||||
folders: PamFoldersSchema.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const response = await server.services.pamAccount.list(req.query.projectId, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: req.query.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_ACCOUNT_LIST,
|
||||
metadata: {
|
||||
accountCount: response.accounts.length,
|
||||
folderCount: response.folders.length
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/access",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Access PAM account",
|
||||
body: z.object({
|
||||
accountId: z.string().uuid(),
|
||||
duration: z
|
||||
.string()
|
||||
.min(1)
|
||||
.transform((val, ctx) => {
|
||||
const parsedMs = ms(val);
|
||||
|
||||
if (typeof parsedMs !== "number" || parsedMs <= 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid duration format. Must be a positive duration (e.g., '1h', '30m', '2d')."
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return parsedMs;
|
||||
})
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
sessionId: z.string(),
|
||||
resourceType: z.nativeEnum(PamResource),
|
||||
relayClientCertificate: z.string(),
|
||||
relayClientPrivateKey: z.string(),
|
||||
relayServerCertificateChain: z.string(),
|
||||
gatewayClientCertificate: z.string(),
|
||||
gatewayClientPrivateKey: z.string(),
|
||||
gatewayServerCertificateChain: z.string(),
|
||||
relayHost: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
// To prevent type errors when accessing req.auth
|
||||
if (req.auth.authMode !== AuthMode.JWT) {
|
||||
throw new BadRequestError({ message: "You can only access PAM accounts using JWT auth tokens." });
|
||||
}
|
||||
|
||||
const response = await server.services.pamAccount.access(
|
||||
{
|
||||
actorEmail: req.auth.user.email ?? "",
|
||||
actorIp: req.realIp,
|
||||
actorName: `${req.auth.user.firstName ?? ""} ${req.auth.user.lastName ?? ""}`.trim(),
|
||||
actorUserAgent: req.auditLogInfo.userAgent ?? "",
|
||||
...req.body
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: response.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_ACCOUNT_ACCESS,
|
||||
metadata: {
|
||||
accountId: req.body.accountId,
|
||||
accountName: response.account.name,
|
||||
duration: req.body.duration ? new Date(req.body.duration).toISOString() : undefined
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
});
|
||||
};
|
||||
150
backend/src/ee/routes/v1/pam-folder-router.ts
Normal file
150
backend/src/ee/routes/v1/pam-folder-router.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamFoldersSchema } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { isValidFolderName } from "@app/lib/validator";
|
||||
import { writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerPamFolderRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Create PAM folder",
|
||||
body: z.object({
|
||||
projectId: z.string().uuid(),
|
||||
parentId: z.string().uuid().nullable().optional(),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((name) => isValidFolderName(name), {
|
||||
message: "Folder name can only contain alphanumeric characters, dashes, and underscores."
|
||||
}),
|
||||
description: z.string().trim().max(512).nullable().optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
folder: PamFoldersSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const folder = await server.services.pamFolder.createFolder(req.body, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: req.body.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_FOLDER_CREATE,
|
||||
metadata: {
|
||||
name: req.body.name,
|
||||
description: req.body.description,
|
||||
parentId: req.body.parentId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { folder };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/:folderId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Update PAM folder",
|
||||
params: z.object({
|
||||
folderId: z.string().uuid()
|
||||
}),
|
||||
body: z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.refine((name) => (name ? isValidFolderName(name) : true), {
|
||||
message: "Folder name can only contain alphanumeric characters, dashes, and underscores."
|
||||
}),
|
||||
description: z.string().trim().max(512).nullable().optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
folder: PamFoldersSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const folder = await server.services.pamFolder.updateFolder(
|
||||
{
|
||||
...req.body,
|
||||
id: req.params.folderId
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: folder.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_FOLDER_UPDATE,
|
||||
metadata: {
|
||||
folderId: req.params.folderId,
|
||||
name: req.body.name,
|
||||
description: req.body.description
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { folder };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/:folderId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Delete PAM folder",
|
||||
params: z.object({
|
||||
folderId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
folder: PamFoldersSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const folder = await server.services.pamFolder.deleteFolder(req.params.folderId, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: folder.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_FOLDER_DELETE,
|
||||
metadata: {
|
||||
folderName: folder.name,
|
||||
folderId: req.params.folderId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { folder };
|
||||
}
|
||||
});
|
||||
};
|
||||
20
backend/src/ee/routes/v1/pam-resource-routers/index.ts
Normal file
20
backend/src/ee/routes/v1/pam-resource-routers/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
|
||||
import {
|
||||
CreatePostgresResourceSchema,
|
||||
PostgresResourceSchema,
|
||||
UpdatePostgresResourceSchema
|
||||
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
|
||||
import { registerPamResourceEndpoints } from "./pam-resource-endpoints";
|
||||
|
||||
export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record<PamResource, (server: FastifyZodProvider) => Promise<void>> = {
|
||||
[PamResource.Postgres]: async (server: FastifyZodProvider) => {
|
||||
registerPamResourceEndpoints({
|
||||
server,
|
||||
resourceType: PamResource.Postgres,
|
||||
resourceResponseSchema: PostgresResourceSchema,
|
||||
createResourceSchema: CreatePostgresResourceSchema,
|
||||
updateResourceSchema: UpdatePostgresResourceSchema
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
|
||||
import { TPamResource } from "@app/ee/services/pam-resource/pam-resource-types";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerPamResourceEndpoints = <T extends TPamResource>({
|
||||
server,
|
||||
resourceType,
|
||||
createResourceSchema,
|
||||
updateResourceSchema,
|
||||
resourceResponseSchema
|
||||
}: {
|
||||
server: FastifyZodProvider;
|
||||
resourceType: PamResource;
|
||||
createResourceSchema: z.ZodType<{
|
||||
projectId: T["projectId"];
|
||||
connectionDetails: T["connectionDetails"];
|
||||
gatewayId: T["gatewayId"];
|
||||
name: T["name"];
|
||||
}>;
|
||||
updateResourceSchema: z.ZodType<{
|
||||
connectionDetails?: T["connectionDetails"];
|
||||
gatewayId?: T["gatewayId"];
|
||||
name?: T["name"];
|
||||
}>;
|
||||
resourceResponseSchema: z.ZodTypeAny;
|
||||
}) => {
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:resourceId",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Get PAM resource",
|
||||
params: z.object({
|
||||
resourceId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
resource: resourceResponseSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const resource = await server.services.pamResource.getById(req.params.resourceId, resourceType, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: resource.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_RESOURCE_GET,
|
||||
metadata: {
|
||||
resourceId: resource.id,
|
||||
resourceType: resource.resourceType,
|
||||
name: resource.name
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { resource };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Create PAM resource",
|
||||
body: createResourceSchema,
|
||||
response: {
|
||||
200: z.object({
|
||||
resource: resourceResponseSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const resource = await server.services.pamResource.create(
|
||||
{
|
||||
...req.body,
|
||||
resourceType
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: req.body.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_RESOURCE_CREATE,
|
||||
metadata: {
|
||||
resourceType,
|
||||
gatewayId: req.body.gatewayId,
|
||||
name: req.body.name
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { resource };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/:resourceId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Update PAM resource",
|
||||
params: z.object({
|
||||
resourceId: z.string().uuid()
|
||||
}),
|
||||
body: updateResourceSchema,
|
||||
response: {
|
||||
200: z.object({
|
||||
resource: resourceResponseSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const resource = await server.services.pamResource.updateById(
|
||||
{
|
||||
...req.body,
|
||||
resourceId: req.params.resourceId
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: resource.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_RESOURCE_UPDATE,
|
||||
metadata: {
|
||||
resourceId: req.params.resourceId,
|
||||
resourceType,
|
||||
gatewayId: req.body.gatewayId,
|
||||
name: req.body.name
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { resource };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/:resourceId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Delete PAM resource",
|
||||
params: z.object({
|
||||
resourceId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
resource: resourceResponseSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const resource = await server.services.pamResource.deleteById(req.params.resourceId, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: resource.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_RESOURCE_DELETE,
|
||||
metadata: {
|
||||
resourceId: req.params.resourceId,
|
||||
resourceType
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { resource };
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import {
|
||||
PostgresResourceListItemSchema,
|
||||
PostgresResourceSchema
|
||||
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
import { readLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
// Use z.union([...]) when more resources are added
|
||||
const ResourceSchema = PostgresResourceSchema;
|
||||
|
||||
const ResourceOptionsSchema = z.discriminatedUnion("resource", [PostgresResourceListItemSchema]);
|
||||
|
||||
export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/options",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "List PAM resource types",
|
||||
response: {
|
||||
200: z.object({
|
||||
resourceOptions: ResourceOptionsSchema.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: () => {
|
||||
const resourceOptions = server.services.pamResource.listResourceOptions();
|
||||
|
||||
return { resourceOptions };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "List PAM resources",
|
||||
querystring: z.object({
|
||||
projectId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
resources: ResourceSchema.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const response = await server.services.pamResource.list(req.query.projectId, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: req.query.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_RESOURCE_LIST,
|
||||
metadata: {
|
||||
count: response.resources.length
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
});
|
||||
};
|
||||
224
backend/src/ee/routes/v1/pam-session-router.ts
Normal file
224
backend/src/ee/routes/v1/pam-session-router.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamSessionsSchema } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
|
||||
import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "@app/ee/services/pam-session/pam-session-schemas";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
// Use z.union([]) once there's multiple
|
||||
const SessionCredentialsSchema = PostgresSessionCredentialsSchema;
|
||||
|
||||
export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
|
||||
// Meant to be hit solely by gateway identities
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:sessionId/credentials",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Get PAM session credentials and start session",
|
||||
params: z.object({
|
||||
sessionId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
credentials: SessionCredentialsSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { credentials, projectId, account } = await server.services.pamAccount.getSessionCredentials(
|
||||
req.params.sessionId,
|
||||
req.permission
|
||||
);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId,
|
||||
event: {
|
||||
type: EventType.PAM_SESSION_START,
|
||||
metadata: {
|
||||
sessionId: req.params.sessionId,
|
||||
accountName: account.name
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { credentials };
|
||||
}
|
||||
});
|
||||
|
||||
// Meant to be hit solely by gateway identities
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:sessionId/logs",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Update PAM session logs",
|
||||
params: z.object({
|
||||
sessionId: z.string().uuid()
|
||||
}),
|
||||
body: z.object({
|
||||
logs: PamSessionCommandLogSchema.array()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
session: PamSessionsSchema.omit({
|
||||
encryptedLogsBlob: true
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { session, projectId } = await server.services.pamSession.updateLogsById(
|
||||
{
|
||||
sessionId: req.params.sessionId,
|
||||
logs: req.body.logs
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId,
|
||||
event: {
|
||||
type: EventType.PAM_SESSION_LOGS_UPDATE,
|
||||
metadata: {
|
||||
sessionId: req.params.sessionId,
|
||||
accountName: session.accountName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { session };
|
||||
}
|
||||
});
|
||||
|
||||
// Meant to be hit solely by gateway identities
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:sessionId/end",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "End PAM session",
|
||||
params: z.object({
|
||||
sessionId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
session: PamSessionsSchema.omit({
|
||||
encryptedLogsBlob: true
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { session, projectId } = await server.services.pamSession.endSessionById(
|
||||
req.params.sessionId,
|
||||
req.permission
|
||||
);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId,
|
||||
event: {
|
||||
type: EventType.PAM_SESSION_END,
|
||||
metadata: {
|
||||
sessionId: req.params.sessionId,
|
||||
accountName: session.accountName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { session };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:sessionId",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Get PAM session",
|
||||
params: z.object({
|
||||
sessionId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
session: SanitizedSessionSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const response = await server.services.pamSession.getById(req.params.sessionId, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: response.session.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_SESSION_GET,
|
||||
metadata: {
|
||||
sessionId: req.params.sessionId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "List PAM sessions",
|
||||
querystring: z.object({
|
||||
projectId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
sessions: SanitizedSessionSchema.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const response = await server.services.pamSession.list(req.query.projectId, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: req.query.projectId,
|
||||
event: {
|
||||
type: EventType.PAM_SESSION_LIST,
|
||||
metadata: {
|
||||
count: response.sessions.length
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -286,7 +286,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
|
||||
entryPoint: z.string(),
|
||||
issuer: z.string(),
|
||||
cert: z.string(),
|
||||
lastUsed: z.date().nullable().optional()
|
||||
lastUsed: z.date().nullable().optional(),
|
||||
enableGroupSync: z.boolean().optional()
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -325,14 +326,15 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
|
||||
isActive: z.boolean().describe(SamlSso.CREATE_CONFIG.isActive),
|
||||
entryPoint: z.string().trim().describe(SamlSso.CREATE_CONFIG.entryPoint),
|
||||
issuer: z.string().trim().describe(SamlSso.CREATE_CONFIG.issuer),
|
||||
cert: z.string().trim().describe(SamlSso.CREATE_CONFIG.cert)
|
||||
cert: z.string().trim().describe(SamlSso.CREATE_CONFIG.cert),
|
||||
enableGroupSync: z.boolean().optional().describe(SamlSso.CREATE_CONFIG.enableGroupSync)
|
||||
}),
|
||||
response: {
|
||||
200: SanitizedSamlConfigSchema
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const { isActive, authProvider, issuer, entryPoint, cert } = req.body;
|
||||
const { isActive, authProvider, issuer, entryPoint, cert, enableGroupSync } = req.body;
|
||||
const { permission } = req;
|
||||
|
||||
return server.services.saml.createSamlCfg({
|
||||
@@ -341,6 +343,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
|
||||
issuer,
|
||||
entryPoint,
|
||||
idpCert: cert,
|
||||
enableGroupSync,
|
||||
actor: permission.type,
|
||||
actorId: permission.id,
|
||||
actorAuthMethod: permission.authMethod,
|
||||
@@ -372,7 +375,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
|
||||
isActive: z.boolean().describe(SamlSso.UPDATE_CONFIG.isActive),
|
||||
entryPoint: z.string().trim().describe(SamlSso.UPDATE_CONFIG.entryPoint),
|
||||
issuer: z.string().trim().describe(SamlSso.UPDATE_CONFIG.issuer),
|
||||
cert: z.string().trim().describe(SamlSso.UPDATE_CONFIG.cert)
|
||||
cert: z.string().trim().describe(SamlSso.UPDATE_CONFIG.cert),
|
||||
enableGroupSync: z.boolean().optional().describe(SamlSso.UPDATE_CONFIG.enableGroupSync)
|
||||
})
|
||||
.partial()
|
||||
.merge(z.object({ organizationId: z.string().trim().describe(SamlSso.UPDATE_CONFIG.organizationId) })),
|
||||
@@ -381,7 +385,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const { isActive, authProvider, issuer, entryPoint, cert } = req.body;
|
||||
const { isActive, authProvider, issuer, entryPoint, cert, enableGroupSync } = req.body;
|
||||
const { permission } = req;
|
||||
|
||||
return server.services.saml.updateSamlCfg({
|
||||
@@ -390,6 +394,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
|
||||
issuer,
|
||||
entryPoint,
|
||||
idpCert: cert,
|
||||
enableGroupSync,
|
||||
actor: permission.type,
|
||||
actorId: permission.id,
|
||||
actorAuthMethod: permission.authMethod,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import z from "zod";
|
||||
|
||||
import { GatewaysV2Schema } from "@app/db/schemas";
|
||||
import { zodBuffer } from "@app/lib/zod";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { slugSchema } from "@app/server/lib/schemas";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
@@ -130,4 +131,25 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => {
|
||||
return gateway;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/pam-session-key",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
response: {
|
||||
200: zodBuffer
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const pamSessionKey = await server.services.gatewayV2.getPamSessionKey({
|
||||
orgPermission: req.permission
|
||||
});
|
||||
|
||||
return pamSessionKey;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -500,7 +500,26 @@ export enum EventType {
|
||||
|
||||
DASHBOARD_LIST_SECRETS = "dashboard-list-secrets",
|
||||
DASHBOARD_GET_SECRET_VALUE = "dashboard-get-secret-value",
|
||||
DASHBOARD_GET_SECRET_VERSION_VALUE = "dashboard-get-secret-version-value"
|
||||
DASHBOARD_GET_SECRET_VERSION_VALUE = "dashboard-get-secret-version-value",
|
||||
|
||||
PAM_SESSION_START = "pam-session-start",
|
||||
PAM_SESSION_LOGS_UPDATE = "pam-session-logs-update",
|
||||
PAM_SESSION_END = "pam-session-end",
|
||||
PAM_SESSION_GET = "pam-session-get",
|
||||
PAM_SESSION_LIST = "pam-session-list",
|
||||
PAM_FOLDER_CREATE = "pam-folder-create",
|
||||
PAM_FOLDER_UPDATE = "pam-folder-update",
|
||||
PAM_FOLDER_DELETE = "pam-folder-delete",
|
||||
PAM_ACCOUNT_LIST = "pam-account-list",
|
||||
PAM_ACCOUNT_ACCESS = "pam-account-access",
|
||||
PAM_ACCOUNT_CREATE = "pam-account-create",
|
||||
PAM_ACCOUNT_UPDATE = "pam-account-update",
|
||||
PAM_ACCOUNT_DELETE = "pam-account-delete",
|
||||
PAM_RESOURCE_LIST = "pam-resource-list",
|
||||
PAM_RESOURCE_GET = "pam-resource-get",
|
||||
PAM_RESOURCE_CREATE = "pam-resource-create",
|
||||
PAM_RESOURCE_UPDATE = "pam-resource-update",
|
||||
PAM_RESOURCE_DELETE = "pam-resource-delete"
|
||||
}
|
||||
|
||||
export const filterableSecretEvents: EventType[] = [
|
||||
@@ -3687,6 +3706,162 @@ interface OrgRoleDeleteEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface PamSessionStartEvent {
|
||||
type: EventType.PAM_SESSION_START;
|
||||
metadata: {
|
||||
sessionId: string;
|
||||
accountName: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamSessionLogsUpdateEvent {
|
||||
type: EventType.PAM_SESSION_LOGS_UPDATE;
|
||||
metadata: {
|
||||
sessionId: string;
|
||||
accountName: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamSessionEndEvent {
|
||||
type: EventType.PAM_SESSION_END;
|
||||
metadata: {
|
||||
sessionId: string;
|
||||
accountName: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamSessionGetEvent {
|
||||
type: EventType.PAM_SESSION_GET;
|
||||
metadata: {
|
||||
sessionId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamSessionListEvent {
|
||||
type: EventType.PAM_SESSION_LIST;
|
||||
metadata: {
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamFolderCreateEvent {
|
||||
type: EventType.PAM_FOLDER_CREATE;
|
||||
metadata: {
|
||||
parentId?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamFolderUpdateEvent {
|
||||
type: EventType.PAM_FOLDER_UPDATE;
|
||||
metadata: {
|
||||
folderId: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamFolderDeleteEvent {
|
||||
type: EventType.PAM_FOLDER_DELETE;
|
||||
metadata: {
|
||||
folderId: string;
|
||||
folderName: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamAccountListEvent {
|
||||
type: EventType.PAM_ACCOUNT_LIST;
|
||||
metadata: {
|
||||
accountCount: number;
|
||||
folderCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamAccountAccessEvent {
|
||||
type: EventType.PAM_ACCOUNT_ACCESS;
|
||||
metadata: {
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
duration?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamAccountCreateEvent {
|
||||
type: EventType.PAM_ACCOUNT_CREATE;
|
||||
metadata: {
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
folderId?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamAccountUpdateEvent {
|
||||
type: EventType.PAM_ACCOUNT_UPDATE;
|
||||
metadata: {
|
||||
accountId: string;
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamAccountDeleteEvent {
|
||||
type: EventType.PAM_ACCOUNT_DELETE;
|
||||
metadata: {
|
||||
accountName: string;
|
||||
accountId: string;
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamResourceListEvent {
|
||||
type: EventType.PAM_RESOURCE_LIST;
|
||||
metadata: {
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamResourceGetEvent {
|
||||
type: EventType.PAM_RESOURCE_GET;
|
||||
metadata: {
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamResourceCreateEvent {
|
||||
type: EventType.PAM_RESOURCE_CREATE;
|
||||
metadata: {
|
||||
resourceType: string;
|
||||
gatewayId: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamResourceUpdateEvent {
|
||||
type: EventType.PAM_RESOURCE_UPDATE;
|
||||
metadata: {
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
gatewayId?: string;
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamResourceDeleteEvent {
|
||||
type: EventType.PAM_RESOURCE_DELETE;
|
||||
metadata: {
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type Event =
|
||||
| GetSecretsEvent
|
||||
| GetSecretEvent
|
||||
@@ -4020,4 +4195,22 @@ export type Event =
|
||||
| ProjectRoleDeleteEvent
|
||||
| OrgRoleCreateEvent
|
||||
| OrgRoleUpdateEvent
|
||||
| OrgRoleDeleteEvent;
|
||||
| OrgRoleDeleteEvent
|
||||
| PamSessionStartEvent
|
||||
| PamSessionLogsUpdateEvent
|
||||
| PamSessionEndEvent
|
||||
| PamSessionGetEvent
|
||||
| PamSessionListEvent
|
||||
| PamFolderCreateEvent
|
||||
| PamFolderUpdateEvent
|
||||
| PamFolderDeleteEvent
|
||||
| PamAccountListEvent
|
||||
| PamAccountAccessEvent
|
||||
| PamAccountCreateEvent
|
||||
| PamAccountUpdateEvent
|
||||
| PamAccountDeleteEvent
|
||||
| PamResourceListEvent
|
||||
| PamResourceGetEvent
|
||||
| PamResourceCreateEvent
|
||||
| PamResourceUpdateEvent
|
||||
| PamResourceDeleteEvent;
|
||||
|
||||
@@ -34,6 +34,7 @@ export const ElasticSearchProvider = (): TDynamicProviderFns => {
|
||||
|
||||
const $getClient = async (providerInputs: z.infer<typeof DynamicSecretElasticSearchSchema>) => {
|
||||
const connection = new ElasticSearchClient({
|
||||
requestTimeout: 30_000,
|
||||
node: {
|
||||
url: new URL(`${providerInputs.host}:${providerInputs.port}`),
|
||||
...(providerInputs.ca && {
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export const GATEWAY_ROUTING_INFO_OID = "1.3.6.1.4.1.12345.100.1";
|
||||
export const GATEWAY_ACTOR_OID = "1.3.6.1.4.1.12345.100.2";
|
||||
export const PAM_INFO_OID = "1.3.6.1.4.1.12345.100.3";
|
||||
|
||||
@@ -22,11 +22,12 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
|
||||
import { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { PamResource } from "../pam-resource/pam-resource-enums";
|
||||
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||
import { TPermissionServiceFactory } from "../permission/permission-service-types";
|
||||
import { TRelayDALFactory } from "../relay/relay-dal";
|
||||
import { TRelayServiceFactory } from "../relay/relay-service";
|
||||
import { GATEWAY_ACTOR_OID, GATEWAY_ROUTING_INFO_OID } from "./gateway-v2-constants";
|
||||
import { GATEWAY_ACTOR_OID, GATEWAY_ROUTING_INFO_OID, PAM_INFO_OID } from "./gateway-v2-constants";
|
||||
import { TGatewayV2DALFactory } from "./gateway-v2-dal";
|
||||
import { TOrgGatewayConfigV2DALFactory } from "./org-gateway-config-v2-dal";
|
||||
|
||||
@@ -414,6 +415,176 @@ export const gatewayV2ServiceFactory = ({
|
||||
};
|
||||
};
|
||||
|
||||
const getPAMConnectionDetails = async ({
|
||||
gatewayId,
|
||||
sessionId,
|
||||
duration,
|
||||
resourceType,
|
||||
host,
|
||||
port,
|
||||
actorMetadata
|
||||
}: {
|
||||
gatewayId: string;
|
||||
sessionId: string;
|
||||
resourceType: PamResource;
|
||||
duration?: number;
|
||||
host: string;
|
||||
port: number;
|
||||
actorMetadata: { id: string; type: ActorType; name: string };
|
||||
}) => {
|
||||
const gateway = await gatewayV2DAL.findById(gatewayId);
|
||||
if (!gateway) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orgGatewayConfig = await orgGatewayConfigV2DAL.findOne({ orgId: gateway.orgId });
|
||||
if (!orgGatewayConfig) {
|
||||
throw new NotFoundError({ message: `Gateway Config for org ${gateway.orgId} not found.` });
|
||||
}
|
||||
|
||||
if (!gateway.relayId) {
|
||||
throw new BadRequestError({
|
||||
message: "Gateway is not associated with a relay"
|
||||
});
|
||||
}
|
||||
|
||||
const orgLicensePlan = await licenseService.getPlan(orgGatewayConfig.orgId);
|
||||
if (!orgLicensePlan.gateway) {
|
||||
throw new BadRequestError({
|
||||
message: "Please upgrade your instance to Infisical's Enterprise plan to use gateways."
|
||||
});
|
||||
}
|
||||
|
||||
const { decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.Organization,
|
||||
orgId: orgGatewayConfig.orgId
|
||||
});
|
||||
|
||||
const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048);
|
||||
|
||||
const rootGatewayCaCert = new x509.X509Certificate(
|
||||
orgKmsDecryptor({
|
||||
cipherTextBlob: orgGatewayConfig.encryptedRootGatewayCaCertificate
|
||||
})
|
||||
);
|
||||
|
||||
const gatewayClientCaCert = new x509.X509Certificate(
|
||||
orgKmsDecryptor({
|
||||
cipherTextBlob: orgGatewayConfig.encryptedGatewayClientCaCertificate
|
||||
})
|
||||
);
|
||||
|
||||
const gatewayServerCaCert = new x509.X509Certificate(
|
||||
orgKmsDecryptor({
|
||||
cipherTextBlob: orgGatewayConfig.encryptedGatewayServerCaCertificate
|
||||
})
|
||||
);
|
||||
|
||||
const gatewayClientCaPrivateKey = orgKmsDecryptor({
|
||||
cipherTextBlob: orgGatewayConfig.encryptedGatewayClientCaPrivateKey
|
||||
});
|
||||
|
||||
const gatewayClientCaSkObj = crypto.nativeCrypto.createPrivateKey({
|
||||
key: gatewayClientCaPrivateKey,
|
||||
format: "der",
|
||||
type: "pkcs8"
|
||||
});
|
||||
|
||||
const importedGatewayClientCaPrivateKey = await crypto.nativeCrypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
gatewayClientCaSkObj.export({ format: "der", type: "pkcs8" }),
|
||||
alg,
|
||||
true,
|
||||
["sign"]
|
||||
);
|
||||
|
||||
const clientCertIssuedAt = new Date();
|
||||
const clientCertExpiration = new Date(new Date().getTime() + (duration ?? 5 * 60 * 1000));
|
||||
const clientKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]);
|
||||
const clientCertSerialNumber = createSerialNumber();
|
||||
|
||||
const routingInfo = {
|
||||
targetHost: host,
|
||||
targetPort: port
|
||||
};
|
||||
|
||||
const routingExtension = new x509.Extension(
|
||||
GATEWAY_ROUTING_INFO_OID,
|
||||
false,
|
||||
Buffer.from(JSON.stringify(routingInfo))
|
||||
);
|
||||
|
||||
const pamInfoExtension = new x509.Extension(
|
||||
PAM_INFO_OID,
|
||||
false,
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
sessionId,
|
||||
resourceType
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const actorExtension = new x509.Extension(
|
||||
GATEWAY_ACTOR_OID,
|
||||
false,
|
||||
Buffer.from(JSON.stringify({ type: actorMetadata.type, id: actorMetadata.id, name: actorMetadata.name }))
|
||||
);
|
||||
|
||||
const clientCert = await x509.X509CertificateGenerator.create({
|
||||
serialNumber: clientCertSerialNumber,
|
||||
subject: `O=${orgGatewayConfig.orgId},OU=gateway-client,CN=${actorMetadata.type}:${gatewayId}`,
|
||||
issuer: gatewayClientCaCert.subject,
|
||||
notAfter: clientCertExpiration,
|
||||
notBefore: clientCertIssuedAt,
|
||||
signingKey: importedGatewayClientCaPrivateKey,
|
||||
publicKey: clientKeys.publicKey,
|
||||
signingAlgorithm: alg,
|
||||
extensions: [
|
||||
new x509.BasicConstraintsExtension(false),
|
||||
await x509.AuthorityKeyIdentifierExtension.create(gatewayClientCaCert, false),
|
||||
await x509.SubjectKeyIdentifierExtension.create(clientKeys.publicKey),
|
||||
new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy
|
||||
new x509.KeyUsagesExtension(
|
||||
// eslint-disable-next-line no-bitwise
|
||||
x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] |
|
||||
x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT] |
|
||||
x509.KeyUsageFlags[CertKeyUsage.KEY_AGREEMENT],
|
||||
true
|
||||
),
|
||||
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.CLIENT_AUTH]], true),
|
||||
routingExtension,
|
||||
actorExtension,
|
||||
pamInfoExtension
|
||||
]
|
||||
});
|
||||
|
||||
const gatewayClientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey);
|
||||
|
||||
const relayCredentials = await relayService.getCredentialsForClient({
|
||||
relayId: gateway.relayId,
|
||||
orgId: gateway.orgId,
|
||||
orgName: gateway.orgName,
|
||||
gatewayId,
|
||||
gatewayName: gateway.name,
|
||||
duration
|
||||
});
|
||||
|
||||
return {
|
||||
relayHost: relayCredentials.relayHost,
|
||||
gateway: {
|
||||
clientCertificate: clientCert.toString("pem"),
|
||||
clientPrivateKey: gatewayClientCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(),
|
||||
serverCertificateChain: constructPemChainFromCerts([gatewayServerCaCert, rootGatewayCaCert])
|
||||
},
|
||||
relay: {
|
||||
clientCertificate: relayCredentials.clientCertificate,
|
||||
clientPrivateKey: relayCredentials.clientPrivateKey,
|
||||
serverCertificateChain: relayCredentials.serverCertificateChain
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const registerGateway = async ({
|
||||
orgId,
|
||||
actorId,
|
||||
@@ -645,14 +816,75 @@ export const gatewayV2ServiceFactory = ({
|
||||
OrgPermissionSubjects.Gateway
|
||||
);
|
||||
|
||||
return gatewayV2DAL.deleteById(gateway.id);
|
||||
try {
|
||||
return await gatewayV2DAL.deleteById(gateway.id);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof DatabaseError &&
|
||||
(err.error as { code: string })?.code === DatabaseErrorCode.ForeignKeyViolation
|
||||
) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to delete gateway because it is attached to active resources"
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const getPamSessionKey = async ({ orgPermission }: { orgPermission: OrgServiceActor }) => {
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
orgPermission.type,
|
||||
orgPermission.id,
|
||||
orgPermission.orgId,
|
||||
orgPermission.authMethod,
|
||||
orgPermission.orgId
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
OrgPermissionGatewayActions.CreateGateways,
|
||||
OrgPermissionSubjects.Gateway
|
||||
);
|
||||
|
||||
return gatewayV2DAL.transaction(async (tx) => {
|
||||
const gateway = await gatewayV2DAL.findOne(
|
||||
{
|
||||
identityId: orgPermission.id
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (!gateway) {
|
||||
throw new NotFoundError({ message: "Gateway not found" });
|
||||
}
|
||||
|
||||
const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.Organization,
|
||||
orgId: orgPermission.orgId
|
||||
});
|
||||
|
||||
if (gateway.encryptedPamSessionKey) {
|
||||
return decryptor({ cipherTextBlob: gateway.encryptedPamSessionKey });
|
||||
}
|
||||
|
||||
await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.GatewayPamSessionKey(gateway.id)]);
|
||||
|
||||
const newPamSessionKey = crypto.randomBytes(32);
|
||||
const { cipherTextBlob: encryptedPamSessionKey } = encryptor({ plainText: newPamSessionKey });
|
||||
|
||||
await gatewayV2DAL.updateById(gateway.id, { encryptedPamSessionKey }, tx);
|
||||
|
||||
return newPamSessionKey;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
listGateways,
|
||||
registerGateway,
|
||||
getPlatformConnectionDetailsByGatewayId,
|
||||
getPAMConnectionDetails,
|
||||
deleteGatewayById,
|
||||
heartbeat
|
||||
heartbeat,
|
||||
getPamSessionKey
|
||||
};
|
||||
};
|
||||
|
||||
@@ -66,7 +66,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
|
||||
enterpriseAppConnections: false,
|
||||
fips: false,
|
||||
eventSubscriptions: false,
|
||||
machineIdentityAuthTemplates: false
|
||||
machineIdentityAuthTemplates: false,
|
||||
pam: false
|
||||
});
|
||||
|
||||
export const setupLicenseRequestWithStore = (
|
||||
|
||||
@@ -461,17 +461,16 @@ export const licenseServiceFactory = ({
|
||||
};
|
||||
|
||||
const fetchPlanTableFromServer = async (customerId: string | null | undefined) => {
|
||||
if (!customerId) {
|
||||
throw new NotFoundError({ message: "Organization customer ID is required for plan table retrieval" });
|
||||
}
|
||||
|
||||
const baseUrl = `/api/license-server/v1/customers/${customerId}`;
|
||||
const baseUrl = `/api/license-server/v1/customers`;
|
||||
|
||||
if (instanceType === InstanceType.Cloud) {
|
||||
if (!customerId) {
|
||||
throw new NotFoundError({ message: "Organization customer ID is required for plan table retrieval" });
|
||||
}
|
||||
const { data } = await licenseServerCloudApi.request.get<{
|
||||
head: { name: string }[];
|
||||
rows: { name: string; allowed: boolean }[];
|
||||
}>(`${baseUrl}/cloud-plan/table`);
|
||||
}>(`${baseUrl}/${customerId}/cloud-plan/table`);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ export type TFeatureSet = {
|
||||
machineIdentityAuthTemplates: false;
|
||||
fips: false;
|
||||
eventSubscriptions: false;
|
||||
pam: false;
|
||||
};
|
||||
|
||||
export type TOrgPlansTableDTO = {
|
||||
|
||||
43
backend/src/ee/services/pam-account/pam-account-dal.ts
Normal file
43
backend/src/ee/services/pam-account/pam-account-dal.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName, TPamAccounts } from "@app/db/schemas";
|
||||
import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
export type TPamAccountDALFactory = ReturnType<typeof pamAccountDALFactory>;
|
||||
|
||||
type PamAccountFindFilter = Parameters<typeof buildFindFilter<TPamAccounts>>[0];
|
||||
|
||||
export const pamAccountDALFactory = (db: TDbClient) => {
|
||||
const orm = ormify(db, TableName.PamAccount);
|
||||
|
||||
const findWithResourceDetails = async (filter: PamAccountFindFilter, tx?: Knex) => {
|
||||
const query = (tx || db.replicaNode())(TableName.PamAccount)
|
||||
.leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`)
|
||||
.select(selectAllTableCols(TableName.PamAccount))
|
||||
.select(
|
||||
// resource
|
||||
db.ref("name").withSchema(TableName.PamResource).as("resourceName"),
|
||||
db.ref("resourceType").withSchema(TableName.PamResource)
|
||||
);
|
||||
|
||||
if (filter) {
|
||||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PamAccount, filter)));
|
||||
}
|
||||
|
||||
const accounts = await query;
|
||||
|
||||
return accounts.map(({ resourceId, resourceName, resourceType, ...account }) => ({
|
||||
...account,
|
||||
resourceId,
|
||||
resource: {
|
||||
id: resourceId,
|
||||
name: resourceName,
|
||||
resourceType
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
return { ...orm, findWithResourceDetails };
|
||||
};
|
||||
61
backend/src/ee/services/pam-account/pam-account-fns.ts
Normal file
61
backend/src/ee/services/pam-account/pam-account-fns.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
|
||||
import { TPamAccountCredentials } from "../pam-resource/pam-resource-types";
|
||||
|
||||
export const encryptAccountCredentials = async ({
|
||||
projectId,
|
||||
credentials,
|
||||
kmsService
|
||||
}: {
|
||||
projectId: string;
|
||||
credentials: TPamAccountCredentials;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
}) => {
|
||||
const { encryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({
|
||||
plainText: Buffer.from(JSON.stringify(credentials))
|
||||
});
|
||||
|
||||
return encryptedCredentialsBlob;
|
||||
};
|
||||
|
||||
export const decryptAccountCredentials = async ({
|
||||
projectId,
|
||||
encryptedCredentials,
|
||||
kmsService
|
||||
}: {
|
||||
projectId: string;
|
||||
encryptedCredentials: Buffer;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
}) => {
|
||||
const { decryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
const decryptedPlainTextBlob = decryptor({
|
||||
cipherTextBlob: encryptedCredentials
|
||||
});
|
||||
|
||||
return JSON.parse(decryptedPlainTextBlob.toString()) as TPamAccountCredentials;
|
||||
};
|
||||
|
||||
export const decryptAccount = async <T extends { encryptedCredentials: Buffer }>(
|
||||
account: T,
|
||||
projectId: string,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
): Promise<T & { credentials: TPamAccountCredentials }> => {
|
||||
return {
|
||||
...account,
|
||||
credentials: await decryptAccountCredentials({
|
||||
encryptedCredentials: account.encryptedCredentials,
|
||||
projectId,
|
||||
kmsService
|
||||
})
|
||||
} as T & { credentials: TPamAccountCredentials };
|
||||
};
|
||||
527
backend/src/ee/services/pam-account/pam-account-service.ts
Normal file
527
backend/src/ee/services/pam-account/pam-account-service.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType, TPamAccounts, TPamResources } from "@app/db/schemas";
|
||||
import { PAM_RESOURCE_FACTORY_MAP } from "@app/ee/services/pam-resource/pam-resource-factory";
|
||||
import { decryptResource, decryptResourceConnectionDetails } from "@app/ee/services/pam-resource/pam-resource-fns";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionPamAccountActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/ee/services/permission/project-permission";
|
||||
import { DatabaseErrorCode } from "@app/lib/error-codes";
|
||||
import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { TUserDALFactory } from "@app/services/user/user-dal";
|
||||
|
||||
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
|
||||
import { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { TPamFolderDALFactory } from "../pam-folder/pam-folder-dal";
|
||||
import { getFullPamFolderPath } from "../pam-folder/pam-folder-fns";
|
||||
import { TPamResourceDALFactory } from "../pam-resource/pam-resource-dal";
|
||||
import { PamResource } from "../pam-resource/pam-resource-enums";
|
||||
import { TPamAccountCredentials } from "../pam-resource/pam-resource-types";
|
||||
import { TPamSessionDALFactory } from "../pam-session/pam-session-dal";
|
||||
import { PamSessionStatus } from "../pam-session/pam-session-enums";
|
||||
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||
import { TPamAccountDALFactory } from "./pam-account-dal";
|
||||
import { decryptAccount, decryptAccountCredentials, encryptAccountCredentials } from "./pam-account-fns";
|
||||
import { TAccessAccountDTO, TCreateAccountDTO, TUpdateAccountDTO } from "./pam-account-types";
|
||||
|
||||
type TPamAccountServiceFactoryDep = {
|
||||
pamResourceDAL: TPamResourceDALFactory;
|
||||
pamSessionDAL: TPamSessionDALFactory;
|
||||
pamAccountDAL: TPamAccountDALFactory;
|
||||
pamFolderDAL: TPamFolderDALFactory;
|
||||
projectDAL: TProjectDALFactory;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
gatewayV2Service: Pick<
|
||||
TGatewayV2ServiceFactory,
|
||||
"getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId"
|
||||
>;
|
||||
userDAL: TUserDALFactory;
|
||||
};
|
||||
|
||||
export type TPamAccountServiceFactory = ReturnType<typeof pamAccountServiceFactory>;
|
||||
|
||||
export const pamAccountServiceFactory = ({
|
||||
pamResourceDAL,
|
||||
pamSessionDAL,
|
||||
pamAccountDAL,
|
||||
pamFolderDAL,
|
||||
projectDAL,
|
||||
userDAL,
|
||||
permissionService,
|
||||
licenseService,
|
||||
kmsService,
|
||||
gatewayV2Service
|
||||
}: TPamAccountServiceFactoryDep) => {
|
||||
const create = async (
|
||||
{ credentials, resourceId, name, description, folderId }: TCreateAccountDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
message: "PAM operation failed due to organization plan restrictions."
|
||||
});
|
||||
}
|
||||
|
||||
const resource = await pamResourceDAL.findById(resourceId);
|
||||
if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: resource.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
const accountPath = await getFullPamFolderPath({
|
||||
pamFolderDAL,
|
||||
folderId,
|
||||
projectId: resource.projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPamAccountActions.Create,
|
||||
subject(ProjectPermissionSub.PamAccounts, {
|
||||
resourceName: resource.name,
|
||||
accountName: name,
|
||||
accountPath
|
||||
})
|
||||
);
|
||||
|
||||
const connectionDetails = await decryptResourceConnectionDetails({
|
||||
projectId: resource.projectId,
|
||||
encryptedConnectionDetails: resource.encryptedConnectionDetails,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource](
|
||||
resource.resourceType as PamResource,
|
||||
connectionDetails,
|
||||
resource.gatewayId,
|
||||
gatewayV2Service
|
||||
);
|
||||
const validatedCredentials = await factory.validateAccountCredentials(credentials);
|
||||
|
||||
const encryptedCredentials = await encryptAccountCredentials({
|
||||
credentials: validatedCredentials,
|
||||
projectId: resource.projectId,
|
||||
kmsService
|
||||
});
|
||||
|
||||
try {
|
||||
const account = await pamAccountDAL.create({
|
||||
projectId: resource.projectId,
|
||||
resourceId: resource.id,
|
||||
encryptedCredentials,
|
||||
name,
|
||||
description,
|
||||
folderId
|
||||
});
|
||||
|
||||
return {
|
||||
...(await decryptAccount(account, resource.projectId, kmsService)),
|
||||
resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType }
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) {
|
||||
throw new BadRequestError({
|
||||
message: `Account with name '${name}' already exists for this path`
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const updateById = async (
|
||||
{ accountId, credentials, description, name }: TUpdateAccountDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
message: "PAM operation failed due to organization plan restrictions."
|
||||
});
|
||||
}
|
||||
|
||||
const account = await pamAccountDAL.findById(accountId);
|
||||
if (!account) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` });
|
||||
|
||||
const resource = await pamResourceDAL.findById(account.resourceId);
|
||||
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: account.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
const accountPath = await getFullPamFolderPath({
|
||||
pamFolderDAL,
|
||||
folderId: account.folderId,
|
||||
projectId: account.projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPamAccountActions.Edit,
|
||||
subject(ProjectPermissionSub.PamAccounts, {
|
||||
resourceName: resource.name,
|
||||
accountName: account.name,
|
||||
accountPath
|
||||
})
|
||||
);
|
||||
|
||||
const updateDoc: Partial<TPamAccounts> = {};
|
||||
|
||||
if (name !== undefined) {
|
||||
updateDoc.name = name;
|
||||
}
|
||||
|
||||
if (description !== undefined) {
|
||||
updateDoc.description = description;
|
||||
}
|
||||
|
||||
if (credentials !== undefined) {
|
||||
const connectionDetails = await decryptResourceConnectionDetails({
|
||||
projectId: account.projectId,
|
||||
encryptedConnectionDetails: resource.encryptedConnectionDetails,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource](
|
||||
resource.resourceType as PamResource,
|
||||
connectionDetails,
|
||||
resource.gatewayId,
|
||||
gatewayV2Service
|
||||
);
|
||||
|
||||
// Logic to prevent overwriting unedited censored values
|
||||
const finalCredentials = { ...credentials };
|
||||
if (credentials.password === "******") {
|
||||
const decryptedCredentials = await decryptAccountCredentials({
|
||||
encryptedCredentials: account.encryptedCredentials,
|
||||
projectId: account.projectId,
|
||||
kmsService
|
||||
});
|
||||
|
||||
finalCredentials.password = decryptedCredentials.password;
|
||||
}
|
||||
|
||||
const validatedCredentials = await factory.validateAccountCredentials(finalCredentials);
|
||||
const encryptedCredentials = await encryptAccountCredentials({
|
||||
credentials: validatedCredentials,
|
||||
projectId: account.projectId,
|
||||
kmsService
|
||||
});
|
||||
updateDoc.encryptedCredentials = encryptedCredentials;
|
||||
}
|
||||
|
||||
// If nothing was updated, return the fetched account
|
||||
if (Object.keys(updateDoc).length === 0) {
|
||||
return decryptAccount(account, account.projectId, kmsService);
|
||||
}
|
||||
|
||||
const updatedAccount = await pamAccountDAL.updateById(accountId, updateDoc);
|
||||
|
||||
return {
|
||||
...(await decryptAccount(updatedAccount, account.projectId, kmsService)),
|
||||
resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType }
|
||||
};
|
||||
};
|
||||
|
||||
const deleteById = async (id: string, actor: OrgServiceActor) => {
|
||||
const account = await pamAccountDAL.findById(id);
|
||||
if (!account) throw new NotFoundError({ message: `Account with ID '${id}' not found` });
|
||||
|
||||
const resource = await pamResourceDAL.findById(account.resourceId);
|
||||
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: account.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
const accountPath = await getFullPamFolderPath({
|
||||
pamFolderDAL,
|
||||
folderId: account.folderId,
|
||||
projectId: account.projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPamAccountActions.Delete,
|
||||
subject(ProjectPermissionSub.PamAccounts, {
|
||||
resourceName: resource.name,
|
||||
accountName: account.name,
|
||||
accountPath
|
||||
})
|
||||
);
|
||||
|
||||
const deletedAccount = await pamAccountDAL.deleteById(id);
|
||||
|
||||
return {
|
||||
...(await decryptAccount(deletedAccount, account.projectId, kmsService)),
|
||||
resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType }
|
||||
};
|
||||
};
|
||||
|
||||
const list = async (projectId: string, actor: OrgServiceActor) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
const accountsWithResourceDetails = await pamAccountDAL.findWithResourceDetails({ projectId });
|
||||
|
||||
const canReadFolders = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.PamFolders);
|
||||
|
||||
const folders = canReadFolders ? await pamFolderDAL.find({ projectId }) : [];
|
||||
|
||||
const decryptedAndPermittedAccounts: Array<
|
||||
TPamAccounts & {
|
||||
resource: Pick<TPamResources, "id" | "name" | "resourceType">;
|
||||
credentials: TPamAccountCredentials;
|
||||
}
|
||||
> = [];
|
||||
|
||||
for await (const account of accountsWithResourceDetails) {
|
||||
const accountPath = await getFullPamFolderPath({
|
||||
pamFolderDAL,
|
||||
folderId: account.folderId,
|
||||
projectId: account.projectId
|
||||
});
|
||||
|
||||
// Check permission for each individual account
|
||||
if (
|
||||
permission.can(
|
||||
ProjectPermissionPamAccountActions.Read,
|
||||
subject(ProjectPermissionSub.PamAccounts, {
|
||||
resourceName: account.resource.name,
|
||||
accountName: account.name,
|
||||
accountPath
|
||||
})
|
||||
)
|
||||
) {
|
||||
// Decrypt the account only if the user has permission to read it
|
||||
const decryptedAccount = await decryptAccount(account, account.projectId, kmsService);
|
||||
decryptedAndPermittedAccounts.push({
|
||||
...decryptedAccount,
|
||||
resource: {
|
||||
id: account.resource.id,
|
||||
name: account.resource.name,
|
||||
resourceType: account.resource.resourceType
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accounts: decryptedAndPermittedAccounts,
|
||||
folders
|
||||
};
|
||||
};
|
||||
|
||||
const access = async (
|
||||
{ accountId, actorEmail, actorIp, actorName, actorUserAgent, duration }: TAccessAccountDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
message: "PAM operation failed due to organization plan restrictions."
|
||||
});
|
||||
}
|
||||
|
||||
const account = await pamAccountDAL.findById(accountId);
|
||||
if (!account) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` });
|
||||
|
||||
const resource = await pamResourceDAL.findById(account.resourceId);
|
||||
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: account.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
const accountPath = await getFullPamFolderPath({
|
||||
pamFolderDAL,
|
||||
folderId: account.folderId,
|
||||
projectId: account.projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPamAccountActions.Access,
|
||||
subject(ProjectPermissionSub.PamAccounts, {
|
||||
resourceName: resource.name,
|
||||
accountName: account.name,
|
||||
accountPath
|
||||
})
|
||||
);
|
||||
|
||||
const session = await pamSessionDAL.create({
|
||||
accountName: account.name,
|
||||
actorEmail,
|
||||
actorIp,
|
||||
actorName,
|
||||
actorUserAgent,
|
||||
projectId: account.projectId,
|
||||
resourceName: resource.name,
|
||||
resourceType: resource.resourceType,
|
||||
status: PamSessionStatus.Starting,
|
||||
accountId: account.id,
|
||||
userId: actor.id,
|
||||
expiresAt: new Date(Date.now() + duration)
|
||||
});
|
||||
|
||||
const { connectionDetails, gatewayId, resourceType } = await decryptResource(
|
||||
resource,
|
||||
account.projectId,
|
||||
kmsService
|
||||
);
|
||||
|
||||
const user = await userDAL.findById(actor.id);
|
||||
if (!user) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` });
|
||||
|
||||
const gatewayConnectionDetails = await gatewayV2Service.getPAMConnectionDetails({
|
||||
gatewayId,
|
||||
duration,
|
||||
sessionId: session.id,
|
||||
resourceType: resource.resourceType as PamResource,
|
||||
host: connectionDetails.host,
|
||||
port: connectionDetails.port,
|
||||
actorMetadata: {
|
||||
id: actor.id,
|
||||
type: actor.type,
|
||||
name: user.email ?? ""
|
||||
}
|
||||
});
|
||||
|
||||
if (!gatewayConnectionDetails) {
|
||||
throw new NotFoundError({ message: `Gateway connection details for gateway '${gatewayId}' not found.` });
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
resourceType,
|
||||
relayClientCertificate: gatewayConnectionDetails.relay.clientCertificate,
|
||||
relayClientPrivateKey: gatewayConnectionDetails.relay.clientPrivateKey,
|
||||
relayServerCertificateChain: gatewayConnectionDetails.relay.serverCertificateChain,
|
||||
gatewayClientCertificate: gatewayConnectionDetails.gateway.clientCertificate,
|
||||
gatewayClientPrivateKey: gatewayConnectionDetails.gateway.clientPrivateKey,
|
||||
gatewayServerCertificateChain: gatewayConnectionDetails.gateway.serverCertificateChain,
|
||||
relayHost: gatewayConnectionDetails.relayHost,
|
||||
projectId: account.projectId,
|
||||
account
|
||||
};
|
||||
};
|
||||
|
||||
const getSessionCredentials = async (sessionId: string, actor: OrgServiceActor) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
message: "PAM operation failed due to organization plan restrictions."
|
||||
});
|
||||
}
|
||||
|
||||
// To be hit by gateways only
|
||||
if (actor.type !== ActorType.IDENTITY) {
|
||||
throw new ForbiddenRequestError({ message: "Only gateways can perform this action" });
|
||||
}
|
||||
|
||||
const session = await pamSessionDAL.findById(sessionId);
|
||||
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
|
||||
|
||||
const project = await projectDAL.findById(session.projectId);
|
||||
if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
project.orgId,
|
||||
actor.authMethod,
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
OrgPermissionGatewayActions.CreateGateways,
|
||||
OrgPermissionSubjects.Gateway
|
||||
);
|
||||
|
||||
if (!session.accountId) throw new NotFoundError({ message: "Session is missing accountId column" });
|
||||
|
||||
// Verify that the session has not ended
|
||||
if (session.endedAt || (session.expiresAt && session.expiresAt < new Date())) {
|
||||
throw new BadRequestError({ message: "Session has ended or expired" });
|
||||
}
|
||||
|
||||
// Verify that the session has not already had credentials fetched
|
||||
if (session.status !== PamSessionStatus.Starting) {
|
||||
throw new BadRequestError({ message: "Session has already been started" });
|
||||
}
|
||||
|
||||
const account = await pamAccountDAL.findById(session.accountId);
|
||||
if (!account) throw new NotFoundError({ message: `Account with ID '${session.accountId}' not found` });
|
||||
|
||||
const resource = await pamResourceDAL.findById(account.resourceId);
|
||||
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
|
||||
|
||||
if (resource.gatewayIdentityId !== actor.id) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Identity does not have access to fetch the PAM session credentials"
|
||||
});
|
||||
}
|
||||
|
||||
const decryptedAccount = await decryptAccount(account, session.projectId, kmsService);
|
||||
|
||||
const decryptedResource = await decryptResource(resource, session.projectId, kmsService);
|
||||
|
||||
// Mark session as started
|
||||
await pamSessionDAL.updateById(sessionId, {
|
||||
status: PamSessionStatus.Active,
|
||||
startedAt: new Date()
|
||||
});
|
||||
|
||||
return {
|
||||
credentials: {
|
||||
...decryptedResource.connectionDetails,
|
||||
...decryptedAccount.credentials
|
||||
},
|
||||
projectId: project.id,
|
||||
account
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
create,
|
||||
updateById,
|
||||
deleteById,
|
||||
list,
|
||||
access,
|
||||
getSessionCredentials
|
||||
};
|
||||
};
|
||||
17
backend/src/ee/services/pam-account/pam-account-types.ts
Normal file
17
backend/src/ee/services/pam-account/pam-account-types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { TPamAccount } from "../pam-resource/pam-resource-types";
|
||||
|
||||
// DTOs
|
||||
export type TCreateAccountDTO = Pick<TPamAccount, "name" | "description" | "credentials" | "folderId" | "resourceId">;
|
||||
|
||||
export type TUpdateAccountDTO = Partial<Omit<TCreateAccountDTO, "folderId" | "resourceId">> & {
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
export type TAccessAccountDTO = {
|
||||
accountId: string;
|
||||
actorEmail: string;
|
||||
actorIp: string;
|
||||
actorName: string;
|
||||
actorUserAgent: string;
|
||||
duration: number;
|
||||
};
|
||||
9
backend/src/ee/services/pam-folder/pam-folder-dal.ts
Normal file
9
backend/src/ee/services/pam-folder/pam-folder-dal.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TPamFolderDALFactory = ReturnType<typeof pamFolderDALFactory>;
|
||||
export const pamFolderDALFactory = (db: TDbClient) => {
|
||||
const orm = ormify(db, TableName.PamFolder);
|
||||
return { ...orm };
|
||||
};
|
||||
33
backend/src/ee/services/pam-folder/pam-folder-fns.ts
Normal file
33
backend/src/ee/services/pam-folder/pam-folder-fns.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { TPamFolderDALFactory } from "./pam-folder-dal";
|
||||
|
||||
type GetFullFolderPath = {
|
||||
pamFolderDAL: Pick<TPamFolderDALFactory, "find">;
|
||||
folderId?: string | null;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export const getFullPamFolderPath = async ({
|
||||
pamFolderDAL,
|
||||
folderId,
|
||||
projectId
|
||||
}: GetFullFolderPath): Promise<string> => {
|
||||
if (!folderId) return "/";
|
||||
|
||||
const folders = await pamFolderDAL.find({ projectId });
|
||||
const folderMap = new Map(folders.map((folder) => [folder.id, folder]));
|
||||
|
||||
if (!folderMap.has(folderId)) return "";
|
||||
|
||||
const path: string[] = [];
|
||||
let currentFolderId: string | null | undefined = folderId;
|
||||
|
||||
while (currentFolderId) {
|
||||
const folder = folderMap.get(currentFolderId);
|
||||
if (!folder) break;
|
||||
|
||||
path.unshift(folder.name);
|
||||
currentFolderId = folder.parentId;
|
||||
}
|
||||
|
||||
return `/${path.join("/")}`;
|
||||
};
|
||||
146
backend/src/ee/services/pam-folder/pam-folder-service.ts
Normal file
146
backend/src/ee/services/pam-folder/pam-folder-service.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType, TPamFolders } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { DatabaseErrorCode } from "@app/lib/error-codes";
|
||||
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
|
||||
import { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { TPamFolderDALFactory } from "./pam-folder-dal";
|
||||
import { TCreateFolderDTO, TUpdateFolderDTO } from "./pam-folder-types";
|
||||
|
||||
type TPamFolderServiceFactoryDep = {
|
||||
pamFolderDAL: TPamFolderDALFactory;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
};
|
||||
|
||||
export type TPamFolderServiceFactory = ReturnType<typeof pamFolderServiceFactory>;
|
||||
|
||||
export const pamFolderServiceFactory = ({
|
||||
pamFolderDAL,
|
||||
permissionService,
|
||||
licenseService
|
||||
}: TPamFolderServiceFactoryDep) => {
|
||||
const createFolder = async ({ name, description, parentId, projectId }: TCreateFolderDTO, actor: OrgServiceActor) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
message: "PAM operation failed due to organization plan restrictions."
|
||||
});
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PamFolders);
|
||||
|
||||
if (parentId) {
|
||||
if (!(await pamFolderDAL.findOne({ id: parentId, projectId }))) {
|
||||
throw new NotFoundError({
|
||||
message: `Parent folder '${parentId}' not found for project '${projectId}'`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const folder = await pamFolderDAL.create({
|
||||
name,
|
||||
description: description ?? null,
|
||||
parentId: parentId || null,
|
||||
projectId
|
||||
});
|
||||
|
||||
return folder;
|
||||
} catch (err) {
|
||||
if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) {
|
||||
throw new BadRequestError({
|
||||
message: `Folder with name '${name}' already exists for this path`
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const updateFolder = async ({ id, name, description }: TUpdateFolderDTO, actor: OrgServiceActor) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
message: "PAM operation failed due to organization plan restrictions."
|
||||
});
|
||||
}
|
||||
|
||||
const folder = await pamFolderDAL.findById(id);
|
||||
if (!folder) throw new NotFoundError({ message: `Folder with ID '${id}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: folder.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PamFolders);
|
||||
|
||||
const updateDoc: Partial<TPamFolders> = {};
|
||||
|
||||
if (name !== undefined) {
|
||||
updateDoc.name = name;
|
||||
}
|
||||
|
||||
if (description !== undefined) {
|
||||
updateDoc.description = description;
|
||||
}
|
||||
|
||||
if (Object.keys(updateDoc).length === 0) {
|
||||
return folder;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedFolder = await pamFolderDAL.updateById(id, updateDoc);
|
||||
|
||||
return updatedFolder;
|
||||
} catch (err) {
|
||||
if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) {
|
||||
throw new BadRequestError({
|
||||
message: `Folder with name '${name}' already exists for this path`
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFolder = async (id: string, actor: OrgServiceActor) => {
|
||||
const folder = await pamFolderDAL.findById(id);
|
||||
if (!folder) throw new NotFoundError({ message: `Folder with ID '${id}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: folder.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PamFolders);
|
||||
|
||||
const deletedFolder = await pamFolderDAL.deleteById(id);
|
||||
|
||||
return deletedFolder;
|
||||
};
|
||||
|
||||
return { createFolder, updateFolder, deleteFolder };
|
||||
};
|
||||
13
backend/src/ee/services/pam-folder/pam-folder-types.ts
Normal file
13
backend/src/ee/services/pam-folder/pam-folder-types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// DTOs
|
||||
export interface TCreateFolderDTO {
|
||||
projectId: string;
|
||||
parentId?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface TUpdateFolderDTO {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
}
|
||||
24
backend/src/ee/services/pam-resource/pam-resource-dal.ts
Normal file
24
backend/src/ee/services/pam-resource/pam-resource-dal.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
export type TPamResourceDALFactory = ReturnType<typeof pamResourceDALFactory>;
|
||||
export const pamResourceDALFactory = (db: TDbClient) => {
|
||||
const orm = ormify(db, TableName.PamResource);
|
||||
|
||||
const findById = async (id: string, tx?: Knex) => {
|
||||
const doc = await (tx || db.replicaNode())(TableName.PamResource)
|
||||
.join(TableName.GatewayV2, `${TableName.PamResource}.gatewayId`, `${TableName.GatewayV2}.id`)
|
||||
.select(selectAllTableCols(TableName.PamResource))
|
||||
.select(db.ref("name").withSchema(TableName.GatewayV2).as("gatewayName"))
|
||||
.select(db.ref("identityId").withSchema(TableName.GatewayV2).as("gatewayIdentityId"))
|
||||
.where(`${TableName.PamResource}.id`, id)
|
||||
.first();
|
||||
|
||||
return doc;
|
||||
};
|
||||
|
||||
return { ...orm, findById };
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum PamResource {
|
||||
Postgres = "postgres"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PamResource } from "./pam-resource-enums";
|
||||
import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types";
|
||||
import { sqlResourceFactory } from "./shared/sql/sql-resource-factory";
|
||||
|
||||
type TPamResourceFactoryImplementation = TPamResourceFactory<TPamResourceConnectionDetails, TPamAccountCredentials>;
|
||||
|
||||
export const PAM_RESOURCE_FACTORY_MAP: Record<PamResource, TPamResourceFactoryImplementation> = {
|
||||
[PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation
|
||||
};
|
||||
68
backend/src/ee/services/pam-resource/pam-resource-fns.ts
Normal file
68
backend/src/ee/services/pam-resource/pam-resource-fns.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { TPamResources } from "@app/db/schemas";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
|
||||
import { TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types";
|
||||
import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns";
|
||||
|
||||
export const listResourceOptions = () => {
|
||||
return [getPostgresResourceListItem()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
};
|
||||
|
||||
// Resource
|
||||
export const encryptResourceConnectionDetails = async ({
|
||||
projectId,
|
||||
connectionDetails,
|
||||
kmsService
|
||||
}: {
|
||||
projectId: string;
|
||||
connectionDetails: TPamResourceConnectionDetails;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
}) => {
|
||||
const { encryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
const { cipherTextBlob: encryptedConnectionDetailsBlob } = encryptor({
|
||||
plainText: Buffer.from(JSON.stringify(connectionDetails))
|
||||
});
|
||||
|
||||
return encryptedConnectionDetailsBlob;
|
||||
};
|
||||
|
||||
export const decryptResourceConnectionDetails = async ({
|
||||
projectId,
|
||||
encryptedConnectionDetails,
|
||||
kmsService
|
||||
}: {
|
||||
projectId: string;
|
||||
encryptedConnectionDetails: Buffer;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
}) => {
|
||||
const { decryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
const decryptedPlainTextBlob = decryptor({
|
||||
cipherTextBlob: encryptedConnectionDetails
|
||||
});
|
||||
|
||||
return JSON.parse(decryptedPlainTextBlob.toString()) as TPamResourceConnectionDetails;
|
||||
};
|
||||
|
||||
export const decryptResource = async (
|
||||
resource: TPamResources,
|
||||
projectId: string,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
) => {
|
||||
return {
|
||||
...resource,
|
||||
connectionDetails: await decryptResourceConnectionDetails({
|
||||
encryptedConnectionDetails: resource.encryptedConnectionDetails,
|
||||
projectId,
|
||||
kmsService
|
||||
})
|
||||
} as TPamResource;
|
||||
};
|
||||
46
backend/src/ee/services/pam-resource/pam-resource-schemas.ts
Normal file
46
backend/src/ee/services/pam-resource/pam-resource-schemas.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamAccountsSchema, PamResourcesSchema } from "@app/db/schemas";
|
||||
import { slugSchema } from "@app/server/lib/schemas";
|
||||
|
||||
// Resources
|
||||
export const BasePamResourceSchema = PamResourcesSchema.omit({
|
||||
encryptedConnectionDetails: true,
|
||||
resourceType: true
|
||||
});
|
||||
|
||||
export const BaseCreatePamResourceSchema = z.object({
|
||||
projectId: z.string().uuid(),
|
||||
gatewayId: z.string().uuid(),
|
||||
name: slugSchema({ field: "name" })
|
||||
});
|
||||
|
||||
export const BaseUpdatePamResourceSchema = z.object({
|
||||
gatewayId: z.string().uuid().optional(),
|
||||
name: slugSchema({ field: "name" }).optional()
|
||||
});
|
||||
|
||||
// Accounts
|
||||
export const BasePamAccountSchema = PamAccountsSchema.omit({
|
||||
encryptedCredentials: true
|
||||
});
|
||||
|
||||
export const BasePamAccountSchemaWithResource = BasePamAccountSchema.extend({
|
||||
resource: PamResourcesSchema.pick({
|
||||
id: true,
|
||||
name: true,
|
||||
resourceType: true
|
||||
})
|
||||
});
|
||||
|
||||
export const BaseCreatePamAccountSchema = z.object({
|
||||
resourceId: z.string().uuid(),
|
||||
folderId: z.string().uuid().optional(),
|
||||
name: slugSchema({ field: "name" }),
|
||||
description: z.string().max(512).nullable().optional()
|
||||
});
|
||||
|
||||
export const BaseUpdatePamAccountSchema = z.object({
|
||||
name: slugSchema({ field: "name" }).optional(),
|
||||
description: z.string().max(512).nullable().optional()
|
||||
});
|
||||
222
backend/src/ee/services/pam-resource/pam-resource-service.ts
Normal file
222
backend/src/ee/services/pam-resource/pam-resource-service.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType, TPamResources } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { DatabaseErrorCode } from "@app/lib/error-codes";
|
||||
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
|
||||
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
|
||||
import { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { TPamResourceDALFactory } from "./pam-resource-dal";
|
||||
import { PamResource } from "./pam-resource-enums";
|
||||
import { PAM_RESOURCE_FACTORY_MAP } from "./pam-resource-factory";
|
||||
import { decryptResource, encryptResourceConnectionDetails, listResourceOptions } from "./pam-resource-fns";
|
||||
import { TCreateResourceDTO, TUpdateResourceDTO } from "./pam-resource-types";
|
||||
|
||||
type TPamResourceServiceFactoryDep = {
|
||||
pamResourceDAL: TPamResourceDALFactory;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
gatewayV2Service: Pick<
|
||||
TGatewayV2ServiceFactory,
|
||||
"getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId"
|
||||
>;
|
||||
};
|
||||
|
||||
export type TPamResourceServiceFactory = ReturnType<typeof pamResourceServiceFactory>;
|
||||
|
||||
export const pamResourceServiceFactory = ({
|
||||
pamResourceDAL,
|
||||
permissionService,
|
||||
licenseService,
|
||||
kmsService,
|
||||
gatewayV2Service
|
||||
}: TPamResourceServiceFactoryDep) => {
|
||||
const getById = async (id: string, resourceType: PamResource, actor: OrgServiceActor) => {
|
||||
const resource = await pamResourceDAL.findById(id);
|
||||
if (!resource) throw new NotFoundError({ message: `Resource with ID '${id}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: resource.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources);
|
||||
|
||||
if (resource.resourceType !== resourceType) {
|
||||
throw new BadRequestError({
|
||||
message: `Resource with ID '${id}' is not of type '${resourceType}'`
|
||||
});
|
||||
}
|
||||
|
||||
return decryptResource(resource, resource.projectId, kmsService);
|
||||
};
|
||||
|
||||
const create = async (
|
||||
{ resourceType, connectionDetails, gatewayId, name, projectId }: TCreateResourceDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
message: "PAM operation failed due to organization plan restrictions."
|
||||
});
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PamResources);
|
||||
|
||||
const factory = PAM_RESOURCE_FACTORY_MAP[resourceType](
|
||||
resourceType,
|
||||
connectionDetails,
|
||||
gatewayId,
|
||||
gatewayV2Service
|
||||
);
|
||||
const validatedConnectionDetails = await factory.validateConnection();
|
||||
|
||||
const encryptedConnectionDetails = await encryptResourceConnectionDetails({
|
||||
connectionDetails: validatedConnectionDetails,
|
||||
projectId,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const resource = await pamResourceDAL.create({
|
||||
resourceType,
|
||||
encryptedConnectionDetails,
|
||||
gatewayId,
|
||||
name,
|
||||
projectId
|
||||
});
|
||||
|
||||
return decryptResource(resource, projectId, kmsService);
|
||||
};
|
||||
|
||||
const updateById = async ({ connectionDetails, resourceId, name }: TUpdateResourceDTO, actor: OrgServiceActor) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
message: "PAM operation failed due to organization plan restrictions."
|
||||
});
|
||||
}
|
||||
|
||||
const resource = await pamResourceDAL.findById(resourceId);
|
||||
if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: resource.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PamResources);
|
||||
|
||||
const updateDoc: Partial<TPamResources> = {};
|
||||
|
||||
if (name !== undefined) {
|
||||
updateDoc.name = name;
|
||||
}
|
||||
|
||||
if (connectionDetails !== undefined) {
|
||||
const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource](
|
||||
resource.resourceType as PamResource,
|
||||
connectionDetails,
|
||||
resource.gatewayId,
|
||||
gatewayV2Service
|
||||
);
|
||||
const validatedConnectionDetails = await factory.validateConnection();
|
||||
const encryptedConnectionDetails = await encryptResourceConnectionDetails({
|
||||
connectionDetails: validatedConnectionDetails,
|
||||
projectId: resource.projectId,
|
||||
kmsService
|
||||
});
|
||||
updateDoc.encryptedConnectionDetails = encryptedConnectionDetails;
|
||||
}
|
||||
|
||||
// If nothing was updated, return the fetched resource
|
||||
if (Object.keys(updateDoc).length === 0) {
|
||||
return decryptResource(resource, resource.projectId, kmsService);
|
||||
}
|
||||
|
||||
const updatedResource = await pamResourceDAL.updateById(resourceId, updateDoc);
|
||||
|
||||
return decryptResource(updatedResource, resource.projectId, kmsService);
|
||||
};
|
||||
|
||||
const deleteById = async (id: string, actor: OrgServiceActor) => {
|
||||
const resource = await pamResourceDAL.findById(id);
|
||||
if (!resource) throw new NotFoundError({ message: `Resource with ID '${id}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: resource.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PamResources);
|
||||
|
||||
try {
|
||||
const deletedResource = await pamResourceDAL.deleteById(id);
|
||||
return await decryptResource(deletedResource, resource.projectId, kmsService);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof DatabaseError &&
|
||||
(err.error as { code: string })?.code === DatabaseErrorCode.ForeignKeyViolation
|
||||
) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to delete resource because it is attached to active PAM accounts"
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const list = async (projectId: string, actor: OrgServiceActor) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources);
|
||||
|
||||
const resources = await pamResourceDAL.find({ projectId });
|
||||
|
||||
return {
|
||||
resources: await Promise.all(resources.map((resource) => decryptResource(resource, projectId, kmsService)))
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
getById,
|
||||
create,
|
||||
updateById,
|
||||
deleteById,
|
||||
list,
|
||||
listResourceOptions
|
||||
};
|
||||
};
|
||||
42
backend/src/ee/services/pam-resource/pam-resource-types.ts
Normal file
42
backend/src/ee/services/pam-resource/pam-resource-types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
|
||||
import { PamResource } from "./pam-resource-enums";
|
||||
import {
|
||||
TPostgresAccount,
|
||||
TPostgresAccountCredentials,
|
||||
TPostgresResource,
|
||||
TPostgresResourceConnectionDetails
|
||||
} from "./postgres/postgres-resource-types";
|
||||
|
||||
// Resource types
|
||||
export type TPamResource = TPostgresResource;
|
||||
export type TPamResourceConnectionDetails = TPostgresResourceConnectionDetails;
|
||||
|
||||
// Account types
|
||||
export type TPamAccount = TPostgresAccount;
|
||||
export type TPamAccountCredentials = TPostgresAccountCredentials;
|
||||
|
||||
// Resource DTOs
|
||||
export type TCreateResourceDTO = Pick<
|
||||
TPamResource,
|
||||
"name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId"
|
||||
>;
|
||||
|
||||
export type TUpdateResourceDTO = Partial<Omit<TCreateResourceDTO, "resourceType" | "projectId">> & {
|
||||
resourceId: string;
|
||||
};
|
||||
|
||||
// Resource factory
|
||||
export type TPamResourceFactoryValidateConnection<T extends TPamResourceConnectionDetails> = () => Promise<T>;
|
||||
export type TPamResourceFactoryValidateAccountCredentials<C extends TPamAccountCredentials> = (
|
||||
credentials: C
|
||||
) => Promise<C>;
|
||||
|
||||
export type TPamResourceFactory<T extends TPamResourceConnectionDetails, C extends TPamAccountCredentials> = (
|
||||
resourceType: PamResource,
|
||||
connectionDetails: T,
|
||||
gatewayId: string,
|
||||
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">
|
||||
) => {
|
||||
validateConnection: TPamResourceFactoryValidateConnection<T>;
|
||||
validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<C>;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PostgresResourceListItemSchema } from "./postgres-resource-schemas";
|
||||
|
||||
export const getPostgresResourceListItem = () => {
|
||||
return {
|
||||
name: PostgresResourceListItemSchema.shape.name.value,
|
||||
resource: PostgresResourceListItemSchema.shape.resource.value
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamResource } from "../pam-resource-enums";
|
||||
import {
|
||||
BaseCreatePamAccountSchema,
|
||||
BaseCreatePamResourceSchema,
|
||||
BasePamAccountSchema,
|
||||
BasePamAccountSchemaWithResource,
|
||||
BasePamResourceSchema,
|
||||
BaseUpdatePamAccountSchema,
|
||||
BaseUpdatePamResourceSchema
|
||||
} from "../pam-resource-schemas";
|
||||
import {
|
||||
BaseSqlAccountCredentialsSchema,
|
||||
BaseSqlResourceConnectionDetailsSchema
|
||||
} from "../shared/sql/sql-resource-schemas";
|
||||
|
||||
// Resources
|
||||
export const PostgresResourceConnectionDetailsSchema = BaseSqlResourceConnectionDetailsSchema;
|
||||
|
||||
const BasePostgresResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.Postgres) });
|
||||
|
||||
export const PostgresResourceSchema = BasePostgresResourceSchema.extend({
|
||||
connectionDetails: PostgresResourceConnectionDetailsSchema
|
||||
});
|
||||
|
||||
export const PostgresResourceListItemSchema = z.object({
|
||||
name: z.literal("PostgreSQL"),
|
||||
resource: z.literal(PamResource.Postgres)
|
||||
});
|
||||
|
||||
export const CreatePostgresResourceSchema = BaseCreatePamResourceSchema.extend({
|
||||
connectionDetails: PostgresResourceConnectionDetailsSchema
|
||||
});
|
||||
|
||||
export const UpdatePostgresResourceSchema = BaseUpdatePamResourceSchema.extend({
|
||||
connectionDetails: PostgresResourceConnectionDetailsSchema.optional()
|
||||
});
|
||||
|
||||
// Accounts
|
||||
export const PostgresAccountCredentialsSchema = BaseSqlAccountCredentialsSchema;
|
||||
|
||||
export const PostgresAccountSchema = BasePamAccountSchema.extend({
|
||||
credentials: PostgresAccountCredentialsSchema
|
||||
});
|
||||
|
||||
export const CreatePostgresAccountSchema = BaseCreatePamAccountSchema.extend({
|
||||
credentials: PostgresAccountCredentialsSchema
|
||||
});
|
||||
|
||||
export const UpdatePostgresAccountSchema = BaseUpdatePamAccountSchema.extend({
|
||||
credentials: PostgresAccountCredentialsSchema.optional()
|
||||
});
|
||||
|
||||
export const SanitizedPostgresAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({
|
||||
credentials: PostgresAccountCredentialsSchema.pick({
|
||||
username: true
|
||||
})
|
||||
});
|
||||
|
||||
// Sessions
|
||||
export const PostgresSessionCredentialsSchema = PostgresResourceConnectionDetailsSchema.and(
|
||||
PostgresAccountCredentialsSchema
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
PostgresAccountCredentialsSchema,
|
||||
PostgresAccountSchema,
|
||||
PostgresResourceConnectionDetailsSchema,
|
||||
PostgresResourceSchema
|
||||
} from "./postgres-resource-schemas";
|
||||
|
||||
// Resources
|
||||
export type TPostgresResource = z.infer<typeof PostgresResourceSchema>;
|
||||
export type TPostgresResourceConnectionDetails = z.infer<typeof PostgresResourceConnectionDetailsSchema>;
|
||||
|
||||
// Accounts
|
||||
export type TPostgresAccount = z.infer<typeof PostgresAccountSchema>;
|
||||
export type TPostgresAccountCredentials = z.infer<typeof PostgresAccountCredentialsSchema>;
|
||||
@@ -0,0 +1,183 @@
|
||||
import knex, { Knex } from "knex";
|
||||
import tls, { PeerCertificate } from "tls";
|
||||
|
||||
import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns";
|
||||
import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { GatewayProxyProtocol } from "@app/lib/gateway";
|
||||
import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2";
|
||||
|
||||
import { PamResource } from "../../pam-resource-enums";
|
||||
import { TPamResourceFactory, TPamResourceFactoryValidateAccountCredentials } from "../../pam-resource-types";
|
||||
import { TSqlAccountCredentials, TSqlResourceConnectionDetails } from "./sql-resource-types";
|
||||
|
||||
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
|
||||
|
||||
const TEST_CONNECTION_USERNAME = "infisical-gateway-connection-test";
|
||||
const TEST_CONNECTION_PASSWORD = "infisical-gateway-connection-test-password";
|
||||
|
||||
const SQL_CONNECTION_CLIENT_MAP = {
|
||||
[PamResource.Postgres]: "pg"
|
||||
};
|
||||
|
||||
const getConnectionConfig = (
|
||||
resourceType: PamResource,
|
||||
{ host, sslEnabled, sslRejectUnauthorized, sslCertificate }: TSqlResourceConnectionDetails
|
||||
) => {
|
||||
switch (resourceType) {
|
||||
case PamResource.Postgres: {
|
||||
return {
|
||||
ssl: sslEnabled
|
||||
? {
|
||||
rejectUnauthorized: sslRejectUnauthorized,
|
||||
ca: sslCertificate,
|
||||
servername: host,
|
||||
// When using proxy, we need to bypass hostname validation since we connect to localhost
|
||||
// but validate the certificate against the actual hostname
|
||||
checkServerIdentity: (hostname: string, cert: PeerCertificate) => {
|
||||
return tls.checkServerIdentity(host, cert);
|
||||
}
|
||||
}
|
||||
: false
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new BadRequestError({
|
||||
message: `Unhandled SQL Resource Connection Config: ${resourceType as PamResource}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const executeWithGateway = async <T>(
|
||||
config: {
|
||||
connectionDetails: TSqlResourceConnectionDetails;
|
||||
resourceType: PamResource;
|
||||
gatewayId: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
},
|
||||
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">,
|
||||
operation: (client: Knex) => Promise<T>
|
||||
): Promise<T> => {
|
||||
const { connectionDetails, resourceType, gatewayId, username, password } = config;
|
||||
|
||||
const [targetHost] = await verifyHostInputValidity(connectionDetails.host, true);
|
||||
const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({
|
||||
gatewayId,
|
||||
targetHost,
|
||||
targetPort: connectionDetails.port
|
||||
});
|
||||
|
||||
if (!platformConnectionDetails) {
|
||||
throw new BadRequestError({ message: "Unable to connect to gateway, no platform connection details found" });
|
||||
}
|
||||
|
||||
return withGatewayV2Proxy(
|
||||
async (proxyPort) => {
|
||||
const client = knex({
|
||||
client: SQL_CONNECTION_CLIENT_MAP[resourceType],
|
||||
connection: {
|
||||
database: connectionDetails.database,
|
||||
port: proxyPort,
|
||||
host: "localhost",
|
||||
user: username ?? TEST_CONNECTION_USERNAME, // Use provided username or fallback
|
||||
password: password ?? TEST_CONNECTION_PASSWORD, // Use provided password or fallback
|
||||
connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT,
|
||||
...getConnectionConfig(resourceType, connectionDetails)
|
||||
}
|
||||
});
|
||||
try {
|
||||
return await operation(client);
|
||||
} finally {
|
||||
await client.destroy();
|
||||
}
|
||||
},
|
||||
{
|
||||
protocol: GatewayProxyProtocol.Tcp,
|
||||
relayHost: platformConnectionDetails.relayHost,
|
||||
gateway: platformConnectionDetails.gateway,
|
||||
relay: platformConnectionDetails.relay
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const sqlResourceFactory: TPamResourceFactory<TSqlResourceConnectionDetails, TSqlAccountCredentials> = (
|
||||
resourceType,
|
||||
connectionDetails,
|
||||
gatewayId,
|
||||
gatewayV2Service
|
||||
) => {
|
||||
const validateConnection = async () => {
|
||||
try {
|
||||
await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (client) => {
|
||||
await client.raw("Select 1");
|
||||
});
|
||||
return connectionDetails;
|
||||
} catch (error) {
|
||||
// Hacky way to know if we successfully hit the database
|
||||
if (error instanceof BadRequestError) {
|
||||
if (error.message === `password authentication failed for user "${TEST_CONNECTION_USERNAME}"`) {
|
||||
return connectionDetails;
|
||||
}
|
||||
|
||||
if (error.message.includes("no pg_hba.conf entry for host")) {
|
||||
return connectionDetails;
|
||||
}
|
||||
|
||||
if (error.message === "Connection terminated unexpectedly") {
|
||||
throw new BadRequestError({
|
||||
message: "Connection terminated unexpectedly. Verify that host and port are correct"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<TSqlAccountCredentials> = async (
|
||||
credentials
|
||||
) => {
|
||||
try {
|
||||
await executeWithGateway(
|
||||
{
|
||||
connectionDetails,
|
||||
gatewayId,
|
||||
resourceType,
|
||||
username: credentials.username,
|
||||
password: credentials.password
|
||||
},
|
||||
gatewayV2Service,
|
||||
async (client) => {
|
||||
await client.raw("Select 1");
|
||||
}
|
||||
);
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
if (error instanceof BadRequestError) {
|
||||
if (error.message === `password authentication failed for user "${credentials.username}"`) {
|
||||
throw new BadRequestError({
|
||||
message: "Account credentials invalid: Username or password incorrect"
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message === "Connection terminated unexpectedly") {
|
||||
throw new BadRequestError({
|
||||
message: "Connection terminated unexpectedly. Verify that host and port are correct"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
validateConnection,
|
||||
validateAccountCredentials
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Resources
|
||||
export const BaseSqlResourceConnectionDetailsSchema = z.object({
|
||||
host: z.string().trim().min(1).max(255),
|
||||
port: z.coerce.number(),
|
||||
database: z.string().trim().min(1).max(255),
|
||||
sslEnabled: z.boolean(),
|
||||
sslRejectUnauthorized: z.boolean(),
|
||||
sslCertificate: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => value || undefined)
|
||||
.optional()
|
||||
});
|
||||
|
||||
// Accounts
|
||||
export const BaseSqlAccountCredentialsSchema = z.object({
|
||||
username: z.string().trim().min(1),
|
||||
password: z.string().trim().min(1)
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import {
|
||||
TPostgresAccountCredentials,
|
||||
TPostgresResourceConnectionDetails
|
||||
} from "../../postgres/postgres-resource-types";
|
||||
|
||||
export type TSqlResourceConnectionDetails = TPostgresResourceConnectionDetails;
|
||||
export type TSqlAccountCredentials = TPostgresAccountCredentials;
|
||||
26
backend/src/ee/services/pam-session/pam-session-dal.ts
Normal file
26
backend/src/ee/services/pam-session/pam-session-dal.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
export type TPamSessionDALFactory = ReturnType<typeof pamSessionDALFactory>;
|
||||
export const pamSessionDALFactory = (db: TDbClient) => {
|
||||
const orm = ormify(db, TableName.PamSession);
|
||||
|
||||
const findById = async (id: string, tx?: Knex) => {
|
||||
const session = await (tx || db.replicaNode())(TableName.PamSession)
|
||||
.leftJoin(TableName.PamAccount, `${TableName.PamSession}.accountId`, `${TableName.PamAccount}.id`)
|
||||
.leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`)
|
||||
.leftJoin(TableName.GatewayV2, `${TableName.PamResource}.gatewayId`, `${TableName.GatewayV2}.id`)
|
||||
.select(selectAllTableCols(TableName.PamSession))
|
||||
.select(db.ref("name").withSchema(TableName.GatewayV2).as("gatewayName"))
|
||||
.select(db.ref("identityId").withSchema(TableName.GatewayV2).as("gatewayIdentityId"))
|
||||
.where(`${TableName.PamSession}.id`, id)
|
||||
.first();
|
||||
|
||||
return session;
|
||||
};
|
||||
|
||||
return { ...orm, findById };
|
||||
};
|
||||
6
backend/src/ee/services/pam-session/pam-session-enums.ts
Normal file
6
backend/src/ee/services/pam-session/pam-session-enums.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum PamSessionStatus {
|
||||
Starting = "starting", // Starting, user connecting to resource
|
||||
Active = "active", // Active, user is connected to resource
|
||||
Ended = "ended", // Ended by user
|
||||
Terminated = "terminated" // Terminated by an admin
|
||||
}
|
||||
43
backend/src/ee/services/pam-session/pam-session-fns.ts
Normal file
43
backend/src/ee/services/pam-session/pam-session-fns.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { TPamSessions } from "@app/db/schemas";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
|
||||
import { TPamSanitizedSession, TPamSessionCommandLog } from "./pam-session.types";
|
||||
|
||||
export const decryptSessionCommandLogs = async ({
|
||||
projectId,
|
||||
encryptedLogs,
|
||||
kmsService
|
||||
}: {
|
||||
projectId: string;
|
||||
encryptedLogs: Buffer;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
}) => {
|
||||
const { decryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
const decryptedPlainTextBlob = decryptor({
|
||||
cipherTextBlob: encryptedLogs
|
||||
});
|
||||
|
||||
return JSON.parse(decryptedPlainTextBlob.toString()) as TPamSessionCommandLog;
|
||||
};
|
||||
|
||||
export const decryptSession = async (
|
||||
session: TPamSessions,
|
||||
projectId: string,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
) => {
|
||||
return {
|
||||
...session,
|
||||
commandLogs: session.encryptedLogsBlob
|
||||
? await decryptSessionCommandLogs({
|
||||
projectId,
|
||||
encryptedLogs: session.encryptedLogsBlob,
|
||||
kmsService
|
||||
})
|
||||
: []
|
||||
} as TPamSanitizedSession;
|
||||
};
|
||||
15
backend/src/ee/services/pam-session/pam-session-schemas.ts
Normal file
15
backend/src/ee/services/pam-session/pam-session-schemas.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamSessionsSchema } from "@app/db/schemas";
|
||||
|
||||
export const PamSessionCommandLogSchema = z.object({
|
||||
input: z.string(),
|
||||
output: z.string(),
|
||||
timestamp: z.coerce.date()
|
||||
});
|
||||
|
||||
export const SanitizedSessionSchema = PamSessionsSchema.omit({
|
||||
encryptedLogsBlob: true
|
||||
}).extend({
|
||||
commandLogs: PamSessionCommandLogSchema.array()
|
||||
});
|
||||
190
backend/src/ee/services/pam-session/pam-session-service.ts
Normal file
190
backend/src/ee/services/pam-session/pam-session-service.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
|
||||
import { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||
import { ProjectPermissionPamSessionActions, ProjectPermissionSub } from "../permission/project-permission";
|
||||
import { TUpdateSessionLogsDTO } from "./pam-session.types";
|
||||
import { TPamSessionDALFactory } from "./pam-session-dal";
|
||||
import { PamSessionStatus } from "./pam-session-enums";
|
||||
import { decryptSession } from "./pam-session-fns";
|
||||
|
||||
type TPamSessionServiceFactoryDep = {
|
||||
pamSessionDAL: TPamSessionDALFactory;
|
||||
projectDAL: TProjectDALFactory;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
};
|
||||
|
||||
export type TPamSessionServiceFactory = ReturnType<typeof pamSessionServiceFactory>;
|
||||
|
||||
export const pamSessionServiceFactory = ({
|
||||
pamSessionDAL,
|
||||
projectDAL,
|
||||
permissionService,
|
||||
licenseService,
|
||||
kmsService
|
||||
}: TPamSessionServiceFactoryDep) => {
|
||||
const getById = async (sessionId: string, actor: OrgServiceActor) => {
|
||||
const session = await pamSessionDAL.findById(sessionId);
|
||||
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId: session.projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPamSessionActions.Read,
|
||||
ProjectPermissionSub.PamSessions
|
||||
);
|
||||
|
||||
return {
|
||||
session: await decryptSession(session, session.projectId, kmsService)
|
||||
};
|
||||
};
|
||||
|
||||
const list = async (projectId: string, actor: OrgServiceActor) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorId: actor.id,
|
||||
actorOrgId: actor.orgId,
|
||||
projectId,
|
||||
actionProjectType: ActionProjectType.PAM
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPamSessionActions.Read,
|
||||
ProjectPermissionSub.PamSessions
|
||||
);
|
||||
|
||||
const sessions = await pamSessionDAL.find({ projectId });
|
||||
|
||||
return {
|
||||
sessions: await Promise.all(sessions.map((session) => decryptSession(session, projectId, kmsService)))
|
||||
};
|
||||
};
|
||||
|
||||
const updateLogsById = async ({ sessionId, logs }: TUpdateSessionLogsDTO, actor: OrgServiceActor) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
message: "PAM operation failed due to organization plan restrictions."
|
||||
});
|
||||
}
|
||||
|
||||
// To be hit by gateways only
|
||||
if (actor.type !== ActorType.IDENTITY) {
|
||||
throw new ForbiddenRequestError({ message: "Only gateways can perform this action" });
|
||||
}
|
||||
|
||||
const session = await pamSessionDAL.findById(sessionId);
|
||||
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
|
||||
|
||||
if (session.encryptedLogsBlob) {
|
||||
throw new BadRequestError({ message: "Cannot update logs for sessions with existing logs" });
|
||||
}
|
||||
|
||||
const project = await projectDAL.findById(session.projectId);
|
||||
if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
project.orgId,
|
||||
actor.authMethod,
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
OrgPermissionGatewayActions.CreateGateways,
|
||||
OrgPermissionSubjects.Gateway
|
||||
);
|
||||
|
||||
if (session.gatewayIdentityId !== actor.id) {
|
||||
throw new ForbiddenRequestError({ message: "Identity does not have access to update logs for this session" });
|
||||
}
|
||||
|
||||
const { encryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId: session.projectId
|
||||
});
|
||||
|
||||
const { cipherTextBlob } = encryptor({
|
||||
plainText: Buffer.from(JSON.stringify(logs))
|
||||
});
|
||||
|
||||
const updatedSession = await pamSessionDAL.updateById(sessionId, {
|
||||
encryptedLogsBlob: cipherTextBlob
|
||||
});
|
||||
|
||||
return { session: updatedSession, projectId: project.id };
|
||||
};
|
||||
|
||||
const endSessionById = async (sessionId: string, actor: OrgServiceActor) => {
|
||||
const session = await pamSessionDAL.findById(sessionId);
|
||||
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
|
||||
|
||||
const project = await projectDAL.findById(session.projectId);
|
||||
if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
project.orgId,
|
||||
actor.authMethod,
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
if (actor.type === ActorType.IDENTITY) {
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
OrgPermissionGatewayActions.CreateGateways,
|
||||
OrgPermissionSubjects.Gateway
|
||||
);
|
||||
|
||||
if (session.gatewayIdentityId !== actor.id) {
|
||||
throw new ForbiddenRequestError({ message: "Identity does not have access to end this session" });
|
||||
}
|
||||
} else if (actor.type === ActorType.USER) {
|
||||
if (session.userId !== actor.id) {
|
||||
throw new ForbiddenRequestError({ message: "You are not authorized to end this session" });
|
||||
}
|
||||
} else {
|
||||
throw new ForbiddenRequestError({ message: "Only identities and users can perform this action" });
|
||||
}
|
||||
|
||||
if (session.status === PamSessionStatus.Ended) {
|
||||
return {
|
||||
session,
|
||||
projectId: project.id
|
||||
};
|
||||
}
|
||||
|
||||
if (session.status !== PamSessionStatus.Active && session.status !== PamSessionStatus.Starting) {
|
||||
throw new BadRequestError({ message: "Cannot end sessions that are not active or starting" });
|
||||
}
|
||||
|
||||
const updatedSession = await pamSessionDAL.updateById(sessionId, {
|
||||
endedAt: new Date(),
|
||||
status: PamSessionStatus.Ended
|
||||
});
|
||||
|
||||
return { session: updatedSession, projectId: project.id };
|
||||
};
|
||||
|
||||
return { getById, list, updateLogsById, endSessionById };
|
||||
};
|
||||
12
backend/src/ee/services/pam-session/pam-session.types.ts
Normal file
12
backend/src/ee/services/pam-session/pam-session.types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "./pam-session-schemas";
|
||||
|
||||
export type TPamSessionCommandLog = z.infer<typeof PamSessionCommandLogSchema>;
|
||||
export type TPamSanitizedSession = z.infer<typeof SanitizedSessionSchema>;
|
||||
|
||||
// DTOs
|
||||
export type TUpdateSessionLogsDTO = {
|
||||
sessionId: string;
|
||||
logs: TPamSessionCommandLog[];
|
||||
};
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
ProjectPermissionIdentityActions,
|
||||
ProjectPermissionKmipActions,
|
||||
ProjectPermissionMemberActions,
|
||||
ProjectPermissionPamAccountActions,
|
||||
ProjectPermissionPamSessionActions,
|
||||
ProjectPermissionPkiSubscriberActions,
|
||||
ProjectPermissionPkiSyncActions,
|
||||
ProjectPermissionPkiTemplateActions,
|
||||
@@ -49,7 +51,9 @@ const buildAdminPermissionRules = () => {
|
||||
ProjectPermissionSub.SshCertificateAuthorities,
|
||||
ProjectPermissionSub.SshCertificates,
|
||||
ProjectPermissionSub.SshCertificateTemplates,
|
||||
ProjectPermissionSub.SshHostGroups
|
||||
ProjectPermissionSub.SshHostGroups,
|
||||
ProjectPermissionSub.PamFolders,
|
||||
ProjectPermissionSub.PamResources
|
||||
].forEach((el) => {
|
||||
can(
|
||||
[
|
||||
@@ -290,6 +294,19 @@ const buildAdminPermissionRules = () => {
|
||||
ProjectPermissionSub.AppConnections
|
||||
);
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionPamAccountActions.Access,
|
||||
ProjectPermissionPamAccountActions.Read,
|
||||
ProjectPermissionPamAccountActions.Create,
|
||||
ProjectPermissionPamAccountActions.Edit,
|
||||
ProjectPermissionPamAccountActions.Delete
|
||||
],
|
||||
ProjectPermissionSub.PamAccounts
|
||||
);
|
||||
|
||||
can([ProjectPermissionPamSessionActions.Read], ProjectPermissionSub.PamSessions);
|
||||
|
||||
return rules;
|
||||
};
|
||||
|
||||
@@ -518,6 +535,15 @@ const buildMemberPermissionRules = () => {
|
||||
|
||||
can(ProjectPermissionAppConnectionActions.Connect, ProjectPermissionSub.AppConnections);
|
||||
|
||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamFolders);
|
||||
|
||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamResources);
|
||||
|
||||
can(
|
||||
[ProjectPermissionPamAccountActions.Access, ProjectPermissionPamAccountActions.Read],
|
||||
ProjectPermissionSub.PamAccounts
|
||||
);
|
||||
|
||||
return rules;
|
||||
};
|
||||
|
||||
@@ -579,6 +605,12 @@ const buildViewerPermissionRules = () => {
|
||||
ProjectPermissionSub.SecretEvents
|
||||
);
|
||||
|
||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamFolders);
|
||||
|
||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamResources);
|
||||
|
||||
can([ProjectPermissionPamAccountActions.Read], ProjectPermissionSub.PamAccounts);
|
||||
|
||||
return rules;
|
||||
};
|
||||
|
||||
|
||||
@@ -186,6 +186,19 @@ export enum ProjectPermissionAuditLogsActions {
|
||||
Read = "read"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionPamAccountActions {
|
||||
Access = "access",
|
||||
Read = "read",
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
Delete = "delete"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionPamSessionActions {
|
||||
Read = "read"
|
||||
// Terminate = "terminate"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionSub {
|
||||
Role = "role",
|
||||
Member = "member",
|
||||
@@ -228,7 +241,11 @@ export enum ProjectPermissionSub {
|
||||
SecretScanningFindings = "secret-scanning-findings",
|
||||
SecretScanningConfigs = "secret-scanning-configs",
|
||||
SecretEvents = "secret-events",
|
||||
AppConnections = "app-connections"
|
||||
AppConnections = "app-connections",
|
||||
PamFolders = "pam-folders",
|
||||
PamResources = "pam-resources",
|
||||
PamAccounts = "pam-accounts",
|
||||
PamSessions = "pam-sessions"
|
||||
}
|
||||
|
||||
export type SecretSubjectFields = {
|
||||
@@ -300,6 +317,12 @@ export type AppConnectionSubjectFields = {
|
||||
connectionId: string;
|
||||
};
|
||||
|
||||
export type PamAccountSubjectFields = {
|
||||
resourceName: string;
|
||||
accountName: string;
|
||||
accountPath: string;
|
||||
};
|
||||
|
||||
export type ProjectPermissionSet =
|
||||
| [
|
||||
ProjectPermissionSecretActions,
|
||||
@@ -404,7 +427,14 @@ export type ProjectPermissionSet =
|
||||
| ProjectPermissionSub.AppConnections
|
||||
| (ForcedSubject<ProjectPermissionSub.AppConnections> & AppConnectionSubjectFields)
|
||||
)
|
||||
];
|
||||
]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.PamFolders]
|
||||
| [ProjectPermissionActions, ProjectPermissionSub.PamResources]
|
||||
| [
|
||||
ProjectPermissionPamAccountActions,
|
||||
ProjectPermissionSub.PamAccounts | (ForcedSubject<ProjectPermissionSub.PamAccounts> & PamAccountSubjectFields)
|
||||
]
|
||||
| [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions];
|
||||
|
||||
const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
|
||||
const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
|
||||
@@ -427,6 +457,27 @@ const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
|
||||
})
|
||||
.partial()
|
||||
]);
|
||||
const PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
|
||||
const PAM_ACCOUNT_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
|
||||
z.string().refine((val) => val.startsWith("/"), SECRET_PATH_MISSING_SLASH_ERR_MSG),
|
||||
z
|
||||
.object({
|
||||
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ].refine(
|
||||
(val) => val.startsWith("/"),
|
||||
PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG
|
||||
),
|
||||
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ].refine(
|
||||
(val) => val.startsWith("/"),
|
||||
PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG
|
||||
),
|
||||
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN].refine(
|
||||
(val) => val.every((el) => el.startsWith("/")),
|
||||
PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG
|
||||
),
|
||||
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
|
||||
})
|
||||
.partial()
|
||||
]);
|
||||
// akhilmhdh: don't modify this for v2
|
||||
// if you want to update create a new schema
|
||||
const SecretConditionV1Schema = z
|
||||
@@ -650,6 +701,34 @@ const AppConnectionConditionSchema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
const PamAccountConditionSchema = z
|
||||
.object({
|
||||
resourceName: z.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
|
||||
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
|
||||
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
|
||||
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
|
||||
})
|
||||
.partial()
|
||||
]),
|
||||
accountName: z.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
|
||||
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
|
||||
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
|
||||
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
|
||||
})
|
||||
.partial()
|
||||
]),
|
||||
accountPath: PAM_ACCOUNT_PATH_PERMISSION_OPERATOR_SCHEMA
|
||||
})
|
||||
.partial();
|
||||
|
||||
const GeneralPermissionSchema = [
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."),
|
||||
@@ -840,6 +919,34 @@ const GeneralPermissionSchema = [
|
||||
conditions: AppConnectionConditionSchema.describe(
|
||||
"When specified, only matching conditions will be allowed to access given resource."
|
||||
).optional()
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.PamFolders).describe("The entity this permission pertains to."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.PamResources).describe("The entity this permission pertains to."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.PamAccounts).describe("The entity this permission pertains to."),
|
||||
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPamAccountActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
),
|
||||
conditions: PamAccountConditionSchema.describe(
|
||||
"When specified, only matching conditions will be allowed to access given resource."
|
||||
).optional()
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.PamSessions).describe("The entity this permission pertains to."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPamSessionActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
})
|
||||
];
|
||||
|
||||
|
||||
@@ -708,7 +708,8 @@ export const relayServiceFactory = ({
|
||||
relayPkiClientCaCertificate,
|
||||
relayPkiClientCaPrivateKey,
|
||||
relayPkiServerCaCertificate,
|
||||
relayPkiServerCaCertificateChain
|
||||
relayPkiServerCaCertificateChain,
|
||||
duration
|
||||
}: {
|
||||
gatewayId: string;
|
||||
gatewayName: string;
|
||||
@@ -718,6 +719,7 @@ export const relayServiceFactory = ({
|
||||
relayPkiClientCaPrivateKey: Buffer;
|
||||
relayPkiServerCaCertificate: Buffer;
|
||||
relayPkiServerCaCertificateChain: Buffer;
|
||||
duration?: number;
|
||||
}) => {
|
||||
const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048);
|
||||
const relayClientCaCert = new x509.X509Certificate(relayPkiClientCaCertificate);
|
||||
@@ -737,7 +739,7 @@ export const relayServiceFactory = ({
|
||||
);
|
||||
|
||||
const clientCertIssuedAt = new Date();
|
||||
const clientCertExpiration = new Date(new Date().getTime() + 5 * 60 * 1000);
|
||||
const clientCertExpiration = new Date(new Date().getTime() + (duration ?? 5 * 60 * 1000));
|
||||
const clientKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]);
|
||||
const clientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey);
|
||||
const clientCertSerialNumber = createSerialNumber();
|
||||
@@ -866,13 +868,15 @@ export const relayServiceFactory = ({
|
||||
orgId,
|
||||
orgName,
|
||||
gatewayId,
|
||||
gatewayName
|
||||
gatewayName,
|
||||
duration
|
||||
}: {
|
||||
relayId: string;
|
||||
orgId: string;
|
||||
orgName: string;
|
||||
gatewayId: string;
|
||||
gatewayName: string;
|
||||
duration?: number;
|
||||
}) => {
|
||||
const relay = await relayDAL.findOne({
|
||||
id: relayId
|
||||
@@ -896,7 +900,8 @@ export const relayServiceFactory = ({
|
||||
relayPkiClientCaCertificate: instanceCAs.instanceRelayPkiClientCaCertificate,
|
||||
relayPkiClientCaPrivateKey: instanceCAs.instanceRelayPkiClientCaPrivateKey,
|
||||
relayPkiServerCaCertificate: instanceCAs.instanceRelayPkiServerCaCertificate,
|
||||
relayPkiServerCaCertificateChain: instanceCAs.instanceRelayPkiServerCaCertificateChain
|
||||
relayPkiServerCaCertificateChain: instanceCAs.instanceRelayPkiServerCaCertificateChain,
|
||||
duration
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -914,7 +919,8 @@ export const relayServiceFactory = ({
|
||||
relayPkiClientCaCertificate: orgCAs.relayPkiClientCaCertificate,
|
||||
relayPkiClientCaPrivateKey: orgCAs.relayPkiClientCaPrivateKey,
|
||||
relayPkiServerCaCertificate: orgCAs.relayPkiServerCaCertificate,
|
||||
relayPkiServerCaCertificateChain: orgCAs.relayPkiServerCaCertificateChain
|
||||
relayPkiServerCaCertificateChain: orgCAs.relayPkiServerCaCertificateChain,
|
||||
duration
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import { Knex } from "knex";
|
||||
import RE2 from "re2";
|
||||
|
||||
import { OrgMembershipStatus, TableName, TSamlConfigs, TSamlConfigsUpdate, TUsers } from "@app/db/schemas";
|
||||
import {
|
||||
OrgMembershipRole,
|
||||
OrgMembershipStatus,
|
||||
TableName,
|
||||
TGroups,
|
||||
TSamlConfigs,
|
||||
TSamlConfigsUpdate,
|
||||
TUsers
|
||||
} from "@app/db/schemas";
|
||||
import { throwOnPlanSeatLimitReached } from "@app/ee/services/license/license-fns";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { crypto } from "@app/lib/crypto";
|
||||
@@ -8,12 +19,16 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/
|
||||
import { AuthTokenType } from "@app/services/auth/auth-type";
|
||||
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
|
||||
import { TokenType } from "@app/services/auth-token/auth-token-types";
|
||||
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
|
||||
import { TIdentityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
import { TOrgDALFactory } from "@app/services/org/org-dal";
|
||||
import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns";
|
||||
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
|
||||
import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal";
|
||||
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
||||
import { getServerCfg } from "@app/services/super-admin/super-admin-service";
|
||||
import { LoginMethod } from "@app/services/super-admin/super-admin-types";
|
||||
@@ -22,17 +37,30 @@ import { normalizeUsername } from "@app/services/user/user-fns";
|
||||
import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
|
||||
import { UserAliasType } from "@app/services/user-alias/user-alias-types";
|
||||
|
||||
import { TGroupDALFactory } from "../group/group-dal";
|
||||
import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "../group/group-fns";
|
||||
import { TUserGroupMembershipDALFactory } from "../group/user-group-membership-dal";
|
||||
import { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
|
||||
import { TPermissionServiceFactory } from "../permission/permission-service-types";
|
||||
import { TSamlConfigDALFactory } from "./saml-config-dal";
|
||||
import { TSamlConfigServiceFactory } from "./saml-config-types";
|
||||
import { SamlProviders, TSamlConfigServiceFactory } from "./saml-config-types";
|
||||
|
||||
// SAML providers that support group sync
|
||||
const GROUP_SYNC_SUPPORTED_PROVIDERS = [SamlProviders.GOOGLE_SAML] as SamlProviders[];
|
||||
|
||||
type TSamlConfigServiceFactoryDep = {
|
||||
samlConfigDAL: Pick<TSamlConfigDALFactory, "create" | "findOne" | "update" | "findById">;
|
||||
userDAL: Pick<
|
||||
TUserDALFactory,
|
||||
"create" | "findOne" | "transaction" | "updateById" | "findById" | "findUserEncKeyByUserId"
|
||||
| "create"
|
||||
| "findOne"
|
||||
| "find"
|
||||
| "transaction"
|
||||
| "updateById"
|
||||
| "findById"
|
||||
| "findUserEncKeyByUserId"
|
||||
| "findUserEncKeyByUserIdsBatch"
|
||||
>;
|
||||
userAliasDAL: Pick<TUserAliasDALFactory, "create" | "findOne">;
|
||||
orgDAL: Pick<
|
||||
@@ -41,6 +69,15 @@ type TSamlConfigServiceFactoryDep = {
|
||||
>;
|
||||
identityMetadataDAL: Pick<TIdentityMetadataDALFactory, "delete" | "insertMany" | "transaction">;
|
||||
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "create">;
|
||||
groupDAL: Pick<TGroupDALFactory, "create" | "findOne" | "find" | "transaction">;
|
||||
userGroupMembershipDAL: Pick<
|
||||
TUserGroupMembershipDALFactory,
|
||||
"find" | "delete" | "transaction" | "insertMany" | "filterProjectsByUserMembership"
|
||||
>;
|
||||
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById" | "findProjectGhostUser">;
|
||||
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
|
||||
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "delete" | "findLatestProjectKey" | "insertMany">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan" | "updateSubscriptionOrgMemberCount">;
|
||||
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser">;
|
||||
@@ -54,6 +91,12 @@ export const samlConfigServiceFactory = ({
|
||||
orgMembershipDAL,
|
||||
userDAL,
|
||||
userAliasDAL,
|
||||
groupDAL,
|
||||
userGroupMembershipDAL,
|
||||
groupProjectDAL,
|
||||
projectDAL,
|
||||
projectBotDAL,
|
||||
projectKeyDAL,
|
||||
permissionService,
|
||||
licenseService,
|
||||
tokenService,
|
||||
@@ -61,6 +104,139 @@ export const samlConfigServiceFactory = ({
|
||||
identityMetadataDAL,
|
||||
kmsService
|
||||
}: TSamlConfigServiceFactoryDep): TSamlConfigServiceFactory => {
|
||||
const parseSamlGroups = (groupsValue: string): string[] => {
|
||||
let samlGroups: string[] = [];
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const parsed = JSON.parse(groupsValue);
|
||||
if (Array.isArray(parsed)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
samlGroups = parsed;
|
||||
} else if (typeof parsed === "string") {
|
||||
samlGroups = parsed
|
||||
.split(",")
|
||||
.map((g) => g.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
samlGroups = groupsValue
|
||||
.split(",")
|
||||
.map((g) => g.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return samlGroups;
|
||||
};
|
||||
|
||||
const syncUserGroupMemberships = async ({
|
||||
userId,
|
||||
orgId,
|
||||
samlGroups,
|
||||
tx
|
||||
}: {
|
||||
userId: string;
|
||||
orgId: string;
|
||||
samlGroups: string[];
|
||||
tx?: Knex;
|
||||
}) => {
|
||||
const processGroupSync = async (transaction: Knex) => {
|
||||
const currentGroupMemberships = await userGroupMembershipDAL.find(
|
||||
{
|
||||
userId
|
||||
},
|
||||
{ tx: transaction }
|
||||
);
|
||||
|
||||
const orgGroups = await groupDAL.find({ orgId }, { tx: transaction });
|
||||
const orgGroupsMap = new Map(orgGroups.map((g: TGroups) => [g.name, g]));
|
||||
const orgGroupIds = new Set(orgGroups.map((g) => g.id));
|
||||
|
||||
const currentOrgGroupMemberships = currentGroupMemberships.filter((m) => orgGroupIds.has(m.groupId));
|
||||
const currentGroupNames = new Set(
|
||||
currentOrgGroupMemberships
|
||||
.map((m) => {
|
||||
const group = orgGroups.find((g) => g.id === m.groupId);
|
||||
return group?.name;
|
||||
})
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const targetGroupNames = new Set(samlGroups);
|
||||
const groupsToAdd = samlGroups.filter((groupName) => !currentGroupNames.has(groupName));
|
||||
const groupsToRemove = Array.from(currentGroupNames).filter(
|
||||
(groupName) => groupName && !targetGroupNames.has(groupName)
|
||||
);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
for (const groupName of groupsToAdd) {
|
||||
if (!orgGroupsMap.has(groupName)) {
|
||||
const newGroup = await groupDAL.create(
|
||||
{
|
||||
name: groupName,
|
||||
slug: `${groupName.toLowerCase().replace(new RE2("[^a-z0-9]", "g"), "-")}-${Date.now()}`,
|
||||
orgId,
|
||||
role: OrgMembershipRole.NoAccess,
|
||||
roleId: null
|
||||
},
|
||||
transaction
|
||||
);
|
||||
orgGroupsMap.set(groupName, newGroup);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
for (const groupName of groupsToAdd) {
|
||||
const group = orgGroupsMap.get(groupName);
|
||||
if (group) {
|
||||
try {
|
||||
await addUsersToGroupByUserIds({
|
||||
userIds: [userId],
|
||||
group,
|
||||
userDAL,
|
||||
userGroupMembershipDAL,
|
||||
orgDAL,
|
||||
groupProjectDAL,
|
||||
projectKeyDAL,
|
||||
projectDAL,
|
||||
projectBotDAL,
|
||||
tx: transaction
|
||||
});
|
||||
} catch (error) {
|
||||
// Continue if user already in group
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
for (const groupName of groupsToRemove) {
|
||||
if (groupName) {
|
||||
const group = orgGroupsMap.get(groupName);
|
||||
if (group) {
|
||||
try {
|
||||
await removeUsersFromGroupByUserIds({
|
||||
userIds: [userId],
|
||||
group,
|
||||
userDAL,
|
||||
userGroupMembershipDAL,
|
||||
groupProjectDAL,
|
||||
projectKeyDAL,
|
||||
tx: transaction
|
||||
});
|
||||
} catch (error) {
|
||||
// Continue if user not in group
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (tx) {
|
||||
await processGroupSync(tx);
|
||||
} else {
|
||||
await userDAL.transaction(processGroupSync);
|
||||
}
|
||||
};
|
||||
|
||||
const createSamlCfg: TSamlConfigServiceFactory["createSamlCfg"] = async ({
|
||||
idpCert,
|
||||
actor,
|
||||
@@ -71,7 +247,8 @@ export const samlConfigServiceFactory = ({
|
||||
actorId,
|
||||
isActive,
|
||||
entryPoint,
|
||||
authProvider
|
||||
authProvider,
|
||||
enableGroupSync
|
||||
}) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Sso);
|
||||
@@ -96,6 +273,18 @@ export const samlConfigServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
if (enableGroupSync && !GROUP_SYNC_SUPPORTED_PROVIDERS.includes(authProvider)) {
|
||||
throw new BadRequestError({
|
||||
message: "Group sync is not supported for this SAML provider."
|
||||
});
|
||||
}
|
||||
|
||||
if (enableGroupSync && !plan.groups) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to enable SAML group sync due to plan restriction. Upgrade plan to enable group sync."
|
||||
});
|
||||
}
|
||||
|
||||
const { encryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.Organization,
|
||||
orgId
|
||||
@@ -107,7 +296,8 @@ export const samlConfigServiceFactory = ({
|
||||
isActive,
|
||||
encryptedSamlCertificate: encryptor({ plainText: Buffer.from(idpCert) }).cipherTextBlob,
|
||||
encryptedSamlEntryPoint: encryptor({ plainText: Buffer.from(entryPoint) }).cipherTextBlob,
|
||||
encryptedSamlIssuer: encryptor({ plainText: Buffer.from(issuer) }).cipherTextBlob
|
||||
encryptedSamlIssuer: encryptor({ plainText: Buffer.from(issuer) }).cipherTextBlob,
|
||||
enableGroupSync: enableGroupSync || false
|
||||
});
|
||||
|
||||
return samlConfig;
|
||||
@@ -123,7 +313,8 @@ export const samlConfigServiceFactory = ({
|
||||
issuer,
|
||||
isActive,
|
||||
entryPoint,
|
||||
authProvider
|
||||
authProvider,
|
||||
enableGroupSync
|
||||
}) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
|
||||
@@ -147,7 +338,27 @@ export const samlConfigServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
const updateQuery: TSamlConfigsUpdate = { authProvider, isActive, lastUsed: null };
|
||||
if (enableGroupSync && authProvider && !GROUP_SYNC_SUPPORTED_PROVIDERS.includes(authProvider)) {
|
||||
throw new BadRequestError({
|
||||
message: "Group sync is not supported for this SAML provider."
|
||||
});
|
||||
}
|
||||
|
||||
if (enableGroupSync && !plan.groups) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to enable SAML group sync due to plan restriction. Upgrade plan to enable group sync."
|
||||
});
|
||||
}
|
||||
|
||||
const updateQuery: TSamlConfigsUpdate = {
|
||||
authProvider,
|
||||
isActive,
|
||||
lastUsed: null
|
||||
};
|
||||
|
||||
if (enableGroupSync !== undefined) {
|
||||
updateQuery.enableGroupSync = enableGroupSync;
|
||||
}
|
||||
const { encryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.Organization,
|
||||
orgId
|
||||
@@ -250,7 +461,8 @@ export const samlConfigServiceFactory = ({
|
||||
entryPoint,
|
||||
issuer,
|
||||
cert,
|
||||
lastUsed: samlConfig.lastUsed
|
||||
lastUsed: samlConfig.lastUsed,
|
||||
enableGroupSync: samlConfig.enableGroupSync
|
||||
};
|
||||
};
|
||||
|
||||
@@ -282,6 +494,12 @@ export const samlConfigServiceFactory = ({
|
||||
const organization = await orgDAL.findOrgById(orgId);
|
||||
if (!organization) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
|
||||
|
||||
const samlConfig = await samlConfigDAL.findOne({ orgId });
|
||||
const groupsMetadata = metadata?.find(({ key }) => key === "groups");
|
||||
|
||||
const plan = await licenseService.getPlan(orgId);
|
||||
const shouldSyncGroups = !!samlConfig?.enableGroupSync && !!plan.groups;
|
||||
|
||||
let user: TUsers;
|
||||
if (userAlias) {
|
||||
user = await userDAL.transaction(async (tx) => {
|
||||
@@ -303,7 +521,7 @@ export const samlConfigServiceFactory = ({
|
||||
orgId,
|
||||
role,
|
||||
roleId,
|
||||
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
|
||||
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited,
|
||||
isActive: true
|
||||
},
|
||||
tx
|
||||
@@ -334,6 +552,17 @@ export const samlConfigServiceFactory = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSyncGroups && metadata && foundUser.id) {
|
||||
const samlGroups = groupsMetadata?.value ? parseSamlGroups(groupsMetadata.value) : [];
|
||||
|
||||
await syncUserGroupMemberships({
|
||||
userId: foundUser.id,
|
||||
orgId,
|
||||
samlGroups,
|
||||
tx
|
||||
});
|
||||
}
|
||||
|
||||
return foundUser;
|
||||
});
|
||||
} else {
|
||||
@@ -425,6 +654,18 @@ export const samlConfigServiceFactory = ({
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldSyncGroups && metadata && newUser.id) {
|
||||
const samlGroups = groupsMetadata?.value ? parseSamlGroups(groupsMetadata.value) : [];
|
||||
|
||||
await syncUserGroupMemberships({
|
||||
userId: newUser.id,
|
||||
orgId,
|
||||
samlGroups,
|
||||
tx
|
||||
});
|
||||
}
|
||||
|
||||
return newUser;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export type TCreateSamlCfgDTO = {
|
||||
entryPoint: string;
|
||||
issuer: string;
|
||||
idpCert: string;
|
||||
enableGroupSync?: boolean;
|
||||
} & TOrgPermission;
|
||||
|
||||
export type TUpdateSamlCfgDTO = Partial<{
|
||||
@@ -25,6 +26,7 @@ export type TUpdateSamlCfgDTO = Partial<{
|
||||
entryPoint: string;
|
||||
issuer: string;
|
||||
idpCert: string;
|
||||
enableGroupSync?: boolean;
|
||||
}> &
|
||||
TOrgPermission;
|
||||
|
||||
@@ -71,6 +73,7 @@ export type TSamlConfigServiceFactory = {
|
||||
issuer: string;
|
||||
cert: string;
|
||||
lastUsed: Date | null | undefined;
|
||||
enableGroupSync?: boolean;
|
||||
}>;
|
||||
samlLogin: (arg: TSamlLoginDTO) => Promise<{
|
||||
isUserCompleted: boolean;
|
||||
|
||||
@@ -23,6 +23,7 @@ export const PgSqlLock = {
|
||||
InstanceRelayConfigInit: () => pgAdvisoryLockHashText("instance-relay-config-init"),
|
||||
OrgGatewayV2Init: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-v2-init:${orgId}`),
|
||||
OrgRelayConfigInit: (orgId: string) => pgAdvisoryLockHashText(`org-relay-config-init:${orgId}`),
|
||||
GatewayPamSessionKey: (gatewayId: string) => pgAdvisoryLockHashText(`gateway-pam-session-key:${gatewayId}`),
|
||||
IdentityLogin: (identityId: string, nonce: string) => pgAdvisoryLockHashText(`identity-login:${identityId}:${nonce}`)
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -2872,7 +2872,9 @@ export const SamlSso = {
|
||||
entryPoint:
|
||||
"The entry point for the SAML authentication. This is the URL that the user will be redirected to after they have authenticated with the SAML provider.",
|
||||
issuer: "The SAML provider issuer URL or entity ID.",
|
||||
cert: "The certificate to use for SAML authentication."
|
||||
cert: "The certificate to use for SAML authentication.",
|
||||
enableGroupSync:
|
||||
"Whether to enable automatic synchronization of group memberships from the SAML provider to Infisical groups."
|
||||
},
|
||||
CREATE_CONFIG: {
|
||||
organizationId: "The ID of the organization to create the SAML config for.",
|
||||
@@ -2881,7 +2883,9 @@ export const SamlSso = {
|
||||
entryPoint:
|
||||
"The entry point for the SAML authentication. This is the URL that the user will be redirected to after they have authenticated with the SAML provider.",
|
||||
issuer: "The SAML provider issuer URL or entity ID.",
|
||||
cert: "The certificate to use for SAML authentication."
|
||||
cert: "The certificate to use for SAML authentication.",
|
||||
enableGroupSync:
|
||||
"Whether to enable automatic synchronization of group memberships from the SAML provider to Infisical groups."
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -53,3 +53,53 @@ export const titleCaseToCamelCase = (obj: unknown): unknown => {
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const deepEqual = (obj1: unknown, obj2: unknown): boolean => {
|
||||
if (obj1 === obj2) return true;
|
||||
|
||||
if (obj1 === null || obj2 === null || obj1 === undefined || obj2 === undefined) {
|
||||
return obj1 === obj2;
|
||||
}
|
||||
|
||||
if (typeof obj1 !== typeof obj2) return false;
|
||||
|
||||
if (typeof obj1 !== "object") return obj1 === obj2;
|
||||
|
||||
if (Array.isArray(obj1) !== Array.isArray(obj2)) return false;
|
||||
|
||||
if (Array.isArray(obj1)) {
|
||||
const arr1 = obj1 as unknown[];
|
||||
const arr2 = obj2 as unknown[];
|
||||
if (arr1.length !== arr2.length) return false;
|
||||
return arr1.every((val, idx) => deepEqual(val, arr2[idx]));
|
||||
}
|
||||
|
||||
const keys1 = Object.keys(obj1 as Record<string, unknown>).sort();
|
||||
const keys2 = Object.keys(obj2 as Record<string, unknown>).sort();
|
||||
|
||||
if (keys1.length !== keys2.length) return false;
|
||||
if (keys1.some((key, idx) => key !== keys2[idx])) return false;
|
||||
|
||||
return keys1.every((key) =>
|
||||
deepEqual((obj1 as Record<string, unknown>)[key], (obj2 as Record<string, unknown>)[key])
|
||||
);
|
||||
};
|
||||
|
||||
export const deepEqualSkipFields = (obj1: unknown, obj2: unknown, skipFields: string[] = []): boolean => {
|
||||
if (skipFields.length === 0) {
|
||||
return deepEqual(obj1, obj2);
|
||||
}
|
||||
|
||||
if (typeof obj1 !== "object" || typeof obj2 !== "object" || obj1 === null || obj2 === null) {
|
||||
return deepEqual(obj1, obj2);
|
||||
}
|
||||
|
||||
const filtered1 = Object.fromEntries(
|
||||
Object.entries(obj1 as Record<string, unknown>).filter(([key]) => !skipFields.includes(key))
|
||||
);
|
||||
const filtered2 = Object.fromEntries(
|
||||
Object.entries(obj2 as Record<string, unknown>).filter(([key]) => !skipFields.includes(key))
|
||||
);
|
||||
|
||||
return deepEqual(filtered1, filtered2);
|
||||
};
|
||||
|
||||
@@ -66,6 +66,14 @@ import { licenseDALFactory } from "@app/ee/services/license/license-dal";
|
||||
import { licenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { oidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal";
|
||||
import { oidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service";
|
||||
import { pamAccountDALFactory } from "@app/ee/services/pam-account/pam-account-dal";
|
||||
import { pamAccountServiceFactory } from "@app/ee/services/pam-account/pam-account-service";
|
||||
import { pamFolderDALFactory } from "@app/ee/services/pam-folder/pam-folder-dal";
|
||||
import { pamFolderServiceFactory } from "@app/ee/services/pam-folder/pam-folder-service";
|
||||
import { pamResourceDALFactory } from "@app/ee/services/pam-resource/pam-resource-dal";
|
||||
import { pamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-resource-service";
|
||||
import { pamSessionDALFactory } from "@app/ee/services/pam-session/pam-session-dal";
|
||||
import { pamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service";
|
||||
import { permissionDALFactory } from "@app/ee/services/permission/permission-dal";
|
||||
import { permissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { pitServiceFactory } from "@app/ee/services/pit/pit-service";
|
||||
@@ -623,6 +631,12 @@ export const registerRoutes = async (
|
||||
userDAL,
|
||||
userAliasDAL,
|
||||
samlConfigDAL,
|
||||
groupDAL,
|
||||
userGroupMembershipDAL,
|
||||
groupProjectDAL,
|
||||
projectDAL,
|
||||
projectBotDAL,
|
||||
projectKeyDAL,
|
||||
licenseService,
|
||||
tokenService,
|
||||
smtpService,
|
||||
@@ -2104,6 +2118,46 @@ export const registerRoutes = async (
|
||||
appConnectionDAL
|
||||
});
|
||||
|
||||
const pamFolderDAL = pamFolderDALFactory(db);
|
||||
const pamResourceDAL = pamResourceDALFactory(db);
|
||||
const pamAccountDAL = pamAccountDALFactory(db);
|
||||
const pamSessionDAL = pamSessionDALFactory(db);
|
||||
|
||||
const pamFolderService = pamFolderServiceFactory({
|
||||
pamFolderDAL,
|
||||
permissionService,
|
||||
licenseService
|
||||
});
|
||||
|
||||
const pamResourceService = pamResourceServiceFactory({
|
||||
pamResourceDAL,
|
||||
permissionService,
|
||||
licenseService,
|
||||
kmsService,
|
||||
gatewayV2Service
|
||||
});
|
||||
|
||||
const pamAccountService = pamAccountServiceFactory({
|
||||
pamAccountDAL,
|
||||
gatewayV2Service,
|
||||
kmsService,
|
||||
licenseService,
|
||||
pamFolderDAL,
|
||||
pamResourceDAL,
|
||||
pamSessionDAL,
|
||||
permissionService,
|
||||
projectDAL,
|
||||
userDAL
|
||||
});
|
||||
|
||||
const pamSessionService = pamSessionServiceFactory({
|
||||
pamSessionDAL,
|
||||
projectDAL,
|
||||
permissionService,
|
||||
licenseService,
|
||||
kmsService
|
||||
});
|
||||
|
||||
// setup the communication with license key server
|
||||
await licenseService.init();
|
||||
|
||||
@@ -2242,6 +2296,10 @@ export const registerRoutes = async (
|
||||
bus: eventBusService,
|
||||
sse: sseService,
|
||||
notification: notificationService,
|
||||
pamFolder: pamFolderService,
|
||||
pamResource: pamResourceService,
|
||||
pamAccount: pamAccountService,
|
||||
pamSession: pamSessionService,
|
||||
upgradePath: upgradePathService
|
||||
});
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ import { registerSecretRequestsRouter } from "./secret-requests-router";
|
||||
import { registerSecretSharingRouter } from "./secret-sharing-router";
|
||||
import { registerSecretTagRouter } from "./secret-tag-router";
|
||||
import { registerSlackRouter } from "./slack-router";
|
||||
import { registerUpgradePathRouter } from "./upgrade-path-router";
|
||||
import { registerSsoRouter } from "./sso-router";
|
||||
import { registerUpgradePathRouter } from "./upgrade-path-router";
|
||||
import { registerUserActionRouter } from "./user-action-router";
|
||||
import { registerUserEngagementRouter } from "./user-engagement-router";
|
||||
import { registerUserRouter } from "./user-router";
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION,
|
||||
AwsCertificateManagerPkiSyncSchema,
|
||||
CreateAwsCertificateManagerPkiSyncSchema,
|
||||
UpdateAwsCertificateManagerPkiSyncSchema
|
||||
} from "@app/services/pki-sync/aws-certificate-manager";
|
||||
import { PkiSync } from "@app/services/pki-sync/pki-sync-enums";
|
||||
|
||||
import { registerSyncPkiEndpoints } from "./pki-sync-endpoints";
|
||||
|
||||
export const registerAwsCertificateManagerPkiSyncRouter = async (server: FastifyZodProvider) =>
|
||||
registerSyncPkiEndpoints({
|
||||
destination: PkiSync.AwsCertificateManager,
|
||||
server,
|
||||
responseSchema: AwsCertificateManagerPkiSyncSchema,
|
||||
createSchema: CreateAwsCertificateManagerPkiSyncSchema,
|
||||
updateSchema: UpdateAwsCertificateManagerPkiSyncSchema,
|
||||
syncOptions: {
|
||||
canImportCertificates: AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION.canImportCertificates,
|
||||
canRemoveCertificates: AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION.canRemoveCertificates
|
||||
}
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { PkiSync } from "@app/services/pki-sync/pki-sync-enums";
|
||||
|
||||
import { registerAwsCertificateManagerPkiSyncRouter } from "./aws-certificate-manager-pki-sync-router";
|
||||
import { registerAzureKeyVaultPkiSyncRouter } from "./azure-key-vault-pki-sync-router";
|
||||
|
||||
export * from "./pki-sync-router";
|
||||
|
||||
export const PKI_SYNC_REGISTER_ROUTER_MAP: Record<PkiSync, (server: FastifyZodProvider) => Promise<void>> = {
|
||||
[PkiSync.AzureKeyVault]: registerAzureKeyVaultPkiSyncRouter
|
||||
[PkiSync.AzureKeyVault]: registerAzureKeyVaultPkiSyncRouter,
|
||||
[PkiSync.AwsCertificateManager]: registerAwsCertificateManagerPkiSyncRouter
|
||||
};
|
||||
|
||||
@@ -425,4 +425,42 @@ export const registerSyncSecretsEndpoints = <T extends TSecretSync, I extends TS
|
||||
return { secretSync };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/check-destination",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
tags: [ApiDocsTags.SecretSyncs],
|
||||
body: z.object({
|
||||
destinationConfig: z.unknown(),
|
||||
excludeSyncId: z.string().uuid().optional(),
|
||||
projectId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
hasDuplicate: z.boolean(),
|
||||
duplicateProjectId: z.string().uuid().optional()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { destinationConfig, excludeSyncId, projectId } = req.body;
|
||||
|
||||
const result = await server.services.secretSync.checkDuplicateDestination(
|
||||
{
|
||||
destinationConfig: destinationConfig as Record<string, unknown>,
|
||||
destination,
|
||||
excludeSyncId,
|
||||
projectId
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -215,6 +215,8 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => {
|
||||
return false;
|
||||
case ProjectType.SSH:
|
||||
return false;
|
||||
case ProjectType.PAM:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const constructGroupOrgMembershipRoleMappings = async ({
|
||||
let customRolesMap: Map<string, TOrgRoles> = new Map();
|
||||
if (customRoleSlugs.length > 0) {
|
||||
const customRoles = await orgRoleDAL.find({
|
||||
orgId,
|
||||
$in: {
|
||||
slug: customRoleSlugs
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import RE2 from "re2";
|
||||
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { PkiSync } from "@app/services/pki-sync/pki-sync-enums";
|
||||
|
||||
/**
|
||||
* AWS Certificate Manager naming constraints for certificates
|
||||
*/
|
||||
export const AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING = {
|
||||
/**
|
||||
* Regular expression pattern for valid AWS Certificate Manager certificate names
|
||||
* Must contain only alphanumeric characters, spaces, hyphens, and underscores
|
||||
* Must be 1-256 characters long
|
||||
*/
|
||||
NAME_PATTERN: new RE2("^[a-zA-Z0-9\\s\\-_]{1,256}$"),
|
||||
|
||||
/**
|
||||
* String of characters that are forbidden in AWS Certificate Manager certificate names
|
||||
*/
|
||||
FORBIDDEN_CHARACTERS: "!@#$%^&*()+={}[]|\\:;\"'<>,.?/~`",
|
||||
|
||||
/**
|
||||
* Maximum length for certificate names in AWS Certificate Manager
|
||||
*/
|
||||
MAX_LENGTH: 256,
|
||||
|
||||
/**
|
||||
* Minimum length for certificate names in AWS Certificate Manager
|
||||
*/
|
||||
MIN_LENGTH: 1,
|
||||
|
||||
/**
|
||||
* String representation of the allowed character pattern (for UI display)
|
||||
*/
|
||||
ALLOWED_CHARACTER_PATTERN: "^[a-zA-Z0-9\\s\\-_]{1,256}$"
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* AWS Certificate Manager PKI Sync list option configuration
|
||||
*/
|
||||
export const AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION = {
|
||||
name: "AWS Certificate Manager" as const,
|
||||
connection: AppConnection.AWS,
|
||||
destination: PkiSync.AwsCertificateManager,
|
||||
canImportCertificates: false,
|
||||
canRemoveCertificates: true,
|
||||
defaultCertificateNameSchema: "Infisical-{{certificateId}}",
|
||||
forbiddenCharacters: AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS,
|
||||
allowedCharacterPattern: AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.ALLOWED_CHARACTER_PATTERN,
|
||||
maxCertificateNameLength: AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.MAX_LENGTH,
|
||||
minCertificateNameLength: AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.MIN_LENGTH
|
||||
} as const;
|
||||
@@ -0,0 +1,634 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import * as AWS from "aws-sdk";
|
||||
import RE2 from "re2";
|
||||
import { z } from "zod";
|
||||
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
|
||||
import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums";
|
||||
import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
|
||||
import { AwsConnectionMethod } from "@app/services/app-connection/aws/aws-connection-enums";
|
||||
import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns";
|
||||
import {
|
||||
AwsConnectionAccessTokenCredentialsSchema,
|
||||
AwsConnectionAssumeRoleCredentialsSchema
|
||||
} from "@app/services/app-connection/aws/aws-connection-schemas";
|
||||
import { TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types";
|
||||
import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TCertificateMap } from "@app/services/pki-sync/pki-sync-types";
|
||||
|
||||
import { PkiSyncError } from "../pki-sync-errors";
|
||||
import { TPkiSyncWithCredentials } from "../pki-sync-types";
|
||||
import {
|
||||
ACMCertificateWithKey,
|
||||
CertificateImportRequest,
|
||||
RemoveCertificatesResult,
|
||||
SyncCertificatesResult,
|
||||
TAwsCertificateManagerPkiSyncConfig
|
||||
} from "./aws-certificate-manager-pki-sync-types";
|
||||
|
||||
const INFISICAL_CERTIFICATE_TAG = "InfisicalCertificate";
|
||||
const AWS_CERTIFICATE_ARN_PATTERN = new RE2("^arn:aws:acm:[a-z0-9-]+:\\d{12}:certificate/[a-f0-9-]{36}$");
|
||||
|
||||
type TAwsAssumeRoleCredentials = z.infer<typeof AwsConnectionAssumeRoleCredentialsSchema>;
|
||||
type TAwsAccessKeyCredentials = z.infer<typeof AwsConnectionAccessTokenCredentialsSchema>;
|
||||
|
||||
const AWS_RATE_LIMIT_CONFIG: RateLimitConfig = {
|
||||
MAX_CONCURRENT_REQUESTS: 10,
|
||||
BASE_DELAY: 1000,
|
||||
MAX_DELAY: 30000,
|
||||
MAX_RETRIES: 3,
|
||||
RATE_LIMIT_STATUS_CODES: [429, 503]
|
||||
};
|
||||
|
||||
const awsConnectionQueue = createConnectionQueue(AWS_RATE_LIMIT_CONFIG);
|
||||
|
||||
const { withRateLimitRetry, executeWithConcurrencyLimit } = awsConnectionQueue;
|
||||
|
||||
const validateCertificateArn = (arn: string): boolean => {
|
||||
return AWS_CERTIFICATE_ARN_PATTERN.test(arn);
|
||||
};
|
||||
|
||||
const extractCertificateNameFromArn = (certificateArn: string): string => {
|
||||
if (!validateCertificateArn(certificateArn)) {
|
||||
throw new Error(`Invalid AWS Certificate Manager ARN format: ${certificateArn}`);
|
||||
}
|
||||
const parts = certificateArn.split("/");
|
||||
return parts[parts.length - 1];
|
||||
};
|
||||
|
||||
const sanitizeInput = (input: string): string => {
|
||||
return input.trim().replace(new RE2("[^\\w\\s-]", "g"), "");
|
||||
};
|
||||
|
||||
const validateCertificateContent = (cert: string, privateKey: string): void => {
|
||||
if (!cert || cert.trim().length === 0) {
|
||||
throw new Error("Certificate content is empty or missing");
|
||||
}
|
||||
|
||||
if (!privateKey || privateKey.trim().length === 0) {
|
||||
throw new Error("Private key content is empty or missing");
|
||||
}
|
||||
|
||||
if (!cert.includes("-----BEGIN CERTIFICATE-----") || !cert.includes("-----END CERTIFICATE-----")) {
|
||||
throw new Error("Certificate is not in valid PEM format");
|
||||
}
|
||||
|
||||
if (!privateKey.includes("-----BEGIN") || !privateKey.includes("-----END")) {
|
||||
throw new Error("Private key is not in valid PEM format");
|
||||
}
|
||||
};
|
||||
|
||||
const isAwsIssuedCertificate = (certificate: AWS.ACM.CertificateSummary): boolean => {
|
||||
return certificate.Type === "AMAZON_ISSUED";
|
||||
};
|
||||
|
||||
const shouldSkipCertificateExport = (certificate: AWS.ACM.CertificateSummary): boolean => {
|
||||
return isAwsIssuedCertificate(certificate);
|
||||
};
|
||||
|
||||
const findTagByKey = (tags: AWS.ACM.TagList | undefined, key: string): AWS.ACM.Tag | undefined => {
|
||||
if (!tags || !Array.isArray(tags)) {
|
||||
return undefined;
|
||||
}
|
||||
return tags.find((tag: AWS.ACM.Tag) => tag.Key === key && tag.Value);
|
||||
};
|
||||
|
||||
const findInfisicalCertificateTag = (tags: AWS.ACM.TagList | undefined): AWS.ACM.Tag | undefined => {
|
||||
return findTagByKey(tags, INFISICAL_CERTIFICATE_TAG);
|
||||
};
|
||||
|
||||
const validateCertificateIdentification = (
|
||||
certName: string,
|
||||
existingCert: { arn?: string; Tags?: AWS.ACM.TagList; cert?: string; privateKey?: string; certificateChain?: string }
|
||||
): boolean => {
|
||||
if (!existingCert?.arn || !existingCert?.Tags) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const certNameTag = findInfisicalCertificateTag(existingCert.Tags);
|
||||
|
||||
if (!certNameTag || !certNameTag.Value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return certNameTag.Value === certName;
|
||||
};
|
||||
|
||||
type TAwsCertificateManagerPkiSyncFactoryDeps = {
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
};
|
||||
|
||||
const validateCertificateNameSchema = (schema: string): void => {
|
||||
if (!schema.includes("{{certificateId}}")) {
|
||||
throw new Error(
|
||||
"Certificate name schema must include {{certificateId}} placeholder for proper certificate identification"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const generateCertificateName = (certificateName: string, pkiSync: TPkiSyncWithCredentials): string => {
|
||||
if (!certificateName || typeof certificateName !== "string") {
|
||||
throw new Error("Certificate name must be a non-empty string");
|
||||
}
|
||||
|
||||
const sanitizedCertificateName = sanitizeInput(certificateName);
|
||||
const syncOptions = pkiSync.syncOptions as { certificateNameSchema?: string } | undefined;
|
||||
const certificateNameSchema = syncOptions?.certificateNameSchema;
|
||||
|
||||
if (certificateNameSchema) {
|
||||
validateCertificateNameSchema(certificateNameSchema);
|
||||
|
||||
let certificateId: string;
|
||||
|
||||
if (sanitizedCertificateName.startsWith("Infisical-")) {
|
||||
certificateId = sanitizedCertificateName.substring("Infisical-".length);
|
||||
} else {
|
||||
certificateId = sanitizedCertificateName;
|
||||
}
|
||||
|
||||
if (!certificateId || certificateId.trim().length === 0) {
|
||||
throw new Error(`Certificate ID cannot be empty after processing certificate name: ${certificateName}`);
|
||||
}
|
||||
|
||||
const environment = "global";
|
||||
const generatedName = certificateNameSchema
|
||||
.replace(new RE2("\\{\\{certificateId\\}\\}", "g"), certificateId)
|
||||
.replace(new RE2("\\{\\{environment\\}\\}", "g"), environment);
|
||||
|
||||
if (generatedName.length > 256 || generatedName.length < 1) {
|
||||
throw new Error(
|
||||
`Generated certificate name length (${generatedName.length}) must be between 1 and 256 characters`
|
||||
);
|
||||
}
|
||||
|
||||
if (generatedName.includes("{{certificateId}}")) {
|
||||
throw new Error("Certificate name schema failed to properly replace {{certificateId}} placeholder");
|
||||
}
|
||||
|
||||
return generatedName;
|
||||
}
|
||||
|
||||
return sanitizedCertificateName;
|
||||
};
|
||||
|
||||
const getAwsAcmClient = async (
|
||||
connectionId: string,
|
||||
region: AWSRegion,
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
): Promise<AWS.ACM> => {
|
||||
const appConnection = await appConnectionDAL.findById(connectionId);
|
||||
|
||||
if (!appConnection) {
|
||||
throw new NotFoundError({ message: `Connection with ID '${connectionId}' not found` });
|
||||
}
|
||||
|
||||
if (appConnection.app !== AppConnection.AWS) {
|
||||
throw new BadRequestError({
|
||||
message: `Connection '${connectionId}' is not an AWS connection (found: ${appConnection.app})`
|
||||
});
|
||||
}
|
||||
|
||||
const decryptedCredentials = await decryptAppConnectionCredentials({
|
||||
orgId: appConnection.orgId,
|
||||
kmsService,
|
||||
encryptedCredentials: appConnection.encryptedCredentials,
|
||||
projectId: appConnection.projectId
|
||||
});
|
||||
|
||||
let awsConnectionConfig: TAwsConnectionConfig;
|
||||
switch (appConnection.method) {
|
||||
case AwsConnectionMethod.AssumeRole:
|
||||
awsConnectionConfig = {
|
||||
app: AppConnection.AWS,
|
||||
method: AwsConnectionMethod.AssumeRole,
|
||||
credentials: decryptedCredentials as TAwsAssumeRoleCredentials,
|
||||
orgId: appConnection.orgId
|
||||
};
|
||||
break;
|
||||
case AwsConnectionMethod.AccessKey:
|
||||
awsConnectionConfig = {
|
||||
app: AppConnection.AWS,
|
||||
method: AwsConnectionMethod.AccessKey,
|
||||
credentials: decryptedCredentials as TAwsAccessKeyCredentials,
|
||||
orgId: appConnection.orgId
|
||||
};
|
||||
break;
|
||||
default:
|
||||
throw new BadRequestError({
|
||||
message: `Unsupported AWS connection method: ${appConnection.method}`
|
||||
});
|
||||
}
|
||||
|
||||
const awsConfig = await getAwsConnectionConfig(awsConnectionConfig, region);
|
||||
|
||||
return new AWS.ACM(awsConfig);
|
||||
};
|
||||
|
||||
export const awsCertificateManagerPkiSyncFactory = ({
|
||||
kmsService,
|
||||
appConnectionDAL
|
||||
}: TAwsCertificateManagerPkiSyncFactoryDeps) => {
|
||||
const deleteCertificateFromAcm = async (
|
||||
acm: AWS.ACM,
|
||||
certificateArn: string,
|
||||
operation: string,
|
||||
syncId: string,
|
||||
throwOnError = false
|
||||
): Promise<{ arn: string; success: boolean; error?: Error }> => {
|
||||
try {
|
||||
await withRateLimitRetry(() => acm.deleteCertificate({ CertificateArn: certificateArn }).promise(), {
|
||||
operation,
|
||||
syncId
|
||||
});
|
||||
return { arn: certificateArn, success: true };
|
||||
} catch (error) {
|
||||
const errorObj = error instanceof Error ? error : new Error("Unknown error");
|
||||
|
||||
if (throwOnError) {
|
||||
throw new PkiSyncError({
|
||||
message: `Failed to remove certificate from AWS Certificate Manager: ${errorObj.message}`,
|
||||
cause: errorObj,
|
||||
context: {
|
||||
certificateArn,
|
||||
operation
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
arn: certificateArn,
|
||||
success: false,
|
||||
error: errorObj
|
||||
};
|
||||
}
|
||||
};
|
||||
const $getAwsAcmCertificates = async (
|
||||
acm: AWS.ACM,
|
||||
syncId = "unknown"
|
||||
): Promise<{
|
||||
acmCertificates: Record<
|
||||
string,
|
||||
{ cert: string; privateKey: string; certificateChain?: string; arn?: string; Tags?: AWS.ACM.TagList }
|
||||
>;
|
||||
}> => {
|
||||
const paginateAwsAcmCertificates = async () => {
|
||||
const certificates: AWS.ACM.CertificateSummary[] = [];
|
||||
let nextToken: string | undefined;
|
||||
|
||||
do {
|
||||
const listParams: AWS.ACM.ListCertificatesRequest = {
|
||||
CertificateStatuses: ["ISSUED"],
|
||||
NextToken: nextToken,
|
||||
MaxItems: 100
|
||||
};
|
||||
|
||||
const response = await withRateLimitRetry(() => acm.listCertificates(listParams).promise(), {
|
||||
operation: "list-certificates",
|
||||
syncId
|
||||
});
|
||||
|
||||
if (response.CertificateSummaryList) {
|
||||
certificates.push(...response.CertificateSummaryList);
|
||||
}
|
||||
nextToken = response.NextToken;
|
||||
} while (nextToken);
|
||||
|
||||
return certificates;
|
||||
};
|
||||
|
||||
const certificateSummaries = await paginateAwsAcmCertificates();
|
||||
|
||||
const certificateResults = await executeWithConcurrencyLimit(
|
||||
certificateSummaries,
|
||||
async (certSummary) => {
|
||||
if (!certSummary.CertificateArn) {
|
||||
throw new Error("Certificate ARN is missing");
|
||||
}
|
||||
|
||||
const [certificateDetails, tagsResponse] = await Promise.all([
|
||||
acm.describeCertificate({ CertificateArn: certSummary.CertificateArn }).promise(),
|
||||
acm.listTagsForCertificate({ CertificateArn: certSummary.CertificateArn }).promise()
|
||||
]);
|
||||
|
||||
let certificateContent: AWS.ACM.GetCertificateResponse | undefined;
|
||||
if (!shouldSkipCertificateExport(certSummary)) {
|
||||
try {
|
||||
certificateContent = await acm.getCertificate({ CertificateArn: certSummary.CertificateArn }).promise();
|
||||
} catch (error) {
|
||||
// Certificate content cannot be imported
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...certificateDetails.Certificate,
|
||||
Tags: tagsResponse.Tags,
|
||||
key: extractCertificateNameFromArn(certSummary.CertificateArn),
|
||||
cert: certificateContent?.Certificate || "",
|
||||
certificateChain: certificateContent?.CertificateChain || "",
|
||||
privateKey: "", // Private keys cannot be exported from ACM
|
||||
arn: certSummary.CertificateArn
|
||||
};
|
||||
},
|
||||
{ operation: "fetch-certificate-details", syncId }
|
||||
);
|
||||
|
||||
const successfulCertificates: ACMCertificateWithKey[] = [];
|
||||
certificateResults.forEach((result) => {
|
||||
if (result.status === "fulfilled") {
|
||||
successfulCertificates.push(result.value as ACMCertificateWithKey);
|
||||
}
|
||||
});
|
||||
|
||||
const failedFetches = certificateResults.filter((result) => result.status === "rejected");
|
||||
if (failedFetches.length > 0) {
|
||||
throw new PkiSyncError({
|
||||
message: `Failed to fetch ${failedFetches.length} certificate details from AWS Certificate Manager`,
|
||||
shouldRetry: true,
|
||||
context: {
|
||||
failedCount: failedFetches.length,
|
||||
totalCount: certificateSummaries.length
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const res: Record<
|
||||
string,
|
||||
{ cert: string; privateKey: string; certificateChain?: string; arn?: string; Tags?: AWS.ACM.TagList }
|
||||
> = successfulCertificates.reduce(
|
||||
(obj, certificate) => ({
|
||||
...obj,
|
||||
[certificate.key]: {
|
||||
cert: certificate.cert,
|
||||
privateKey: certificate.privateKey,
|
||||
certificateChain: certificate.certificateChain,
|
||||
arn: certificate.CertificateArn,
|
||||
Tags: certificate.Tags
|
||||
}
|
||||
}),
|
||||
{} as Record<
|
||||
string,
|
||||
{ cert: string; privateKey: string; certificateChain?: string; arn?: string; Tags?: AWS.ACM.TagList }
|
||||
>
|
||||
);
|
||||
|
||||
return {
|
||||
acmCertificates: res
|
||||
};
|
||||
};
|
||||
|
||||
const syncCertificates = async (
|
||||
pkiSync: TPkiSyncWithCredentials,
|
||||
certificateMap: TCertificateMap
|
||||
): Promise<SyncCertificatesResult> => {
|
||||
const destinationConfig = pkiSync.destinationConfig as TAwsCertificateManagerPkiSyncConfig;
|
||||
const acm = await getAwsAcmClient(
|
||||
pkiSync.connection.id,
|
||||
destinationConfig.region as AWSRegion,
|
||||
appConnectionDAL,
|
||||
kmsService
|
||||
);
|
||||
|
||||
const { acmCertificates } = await $getAwsAcmCertificates(acm, pkiSync.id);
|
||||
|
||||
const setCertificates: CertificateImportRequest[] = [];
|
||||
|
||||
const activeCertificateNames = Object.keys(certificateMap);
|
||||
|
||||
Object.entries(certificateMap).forEach(([certName, certData]) => {
|
||||
const { cert, privateKey, certificateChain } = certData;
|
||||
const certificateName = generateCertificateName(certName, pkiSync);
|
||||
|
||||
const existingCert = Object.values(acmCertificates).find((acmCert) =>
|
||||
validateCertificateIdentification(certName, acmCert)
|
||||
);
|
||||
|
||||
const shouldUpdateCert = !existingCert || existingCert.cert !== cert;
|
||||
|
||||
try {
|
||||
validateCertificateContent(cert, privateKey);
|
||||
} catch (validationError) {
|
||||
throw new PkiSyncError({
|
||||
message: `Certificate validation failed for ${certName}: ${validationError instanceof Error ? validationError.message : String(validationError)}`,
|
||||
shouldRetry: false,
|
||||
context: {
|
||||
certificateName,
|
||||
certName
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldUpdateCert) {
|
||||
setCertificates.push({
|
||||
key: certName,
|
||||
name: certificateName,
|
||||
cert,
|
||||
privateKey,
|
||||
certificateChain,
|
||||
existingArn: existingCert?.arn
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Identify expired/removed certificates that need to be cleaned up from ACM
|
||||
const certificatesToRemove = Object.values(acmCertificates)
|
||||
.filter((acmCert) => {
|
||||
if (!acmCert.arn || !acmCert.Tags) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const certNameTag = findInfisicalCertificateTag(acmCert.Tags);
|
||||
if (!certNameTag || !certNameTag.Value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isActive = activeCertificateNames.includes(certNameTag.Value);
|
||||
return !isActive;
|
||||
})
|
||||
.map((acmCert) => acmCert.arn!)
|
||||
.filter((arn) => arn);
|
||||
|
||||
const uploadResults = await executeWithConcurrencyLimit(
|
||||
setCertificates,
|
||||
async ({ key, name, cert, privateKey, certificateChain, existingArn }) => {
|
||||
try {
|
||||
const importParams: AWS.ACM.ImportCertificateRequest = {
|
||||
Certificate: cert,
|
||||
PrivateKey: privateKey,
|
||||
Tags: [
|
||||
{
|
||||
Key: INFISICAL_CERTIFICATE_TAG,
|
||||
Value: key
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
if (certificateChain && certificateChain.trim().length > 0) {
|
||||
importParams.CertificateChain = certificateChain;
|
||||
}
|
||||
if (existingArn) {
|
||||
importParams.CertificateArn = existingArn;
|
||||
}
|
||||
|
||||
const response = await withRateLimitRetry(() => acm.importCertificate(importParams).promise(), {
|
||||
operation: "import-certificate",
|
||||
syncId: pkiSync.id
|
||||
});
|
||||
|
||||
return { key, name, success: true, response };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
throw new PkiSyncError({
|
||||
message: `Failed to import certificate ${key} to AWS Certificate Manager: ${errorMessage}`,
|
||||
cause: error instanceof Error ? error : new Error(errorMessage),
|
||||
context: {
|
||||
certificateKey: key,
|
||||
certificateName: name,
|
||||
region: destinationConfig.region
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
{ operation: "import-certificates", syncId: pkiSync.id }
|
||||
);
|
||||
|
||||
const results = uploadResults;
|
||||
const failedUploads = results.filter((result) => result.status === "rejected");
|
||||
const successfulUploads = results.filter((result) => result.status === "fulfilled");
|
||||
|
||||
let removedCertificates = 0;
|
||||
let failedRemovals = 0;
|
||||
let removeResults: PromiseSettledResult<{ arn: string; success: boolean; error?: Error }>[] = [];
|
||||
|
||||
if (certificatesToRemove.length > 0) {
|
||||
removeResults = await executeWithConcurrencyLimit(
|
||||
certificatesToRemove,
|
||||
async (certificateArn) => deleteCertificateFromAcm(acm, certificateArn, "delete-certificate", pkiSync.id),
|
||||
{ operation: "remove-certificates", syncId: pkiSync.id }
|
||||
);
|
||||
|
||||
const successfulRemovals = removeResults.filter(
|
||||
(result) => result.status === "fulfilled" && result.value.success
|
||||
);
|
||||
removedCertificates = successfulRemovals.length;
|
||||
failedRemovals = removeResults.length - removedCertificates;
|
||||
}
|
||||
|
||||
const details: {
|
||||
failedUploads?: Array<{ name: string; error: string }>;
|
||||
failedRemovals?: Array<{ name: string; error: string }>;
|
||||
} = {};
|
||||
|
||||
if (failedUploads.length > 0) {
|
||||
details.failedUploads = failedUploads.map((failure, index) => {
|
||||
const certificateName = setCertificates[index]?.name || "unknown";
|
||||
let errorMessage = "Unknown error";
|
||||
|
||||
if (failure.status === "rejected") {
|
||||
errorMessage = failure.reason instanceof Error ? failure.reason.message : "Unknown error";
|
||||
}
|
||||
|
||||
return {
|
||||
name: certificateName,
|
||||
error: errorMessage
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (failedRemovals > 0 && removeResults.length > 0) {
|
||||
const actualFailedRemovals = removeResults
|
||||
.map((result, index) => {
|
||||
if (result.status === "rejected") {
|
||||
const arn = certificatesToRemove[index] || "unknown";
|
||||
const errorMessage = result.reason instanceof Error ? result.reason.message : "Unknown error";
|
||||
return {
|
||||
name: arn.includes("certificate/") ? extractCertificateNameFromArn(arn) : arn,
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((item): item is { name: string; error: string } => item !== null);
|
||||
|
||||
details.failedRemovals = actualFailedRemovals;
|
||||
}
|
||||
|
||||
return {
|
||||
uploaded: successfulUploads.length,
|
||||
removed: removedCertificates,
|
||||
failedRemovals,
|
||||
skipped: Object.keys(certificateMap).length - setCertificates.length,
|
||||
details: Object.keys(details).length > 0 ? details : undefined
|
||||
};
|
||||
};
|
||||
|
||||
const removeCertificates = async (
|
||||
pkiSync: TPkiSyncWithCredentials,
|
||||
certificateNames: string[]
|
||||
): Promise<RemoveCertificatesResult> => {
|
||||
const destinationConfig = pkiSync.destinationConfig as TAwsCertificateManagerPkiSyncConfig;
|
||||
const acm = await getAwsAcmClient(
|
||||
pkiSync.connection.id,
|
||||
destinationConfig.region as AWSRegion,
|
||||
appConnectionDAL,
|
||||
kmsService
|
||||
);
|
||||
|
||||
const { acmCertificates } = await $getAwsAcmCertificates(acm, pkiSync.id);
|
||||
|
||||
const certificateArnsToRemove: string[] = [];
|
||||
|
||||
for (const certName of certificateNames) {
|
||||
const matchingCerts = Object.values(acmCertificates).filter((acmCert) =>
|
||||
validateCertificateIdentification(certName, acmCert)
|
||||
);
|
||||
|
||||
for (const acmCert of matchingCerts) {
|
||||
if (acmCert.arn) {
|
||||
certificateArnsToRemove.push(acmCert.arn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const results = await executeWithConcurrencyLimit(
|
||||
certificateArnsToRemove,
|
||||
async (certificateArn) =>
|
||||
deleteCertificateFromAcm(acm, certificateArn, "delete-specific-certificate", pkiSync.id, true),
|
||||
{ operation: "remove-specific-certificates", syncId: pkiSync.id }
|
||||
);
|
||||
|
||||
const failedRemovals = results.filter((result) => result.status === "rejected");
|
||||
|
||||
if (failedRemovals.length > 0) {
|
||||
const failedReasons = failedRemovals.map((failure) => {
|
||||
if (failure.status === "rejected") {
|
||||
return failure.reason instanceof Error ? failure.reason.message : "Unknown error";
|
||||
}
|
||||
return "Unknown error";
|
||||
});
|
||||
|
||||
throw new PkiSyncError({
|
||||
message: `Failed to remove ${failedRemovals.length} certificate(s) from AWS Certificate Manager`,
|
||||
context: {
|
||||
failedReasons,
|
||||
totalCertificates: certificateArnsToRemove.length,
|
||||
failedCount: failedRemovals.length
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
removed: certificateArnsToRemove.length - failedRemovals.length,
|
||||
failed: failedRemovals.length,
|
||||
skipped: certificateNames.length - certificateArnsToRemove.length
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
syncCertificates,
|
||||
removeCertificates
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import RE2 from "re2";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums";
|
||||
import { PkiSync } from "@app/services/pki-sync/pki-sync-enums";
|
||||
import { PkiSyncSchema } from "@app/services/pki-sync/pki-sync-schemas";
|
||||
|
||||
import { AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING } from "./aws-certificate-manager-pki-sync-constants";
|
||||
|
||||
export const AwsCertificateManagerPkiSyncConfigSchema = z.object({
|
||||
region: z.nativeEnum(AWSRegion)
|
||||
});
|
||||
|
||||
const AwsCertificateManagerPkiSyncOptionsSchema = z.object({
|
||||
canImportCertificates: z.boolean().default(false),
|
||||
canRemoveCertificates: z.boolean().default(true),
|
||||
certificateNameSchema: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(schema) => {
|
||||
if (!schema) return true;
|
||||
|
||||
// Validate that {{certificateId}} placeholder is present
|
||||
if (!schema.includes("{{certificateId}}")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const testName = schema
|
||||
.replace(new RE2("\\{\\{certificateId\\}\\}", "g"), "test-cert-id")
|
||||
.replace(new RE2("\\{\\{environment\\}\\}", "g"), "test-env");
|
||||
|
||||
const hasForbiddenChars = AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS.split("").some(
|
||||
(char) => testName.includes(char)
|
||||
);
|
||||
|
||||
return (
|
||||
AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.NAME_PATTERN.test(testName) &&
|
||||
!hasForbiddenChars &&
|
||||
testName.length >= AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.MIN_LENGTH &&
|
||||
testName.length <= AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.MAX_LENGTH
|
||||
);
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Certificate name schema must include {{certificateId}} placeholder and result in names that contain only alphanumeric characters, spaces, hyphens, and underscores and be 1-256 characters long when compiled for AWS Certificate Manager"
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
export const AwsCertificateManagerPkiSyncSchema = PkiSyncSchema.extend({
|
||||
destination: z.literal(PkiSync.AwsCertificateManager),
|
||||
destinationConfig: AwsCertificateManagerPkiSyncConfigSchema,
|
||||
syncOptions: AwsCertificateManagerPkiSyncOptionsSchema
|
||||
});
|
||||
|
||||
export const CreateAwsCertificateManagerPkiSyncSchema = z.object({
|
||||
name: z.string().trim().min(1).max(64),
|
||||
description: z.string().optional(),
|
||||
isAutoSyncEnabled: z.boolean().default(true),
|
||||
destinationConfig: AwsCertificateManagerPkiSyncConfigSchema,
|
||||
syncOptions: AwsCertificateManagerPkiSyncOptionsSchema.optional().default({}),
|
||||
subscriberId: z.string().optional(),
|
||||
connectionId: z.string(),
|
||||
projectId: z.string().trim().min(1)
|
||||
});
|
||||
|
||||
export const UpdateAwsCertificateManagerPkiSyncSchema = z.object({
|
||||
name: z.string().trim().min(1).max(64).optional(),
|
||||
description: z.string().optional(),
|
||||
isAutoSyncEnabled: z.boolean().optional(),
|
||||
destinationConfig: AwsCertificateManagerPkiSyncConfigSchema.optional(),
|
||||
syncOptions: AwsCertificateManagerPkiSyncOptionsSchema.optional(),
|
||||
subscriberId: z.string().optional(),
|
||||
connectionId: z.string().optional()
|
||||
});
|
||||
|
||||
export const AwsCertificateManagerPkiSyncListItemSchema = z.object({
|
||||
name: z.literal("AWS Certificate Manager"),
|
||||
connection: z.literal(AppConnection.AWS),
|
||||
destination: z.literal(PkiSync.AwsCertificateManager),
|
||||
canImportCertificates: z.literal(false),
|
||||
canRemoveCertificates: z.literal(true)
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as AWS from "aws-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types";
|
||||
|
||||
import {
|
||||
AwsCertificateManagerPkiSyncConfigSchema,
|
||||
AwsCertificateManagerPkiSyncSchema,
|
||||
CreateAwsCertificateManagerPkiSyncSchema,
|
||||
UpdateAwsCertificateManagerPkiSyncSchema
|
||||
} from "./aws-certificate-manager-pki-sync-schemas";
|
||||
|
||||
export type TAwsCertificateManagerPkiSyncConfig = z.infer<typeof AwsCertificateManagerPkiSyncConfigSchema>;
|
||||
|
||||
export type TAwsCertificateManagerPkiSync = z.infer<typeof AwsCertificateManagerPkiSyncSchema>;
|
||||
|
||||
export type TAwsCertificateManagerPkiSyncInput = z.infer<typeof CreateAwsCertificateManagerPkiSyncSchema>;
|
||||
|
||||
export type TAwsCertificateManagerPkiSyncUpdate = z.infer<typeof UpdateAwsCertificateManagerPkiSyncSchema>;
|
||||
|
||||
export type TAwsCertificateManagerPkiSyncWithCredentials = TAwsCertificateManagerPkiSync & {
|
||||
connection: TAwsConnection;
|
||||
};
|
||||
|
||||
export interface ACMCertificateWithKey extends AWS.ACM.CertificateDetail {
|
||||
Tags?: AWS.ACM.TagList;
|
||||
key: string;
|
||||
cert: string;
|
||||
certificateChain: string;
|
||||
privateKey: string;
|
||||
arn?: string;
|
||||
}
|
||||
|
||||
export interface SyncCertificatesResult {
|
||||
uploaded: number;
|
||||
removed: number;
|
||||
failedRemovals: number;
|
||||
skipped: number;
|
||||
details?: {
|
||||
failedUploads?: Array<{ name: string; error: string }>;
|
||||
failedRemovals?: Array<{ name: string; error: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RemoveCertificatesResult {
|
||||
removed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export interface CertificateImportRequest {
|
||||
key: string;
|
||||
name: string;
|
||||
cert: string;
|
||||
privateKey: string;
|
||||
certificateChain?: string;
|
||||
existingArn?: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./aws-certificate-manager-pki-sync-constants";
|
||||
export * from "./aws-certificate-manager-pki-sync-fns";
|
||||
export * from "./aws-certificate-manager-pki-sync-schemas";
|
||||
export * from "./aws-certificate-manager-pki-sync-types";
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum PkiSync {
|
||||
AzureKeyVault = "azure-key-vault"
|
||||
AzureKeyVault = "azure-key-vault",
|
||||
AwsCertificateManager = "aws-certificate-manager"
|
||||
}
|
||||
|
||||
export enum PkiSyncStatus {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { BadRequestError } from "@app/lib/errors";
|
||||
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
|
||||
import { AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION } from "./aws-certificate-manager/aws-certificate-manager-pki-sync-constants";
|
||||
import { awsCertificateManagerPkiSyncFactory } from "./aws-certificate-manager/aws-certificate-manager-pki-sync-fns";
|
||||
import { AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION } from "./azure-key-vault/azure-key-vault-pki-sync-constants";
|
||||
import { azureKeyVaultPkiSyncFactory } from "./azure-key-vault/azure-key-vault-pki-sync-fns";
|
||||
import { PkiSync } from "./pki-sync-enums";
|
||||
@@ -14,7 +16,8 @@ import { TCertificateMap, TPkiSyncWithCredentials } from "./pki-sync-types";
|
||||
const ENTERPRISE_PKI_SYNCS: PkiSync[] = [];
|
||||
|
||||
const PKI_SYNC_LIST_OPTIONS = {
|
||||
[PkiSync.AzureKeyVault]: AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION
|
||||
[PkiSync.AzureKeyVault]: AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION,
|
||||
[PkiSync.AwsCertificateManager]: AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION
|
||||
};
|
||||
|
||||
export const enterprisePkiSyncCheck = async (
|
||||
@@ -144,8 +147,10 @@ export const matchesCertificateNameSchema = (name: string, environment: string,
|
||||
return name.startsWith(prefix) && name.endsWith(suffix);
|
||||
};
|
||||
|
||||
const isAzureKeyVaultPkiSync = (pkiSync: TPkiSyncWithCredentials): boolean => {
|
||||
return pkiSync.destination === PkiSync.AzureKeyVault;
|
||||
const checkPkiSyncDestination = (pkiSync: TPkiSyncWithCredentials, destination: PkiSync): void => {
|
||||
if (pkiSync.destination !== destination) {
|
||||
throw new Error(`Invalid PKI sync destination: ${pkiSync.destination}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const PkiSyncFns = {
|
||||
@@ -163,6 +168,11 @@ export const PkiSyncFns = {
|
||||
"Azure Key Vault does not support importing certificates into Infisical (private keys cannot be extracted)"
|
||||
);
|
||||
}
|
||||
case PkiSync.AwsCertificateManager: {
|
||||
throw new Error(
|
||||
"AWS Certificate Manager does not support importing certificates into Infisical (private keys cannot be extracted)"
|
||||
);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`);
|
||||
}
|
||||
@@ -188,12 +198,15 @@ export const PkiSyncFns = {
|
||||
}> => {
|
||||
switch (pkiSync.destination) {
|
||||
case PkiSync.AzureKeyVault: {
|
||||
if (!isAzureKeyVaultPkiSync(pkiSync)) {
|
||||
throw new Error("Invalid Azure Key Vault PKI sync configuration");
|
||||
}
|
||||
checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault);
|
||||
const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies);
|
||||
return azureKeyVaultPkiSync.syncCertificates(pkiSync, certificateMap);
|
||||
}
|
||||
case PkiSync.AwsCertificateManager: {
|
||||
checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager);
|
||||
const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory(dependencies);
|
||||
return awsCertificateManagerPkiSync.syncCertificates(pkiSync, certificateMap);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`);
|
||||
}
|
||||
@@ -209,13 +222,17 @@ export const PkiSyncFns = {
|
||||
): Promise<void> => {
|
||||
switch (pkiSync.destination) {
|
||||
case PkiSync.AzureKeyVault: {
|
||||
if (!isAzureKeyVaultPkiSync(pkiSync)) {
|
||||
throw new Error("Invalid Azure Key Vault PKI sync configuration");
|
||||
}
|
||||
checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault);
|
||||
const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies);
|
||||
await azureKeyVaultPkiSync.removeCertificates(pkiSync, certificateNames);
|
||||
break;
|
||||
}
|
||||
case PkiSync.AwsCertificateManager: {
|
||||
checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager);
|
||||
const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory(dependencies);
|
||||
await awsCertificateManagerPkiSync.removeCertificates(pkiSync, certificateNames);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums
|
||||
import { PkiSync } from "./pki-sync-enums";
|
||||
|
||||
export const PKI_SYNC_NAME_MAP: Record<PkiSync, string> = {
|
||||
[PkiSync.AzureKeyVault]: "Azure Key Vault"
|
||||
[PkiSync.AzureKeyVault]: "Azure Key Vault",
|
||||
[PkiSync.AwsCertificateManager]: "AWS Certificate Manager"
|
||||
};
|
||||
|
||||
export const PKI_SYNC_CONNECTION_MAP: Record<PkiSync, AppConnection> = {
|
||||
[PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault
|
||||
[PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault,
|
||||
[PkiSync.AwsCertificateManager]: AppConnection.AWS
|
||||
};
|
||||
|
||||
@@ -204,5 +204,19 @@ export const secretSyncDALFactory = (
|
||||
}
|
||||
};
|
||||
|
||||
return { ...secretSyncOrm, findById, findOne, find, create, updateById };
|
||||
const findByDestinationAndOrgId = async (destination: string, orgId: string, tx?: Knex) => {
|
||||
try {
|
||||
const response = await (tx || db.replicaNode())(TableName.SecretSync)
|
||||
.join(TableName.Project, `${TableName.SecretSync}.projectId`, `${TableName.Project}.id`)
|
||||
.where(`${TableName.SecretSync}.destination`, destination)
|
||||
.where(`${TableName.Project}.orgId`, orgId)
|
||||
.select(selectAllTableCols(TableName.SecretSync));
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find By Destination And Org ID - Secret Sync" });
|
||||
}
|
||||
};
|
||||
|
||||
return { ...secretSyncOrm, findById, findOne, find, create, updateById, findByDestinationAndOrgId };
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { SecretSync, SecretSyncPlanType } from "@app/services/secret-sync/secret-sync-enums";
|
||||
import { DestinationDuplicateCheckFn } from "@app/services/secret-sync/secret-sync-types";
|
||||
|
||||
export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
|
||||
[SecretSync.AWSParameterStore]: "AWS Parameter Store",
|
||||
@@ -99,3 +100,104 @@ export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
|
||||
[SecretSync.Netlify]: SecretSyncPlanType.Regular,
|
||||
[SecretSync.Bitbucket]: SecretSyncPlanType.Regular
|
||||
};
|
||||
|
||||
export const SECRET_SYNC_SKIP_FIELDS_MAP: Record<SecretSync, string[]> = {
|
||||
[SecretSync.AWSParameterStore]: [],
|
||||
[SecretSync.AWSSecretsManager]: ["mappingBehavior", "secretName"],
|
||||
[SecretSync.GitHub]: [],
|
||||
[SecretSync.GCPSecretManager]: [],
|
||||
[SecretSync.AzureKeyVault]: [],
|
||||
[SecretSync.AzureAppConfiguration]: ["label"],
|
||||
[SecretSync.AzureDevOps]: ["devopsProjectName"],
|
||||
[SecretSync.Databricks]: [],
|
||||
[SecretSync.Humanitec]: [],
|
||||
[SecretSync.TerraformCloud]: ["variableSetName", "workspaceName"],
|
||||
[SecretSync.Camunda]: [],
|
||||
[SecretSync.Vercel]: ["appName"],
|
||||
[SecretSync.Windmill]: [],
|
||||
[SecretSync.HCVault]: [],
|
||||
[SecretSync.TeamCity]: [],
|
||||
[SecretSync.OCIVault]: [],
|
||||
[SecretSync.OnePass]: ["valueLabel"],
|
||||
[SecretSync.Heroku]: ["appName"],
|
||||
[SecretSync.Render]: [],
|
||||
[SecretSync.Flyio]: [],
|
||||
[SecretSync.GitLab]: [
|
||||
"projectName",
|
||||
"shouldProtectSecrets",
|
||||
"shouldMaskSecrets",
|
||||
"shouldHideSecrets",
|
||||
"targetEnvironment",
|
||||
"groupName",
|
||||
"groupId",
|
||||
"projectId"
|
||||
],
|
||||
[SecretSync.CloudflarePages]: [],
|
||||
[SecretSync.CloudflareWorkers]: [],
|
||||
[SecretSync.Supabase]: ["projectName"],
|
||||
[SecretSync.Zabbix]: ["hostName", "macroType"],
|
||||
[SecretSync.Railway]: ["projectName", "environmentName", "serviceName"],
|
||||
[SecretSync.Checkly]: ["groupName", "accountName"],
|
||||
[SecretSync.DigitalOceanAppPlatform]: ["appName"],
|
||||
[SecretSync.Netlify]: ["accountName", "siteName"],
|
||||
[SecretSync.Bitbucket]: []
|
||||
};
|
||||
|
||||
const defaultDuplicateCheck: DestinationDuplicateCheckFn = () => true;
|
||||
|
||||
export const DESTINATION_DUPLICATE_CHECK_MAP: Record<SecretSync, DestinationDuplicateCheckFn> = {
|
||||
[SecretSync.AWSParameterStore]: defaultDuplicateCheck,
|
||||
[SecretSync.AWSSecretsManager]: defaultDuplicateCheck,
|
||||
[SecretSync.GitHub]: defaultDuplicateCheck,
|
||||
[SecretSync.GCPSecretManager]: defaultDuplicateCheck,
|
||||
[SecretSync.AzureKeyVault]: defaultDuplicateCheck,
|
||||
[SecretSync.AzureAppConfiguration]: defaultDuplicateCheck,
|
||||
[SecretSync.AzureDevOps]: defaultDuplicateCheck,
|
||||
[SecretSync.Databricks]: defaultDuplicateCheck,
|
||||
[SecretSync.Humanitec]: defaultDuplicateCheck,
|
||||
[SecretSync.TerraformCloud]: defaultDuplicateCheck,
|
||||
[SecretSync.Camunda]: defaultDuplicateCheck,
|
||||
[SecretSync.Vercel]: defaultDuplicateCheck,
|
||||
[SecretSync.Windmill]: defaultDuplicateCheck,
|
||||
[SecretSync.HCVault]: defaultDuplicateCheck,
|
||||
[SecretSync.TeamCity]: defaultDuplicateCheck,
|
||||
[SecretSync.OCIVault]: defaultDuplicateCheck,
|
||||
[SecretSync.OnePass]: defaultDuplicateCheck,
|
||||
[SecretSync.Heroku]: defaultDuplicateCheck,
|
||||
[SecretSync.Render]: defaultDuplicateCheck,
|
||||
[SecretSync.Flyio]: defaultDuplicateCheck,
|
||||
[SecretSync.GitLab]: (existingConfig, newConfig) => {
|
||||
const existingTargetEnv = existingConfig.targetEnvironment as string | undefined;
|
||||
const newTargetEnv = newConfig.targetEnvironment as string | undefined;
|
||||
|
||||
const wildcardValues = ["*", ""];
|
||||
|
||||
if (
|
||||
(newConfig.scope as string) === "group"
|
||||
? existingConfig.groupId !== newConfig.groupId
|
||||
: existingConfig.projectId !== newConfig.projectId
|
||||
)
|
||||
return false;
|
||||
|
||||
// If either has wildcard, it conflicts with any targetEnvironment
|
||||
if (
|
||||
!existingTargetEnv ||
|
||||
!newTargetEnv ||
|
||||
wildcardValues.includes(existingTargetEnv) ||
|
||||
wildcardValues.includes(newTargetEnv)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return existingTargetEnv === newTargetEnv;
|
||||
},
|
||||
[SecretSync.CloudflarePages]: defaultDuplicateCheck,
|
||||
[SecretSync.CloudflareWorkers]: defaultDuplicateCheck,
|
||||
[SecretSync.Supabase]: defaultDuplicateCheck,
|
||||
[SecretSync.Zabbix]: defaultDuplicateCheck,
|
||||
[SecretSync.Railway]: defaultDuplicateCheck,
|
||||
[SecretSync.Checkly]: defaultDuplicateCheck,
|
||||
[SecretSync.DigitalOceanAppPlatform]: defaultDuplicateCheck,
|
||||
[SecretSync.Netlify]: defaultDuplicateCheck,
|
||||
[SecretSync.Bitbucket]: defaultDuplicateCheck
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import { DatabaseErrorCode } from "@app/lib/error-codes";
|
||||
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
|
||||
import { deepEqualSkipFields } from "@app/lib/fn/object";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
|
||||
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
||||
@@ -20,6 +21,7 @@ import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||
import { enterpriseSyncCheck, listSecretSyncOptions } from "@app/services/secret-sync/secret-sync-fns";
|
||||
import {
|
||||
SecretSyncStatus,
|
||||
TCheckDuplicateDestinationDTO,
|
||||
TCreateSecretSyncDTO,
|
||||
TDeleteSecretSyncDTO,
|
||||
TFindSecretSyncByIdDTO,
|
||||
@@ -35,7 +37,12 @@ import {
|
||||
|
||||
import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
|
||||
import { TSecretSyncDALFactory } from "./secret-sync-dal";
|
||||
import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "./secret-sync-maps";
|
||||
import {
|
||||
DESTINATION_DUPLICATE_CHECK_MAP,
|
||||
SECRET_SYNC_CONNECTION_MAP,
|
||||
SECRET_SYNC_NAME_MAP,
|
||||
SECRET_SYNC_SKIP_FIELDS_MAP
|
||||
} from "./secret-sync-maps";
|
||||
import { TSecretSyncQueueFactory } from "./secret-sync-queue";
|
||||
|
||||
type TSecretSyncServiceFactoryDep = {
|
||||
@@ -696,6 +703,61 @@ export const secretSyncServiceFactory = ({
|
||||
return updatedSecretSync as TSecretSync;
|
||||
};
|
||||
|
||||
const checkDuplicateDestination = async (
|
||||
{ destination, destinationConfig, excludeSyncId, projectId }: TCheckDuplicateDestinationDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const skipFields = SECRET_SYNC_SKIP_FIELDS_MAP[destination];
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId,
|
||||
actionProjectType: ActionProjectType.SecretManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretSyncActions.Read,
|
||||
ProjectPermissionSub.SecretSyncs
|
||||
);
|
||||
|
||||
if (!destinationConfig || Object.keys(destinationConfig).length === 0) {
|
||||
return { hasDuplicate: false, duplicateProjectId: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
const existingSyncs = await secretSyncDAL.findByDestinationAndOrgId(destination, actor.orgId);
|
||||
|
||||
const duplicates = existingSyncs.filter((sync) => {
|
||||
if (sync.id === excludeSyncId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const baseFieldsMatch = deepEqualSkipFields(sync.destinationConfig, destinationConfig, skipFields);
|
||||
if (baseFieldsMatch) {
|
||||
return DESTINATION_DUPLICATE_CHECK_MAP[destination](
|
||||
sync.destinationConfig as Record<string, unknown>,
|
||||
destinationConfig
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const hasDuplicate = duplicates.length > 0;
|
||||
return {
|
||||
hasDuplicate,
|
||||
duplicateProjectId: hasDuplicate ? duplicates[0].projectId : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
return { hasDuplicate: false, duplicateProjectId: undefined };
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
listSecretSyncOptions,
|
||||
listSecretSyncsByProjectId,
|
||||
@@ -707,6 +769,7 @@ export const secretSyncServiceFactory = ({
|
||||
deleteSecretSync,
|
||||
triggerSecretSyncSyncSecretsById,
|
||||
triggerSecretSyncImportSecretsById,
|
||||
triggerSecretSyncRemoveSecretsById
|
||||
triggerSecretSyncRemoveSecretsById,
|
||||
checkDuplicateDestination
|
||||
};
|
||||
};
|
||||
|
||||
@@ -324,6 +324,13 @@ export type TDeleteSecretSyncDTO = {
|
||||
removeSecrets: boolean;
|
||||
};
|
||||
|
||||
export type TCheckDuplicateDestinationDTO = {
|
||||
destination: SecretSync;
|
||||
destinationConfig: Record<string, unknown>;
|
||||
excludeSyncId?: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export enum SecretSyncStatus {
|
||||
Pending = "pending",
|
||||
Running = "running",
|
||||
@@ -408,3 +415,8 @@ export type TSecretMap = Record<
|
||||
secretMetadata?: ResourceMetadataDTO;
|
||||
}
|
||||
>;
|
||||
|
||||
export type DestinationDuplicateCheckFn = (
|
||||
existingConfig: Record<string, unknown>,
|
||||
newConfig: Record<string, unknown>
|
||||
) => boolean;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Heading, Section, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
|
||||
import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper";
|
||||
|
||||
interface AccountDeletionConfirmationTemplateProps
|
||||
extends Omit<BaseEmailWrapperProps, "title" | "preview" | "children"> {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export const AccountDeletionConfirmationTemplate = ({ email, siteUrl }: AccountDeletionConfirmationTemplateProps) => {
|
||||
return (
|
||||
<BaseEmailWrapper
|
||||
title="Your Infisical Account Has Been Deleted"
|
||||
preview="Confirmation that your account and associated data have been deleted."
|
||||
siteUrl={siteUrl}
|
||||
>
|
||||
<Heading className="text-black text-[18px] leading-[28px] text-center font-normal p-0 mx-0">
|
||||
Account Deleted
|
||||
</Heading>
|
||||
<Section className="px-[24px] mt-[20px] pt-[12px] pb-[8px] border border-solid border-gray-200 rounded-md bg-gray-50">
|
||||
<Text className="text-[14px]">
|
||||
This email confirms that your Infisical account <strong>{email}</strong> has been deleted, including all
|
||||
associated data.
|
||||
</Text>
|
||||
</Section>
|
||||
</BaseEmailWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountDeletionConfirmationTemplate;
|
||||
|
||||
AccountDeletionConfirmationTemplate.PreviewProps = {
|
||||
email: "test@infisical.com",
|
||||
siteUrl: "https://infisical.com"
|
||||
} as AccountDeletionConfirmationTemplateProps;
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./AccessApprovalRequestTemplate";
|
||||
export * from "./AccessApprovalRequestUpdatedTemplate";
|
||||
export * from "./AccountDeletionConfirmationTemplate";
|
||||
export * from "./EmailMfaTemplate";
|
||||
export * from "./EmailVerificationTemplate";
|
||||
export * from "./ExternalImportFailedTemplate";
|
||||
|
||||
@@ -9,6 +9,7 @@ import { logger } from "@app/lib/logger";
|
||||
import {
|
||||
AccessApprovalRequestTemplate,
|
||||
AccessApprovalRequestUpdatedTemplate,
|
||||
AccountDeletionConfirmationTemplate,
|
||||
EmailMfaTemplate,
|
||||
EmailVerificationTemplate,
|
||||
ExternalImportFailedTemplate,
|
||||
@@ -83,7 +84,8 @@ export enum SmtpTemplates {
|
||||
OrgAdminBreakglassAccess = "orgAdminBreakglassAccess",
|
||||
ServiceTokenExpired = "serviceTokenExpired",
|
||||
SecretScanningV2ScanFailed = "secretScanningV2ScanFailed",
|
||||
SecretScanningV2SecretsDetected = "secretScanningV2SecretsDetected"
|
||||
SecretScanningV2SecretsDetected = "secretScanningV2SecretsDetected",
|
||||
AccountDeletionConfirmation = "accountDeletionConfirmation"
|
||||
}
|
||||
|
||||
export enum SmtpHost {
|
||||
@@ -128,7 +130,8 @@ const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = {
|
||||
[SmtpTemplates.SetupPassword]: PasswordSetupTemplate,
|
||||
[SmtpTemplates.PkiExpirationAlert]: PkiExpirationAlertTemplate,
|
||||
[SmtpTemplates.SecretScanningV2ScanFailed]: SecretScanningScanFailedTemplate,
|
||||
[SmtpTemplates.SecretScanningV2SecretsDetected]: SecretScanningSecretsDetectedTemplate
|
||||
[SmtpTemplates.SecretScanningV2SecretsDetected]: SecretScanningSecretsDetectedTemplate,
|
||||
[SmtpTemplates.AccountDeletionConfirmation]: AccountDeletionConfirmationTemplate
|
||||
};
|
||||
|
||||
export const smtpServiceFactory = (cfg: TSmtpConfig) => {
|
||||
|
||||
@@ -347,6 +347,23 @@ export const userServiceFactory = ({
|
||||
|
||||
const deleteUser = async (userId: string) => {
|
||||
const user = await userDAL.deleteById(userId);
|
||||
|
||||
try {
|
||||
if (user?.email) {
|
||||
// Send email to user to confirm account deletion
|
||||
await smtpService.sendMail({
|
||||
template: SmtpTemplates.AccountDeletionConfirmation,
|
||||
subjectLine: "Your Infisical account has been deleted",
|
||||
recipients: [user.email],
|
||||
substitutions: {
|
||||
email: user.email
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to send account deletion confirmation email to ${user.email}`);
|
||||
}
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
|
||||
@@ -197,4 +197,4 @@ volumes:
|
||||
driver: local
|
||||
ldap_data:
|
||||
ldap_config:
|
||||
grafana_storage:
|
||||
grafana_storage:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Create AWS Certificate Manager PKI Sync"
|
||||
openapi: "POST /api/v1/pki/syncs/aws-certificate-manager"
|
||||
---
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user