Merge branch 'Infisical:main' into main

This commit is contained in:
Thalles Passos
2025-10-08 10:13:19 -03:00
committed by GitHub
270 changed files with 17394 additions and 1703 deletions
@@ -65,6 +65,15 @@ jobs:
INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }} INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }}
DD_GIT_REPOSITORY_URL=${{ github.server_url }}/${{ github.repository }} DD_GIT_REPOSITORY_URL=${{ github.server_url }}/${{ github.repository }}
DD_GIT_COMMIT_SHA=${{ github.sha }} 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: infisical-fips-standalone:
name: Build infisical standalone image postgres name: Build infisical standalone image postgres
@@ -141,4 +150,4 @@ jobs:
echo "Successfully created tag $TAG_NAME" echo "Successfully created tag $TAG_NAME"
fi fi
env: env:
GH_TOKEN: ${{ secrets.OMNIBUS_RELEASE_TOKEN }} GH_TOKEN: ${{ secrets.OMNIBUS_RELEASE_TOKEN }}
+5 -2
View File
@@ -3,7 +3,10 @@ ARG POSTHOG_API_KEY=posthog-api-key
ARG INTERCOM_ID=intercom-id ARG INTERCOM_ID=intercom-id
ARG CAPTCHA_SITE_KEY=captcha-site-key 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 FROM base AS frontend-dependencies
WORKDIR /app WORKDIR /app
@@ -155,7 +158,7 @@ RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \
# Install Infisical CLI # Install Infisical CLI
RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \ 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/* && rm -rf /var/lib/apt/lists/*
RUN groupadd -r -g 1001 nodejs && useradd -r -u 1001 -g nodejs non-root-user RUN groupadd -r -g 1001 nodejs && useradd -r -u 1001 -g nodejs non-root-user
+5 -2
View File
@@ -3,7 +3,10 @@ ARG POSTHOG_API_KEY=posthog-api-key
ARG INTERCOM_ID=intercom-id ARG INTERCOM_ID=intercom-id
ARG CAPTCHA_SITE_KEY=captcha-site-key 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 FROM base AS frontend-dependencies
@@ -139,7 +142,7 @@ RUN apt-get update && apt-get install -y \
# Install Infisical CLI # Install Infisical CLI
RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \ 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/* && rm -rf /var/lib/apt/lists/*
WORKDIR / WORKDIR /
+2 -2
View File
@@ -1,5 +1,5 @@
# Build stage # Build stage
FROM node:20-slim AS build FROM node:20.19.5-trixie-slim AS build
WORKDIR /app WORKDIR /app
@@ -26,7 +26,7 @@ COPY . .
RUN npm run build RUN npm run build
# Production stage # Production stage
FROM node:20-slim FROM node:20.19.5-trixie-slim
WORKDIR /app WORKDIR /app
ENV npm_config_cache /home/node/.npm ENV npm_config_cache /home/node/.npm
+1 -1
View File
@@ -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. # ? Setup a test SoftHSM module. In production a real HSM is used.
+1 -1
View File
@@ -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. # ? Setup a test SoftHSM module. In production a real HSM is used.
+4249 -625
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -79,12 +79,17 @@
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"overrides": {
"cipher-base": "1.0.5",
"sha.js": "2.4.12"
},
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.18.10", "@babel/cli": "^7.18.10",
"@babel/core": "^7.18.10", "@babel/core": "^7.18.10",
"@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-attributes": "^7.24.7",
"@babel/preset-env": "^7.18.10", "@babel/preset-env": "^7.18.10",
"@babel/preset-react": "^7.24.7", "@babel/preset-react": "^7.24.7",
"@react-email/preview-server": "^4.3.0",
"@smithy/types": "^4.3.1", "@smithy/types": "^4.3.1",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/jmespath": "^0.15.2", "@types/jmespath": "^0.15.2",
@@ -120,7 +125,7 @@
"nodemon": "^3.0.2", "nodemon": "^3.0.2",
"pino-pretty": "^10.2.3", "pino-pretty": "^10.2.3",
"prompt-sync": "^4.2.0", "prompt-sync": "^4.2.0",
"react-email": "4.0.7", "react-email": "^4.3.0",
"rimraf": "^5.0.5", "rimraf": "^5.0.5",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsc-alias": "^1.8.8", "tsc-alias": "^1.8.8",
@@ -138,7 +143,7 @@
"@aws-sdk/client-secrets-manager": "^3.504.0", "@aws-sdk/client-secrets-manager": "^3.504.0",
"@aws-sdk/client-sts": "^3.600.0", "@aws-sdk/client-sts": "^3.600.0",
"@casl/ability": "^6.5.0", "@casl/ability": "^6.5.0",
"@elastic/elasticsearch": "^8.15.0", "@elastic/elasticsearch": "^9.1.1",
"@fastify/cookie": "^9.3.1", "@fastify/cookie": "^9.3.1",
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
"@fastify/etag": "^5.1.0", "@fastify/etag": "^5.1.0",
@@ -185,7 +190,7 @@
"ajv": "^8.12.0", "ajv": "^8.12.0",
"argon2": "^0.31.2", "argon2": "^0.31.2",
"aws-sdk": "^2.1553.0", "aws-sdk": "^2.1553.0",
"axios": "^1.11.0", "axios": "^1.12.0",
"axios-ntlm": "^1.4.4", "axios-ntlm": "^1.4.4",
"axios-retry": "^4.0.0", "axios-retry": "^4.0.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
@@ -196,7 +201,7 @@
"cron": "^3.1.7", "cron": "^3.1.7",
"dd-trace": "^5.40.0", "dd-trace": "^5.40.0",
"dotenv": "^16.4.1", "dotenv": "^16.4.1",
"fastify": "^4.28.1", "fastify": "^4.29.1",
"fastify-plugin": "^4.5.1", "fastify-plugin": "^4.5.1",
"google-auth-library": "^9.9.0", "google-auth-library": "^9.9.0",
"googleapis": "^137.1.0", "googleapis": "^137.1.0",
+8
View File
@@ -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 { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TOidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-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 { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { TPitServiceFactory } from "@app/ee/services/pit/pit-service"; import { TPitServiceFactory } from "@app/ee/services/pit/pit-service";
import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-types"; import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-types";
@@ -315,6 +319,10 @@ declare module "fastify" {
identityAuthTemplate: TIdentityAuthTemplateServiceFactory; identityAuthTemplate: TIdentityAuthTemplateServiceFactory;
notification: TNotificationServiceFactory; notification: TNotificationServiceFactory;
offlineUsageReport: TOfflineUsageReportServiceFactory; offlineUsageReport: TOfflineUsageReportServiceFactory;
pamFolder: TPamFolderServiceFactory;
pamResource: TPamResourceServiceFactory;
pamAccount: TPamAccountServiceFactory;
pamSession: TPamSessionServiceFactory;
upgradePath: TUpgradePathService; upgradePath: TUpgradePathService;
}; };
// this is exclusive use for middlewares in which we need to inject data // this is exclusive use for middlewares in which we need to inject data
+8
View File
@@ -530,6 +530,10 @@ import {
TMicrosoftTeamsIntegrationsInsert, TMicrosoftTeamsIntegrationsInsert,
TMicrosoftTeamsIntegrationsUpdate TMicrosoftTeamsIntegrationsUpdate
} from "@app/db/schemas/microsoft-teams-integrations"; } 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 { import {
TProjectMicrosoftTeamsConfigs, TProjectMicrosoftTeamsConfigs,
TProjectMicrosoftTeamsConfigsInsert, TProjectMicrosoftTeamsConfigsInsert,
@@ -1308,5 +1312,9 @@ declare module "knex/types/tables" {
TKeyValueStoreInsert, TKeyValueStoreInsert,
TKeyValueStoreUpdate 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 // Insert New Commits in batches of 9000
const newCommits = foldersCommitsList.map((folderCommit) => folderCommit.commit); const newCommits = foldersCommitsList.map((folderCommit) => folderCommit.commit);
const commitBatches = chunkArray(newCommits, 9000); const commitBatches = chunkArray(newCommits, 9000);
let pendingDeepTreeCommitResources: TFolderCommits[] = [];
let j = 0; let j = 0;
for (const commitBatch of commitBatches) { for (const commitBatch of commitBatches) {
j += 1; j += 1;
@@ -265,12 +265,14 @@ export async function up(knex: Knex): Promise<void> {
}); });
// Create folder commit changes // Create folder commit changes
const currentBatchFolderIds = new Set(newCommitsInserted.map((commit) => commit.folderId));
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await knex.batchInsert( await knex.batchInsert(
TableName.FolderCommitChanges, TableName.FolderCommitChanges,
foldersCommitsList foldersCommitsList
.map((folderCommit) => folderCommit.changes) .map((folderCommit) => folderCommit.changes)
.flat() .flat()
.filter((change) => currentBatchFolderIds.has(change.folderId))
.map((change) => ({ .map((change) => ({
folderCommitId: newCommitsMap[change.folderId], folderCommitId: newCommitsMap[change.folderId],
changeType: change.changeType, changeType: change.changeType,
@@ -289,6 +291,7 @@ export async function up(knex: Knex): Promise<void> {
foldersCommitsList foldersCommitsList
.map((folderCommit) => folderCommit.changes) .map((folderCommit) => folderCommit.changes)
.flat() .flat()
.filter((change) => currentBatchFolderIds.has(change.folderId))
.map((change) => ({ .map((change) => ({
folderCheckpointId: newCheckpointsMap[change.folderId], folderCheckpointId: newCheckpointsMap[change.folderId],
folderVersionId: change.folderVersionId, folderVersionId: change.folderVersionId,
@@ -303,9 +306,11 @@ export async function up(knex: Knex): Promise<void> {
const newTreeCheckpoints = (await knex const newTreeCheckpoints = (await knex
.batchInsert( .batchInsert(
TableName.FolderTreeCheckpoint, TableName.FolderTreeCheckpoint,
Object.keys(rootFoldersMap).map((folderId) => ({ Object.keys(rootFoldersMap)
folderCommitId: newCommitsMap[folderId] .filter((folderId) => currentBatchFolderIds.has(folderId))
})) .map((folderId) => ({
folderCommitId: newCommitsMap[folderId]
}))
) )
.returning("*")) as TFolderTreeCheckpoints[]; .returning("*")) as TFolderTreeCheckpoints[];
@@ -317,18 +322,31 @@ export async function up(knex: Knex): Promise<void> {
}); });
// Create Folder Tree Checkpoint Resources // Create Folder Tree Checkpoint Resources
// eslint-disable-next-line no-await-in-loop const commitsToProcess = pendingDeepTreeCommitResources.concat(newCommitsInserted);
await knex const unprocessableCommits: TFolderCommits[] = [];
.batchInsert( const processableCommits = commitsToProcess.filter((folderCommit) => {
TableName.FolderTreeCheckpointResources, const isProcessable = newTreeCheckpointsMap[folderCommit.envId];
newCommitsInserted.map((folderCommit) => ({ if (!isProcessable) {
folderTreeCheckpointId: newTreeCheckpointsMap[folderCommit.envId], unprocessableCommits.push(folderCommit);
folderId: folderCommit.folderId, }
folderCommitId: folderCommit.id return isProcessable;
})) });
)
.returning("*");
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}`); logger.info(`Finished inserting folder tree checkpoint resources - batch ${j} of ${commitBatches.length}`);
} }
} }
@@ -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");
});
}
}
+4 -1
View File
@@ -5,6 +5,8 @@
import { z } from "zod"; import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models"; import { TImmutableDBKeys } from "./models";
export const GatewaysV2Schema = z.object({ export const GatewaysV2Schema = z.object({
@@ -15,7 +17,8 @@ export const GatewaysV2Schema = z.object({
identityId: z.string().uuid(), identityId: z.string().uuid(),
relayId: z.string().uuid().nullable().optional(), relayId: z.string().uuid().nullable().optional(),
name: z.string(), name: z.string(),
heartbeat: z.date().nullable().optional() heartbeat: z.date().nullable().optional(),
encryptedPamSessionKey: zodBuffer.nullable().optional()
}); });
export type TGatewaysV2 = z.infer<typeof GatewaysV2Schema>; export type TGatewaysV2 = z.infer<typeof GatewaysV2Schema>;
+4
View File
@@ -83,6 +83,10 @@ export * from "./org-memberships";
export * from "./org-relay-config"; export * from "./org-relay-config";
export * from "./org-roles"; export * from "./org-roles";
export * from "./organizations"; 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-alerts";
export * from "./pki-collection-items"; export * from "./pki-collection-items";
export * from "./pki-collections"; export * from "./pki-collections";
+10 -2
View File
@@ -189,7 +189,13 @@ export enum TableName {
Relay = "relays", Relay = "relays",
GatewayV2 = "gateways_v2", 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"; export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId";
@@ -281,7 +287,8 @@ export enum ProjectType {
CertificateManager = "cert-manager", CertificateManager = "cert-manager",
KMS = "kms", KMS = "kms",
SSH = "ssh", SSH = "ssh",
SecretScanning = "secret-scanning" SecretScanning = "secret-scanning",
PAM = "pam"
} }
export enum ActionProjectType { export enum ActionProjectType {
@@ -290,6 +297,7 @@ export enum ActionProjectType {
KMS = ProjectType.KMS, KMS = ProjectType.KMS,
SSH = ProjectType.SSH, SSH = ProjectType.SSH,
SecretScanning = ProjectType.SecretScanning, SecretScanning = ProjectType.SecretScanning,
PAM = ProjectType.PAM,
// project operations that happen on all types // project operations that happen on all types
Any = "any" Any = "any"
} }
+26
View 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
View 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
View 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
View 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>>;
+2 -1
View File
@@ -28,7 +28,8 @@ export const SamlConfigsSchema = z.object({
lastUsed: z.date().nullable().optional(), lastUsed: z.date().nullable().optional(),
encryptedSamlEntryPoint: zodBuffer, encryptedSamlEntryPoint: zodBuffer,
encryptedSamlIssuer: zodBuffer, encryptedSamlIssuer: zodBuffer,
encryptedSamlCertificate: zodBuffer encryptedSamlCertificate: zodBuffer,
enableGroupSync: z.boolean().default(false)
}); });
export type TSamlConfigs = z.infer<typeof SamlConfigsSchema>; export type TSamlConfigs = z.infer<typeof SamlConfigsSchema>;
+42
View File
@@ -23,6 +23,12 @@ import { registerLdapRouter } from "./ldap-router";
import { registerLicenseRouter } from "./license-router"; import { registerLicenseRouter } from "./license-router";
import { registerOidcRouter } from "./oidc-router"; import { registerOidcRouter } from "./oidc-router";
import { registerOrgRoleRouter } from "./org-role-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 { registerPITRouter } from "./pit-router";
import { registerProjectRoleRouter } from "./project-role-router"; import { registerProjectRoleRouter } from "./project-role-router";
import { registerProjectRouter } from "./project-router"; import { registerProjectRouter } from "./project-router";
@@ -166,4 +172,40 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
}, },
{ prefix: "/kmip" } { 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" }
);
}; };
@@ -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;
}
});
};
@@ -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 };
}
});
};
@@ -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;
}
});
};
@@ -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;
}
});
};
+10 -5
View File
@@ -286,7 +286,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
entryPoint: z.string(), entryPoint: z.string(),
issuer: z.string(), issuer: z.string(),
cert: 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), isActive: z.boolean().describe(SamlSso.CREATE_CONFIG.isActive),
entryPoint: z.string().trim().describe(SamlSso.CREATE_CONFIG.entryPoint), entryPoint: z.string().trim().describe(SamlSso.CREATE_CONFIG.entryPoint),
issuer: z.string().trim().describe(SamlSso.CREATE_CONFIG.issuer), 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: { response: {
200: SanitizedSamlConfigSchema 200: SanitizedSamlConfigSchema
} }
}, },
handler: async (req) => { handler: async (req) => {
const { isActive, authProvider, issuer, entryPoint, cert } = req.body; const { isActive, authProvider, issuer, entryPoint, cert, enableGroupSync } = req.body;
const { permission } = req; const { permission } = req;
return server.services.saml.createSamlCfg({ return server.services.saml.createSamlCfg({
@@ -341,6 +343,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
issuer, issuer,
entryPoint, entryPoint,
idpCert: cert, idpCert: cert,
enableGroupSync,
actor: permission.type, actor: permission.type,
actorId: permission.id, actorId: permission.id,
actorAuthMethod: permission.authMethod, actorAuthMethod: permission.authMethod,
@@ -372,7 +375,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
isActive: z.boolean().describe(SamlSso.UPDATE_CONFIG.isActive), isActive: z.boolean().describe(SamlSso.UPDATE_CONFIG.isActive),
entryPoint: z.string().trim().describe(SamlSso.UPDATE_CONFIG.entryPoint), entryPoint: z.string().trim().describe(SamlSso.UPDATE_CONFIG.entryPoint),
issuer: z.string().trim().describe(SamlSso.UPDATE_CONFIG.issuer), 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() .partial()
.merge(z.object({ organizationId: z.string().trim().describe(SamlSso.UPDATE_CONFIG.organizationId) })), .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) => { handler: async (req) => {
const { isActive, authProvider, issuer, entryPoint, cert } = req.body; const { isActive, authProvider, issuer, entryPoint, cert, enableGroupSync } = req.body;
const { permission } = req; const { permission } = req;
return server.services.saml.updateSamlCfg({ return server.services.saml.updateSamlCfg({
@@ -390,6 +394,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
issuer, issuer,
entryPoint, entryPoint,
idpCert: cert, idpCert: cert,
enableGroupSync,
actor: permission.type, actor: permission.type,
actorId: permission.id, actorId: permission.id,
actorAuthMethod: permission.authMethod, actorAuthMethod: permission.authMethod,
@@ -1,6 +1,7 @@
import z from "zod"; import z from "zod";
import { GatewaysV2Schema } from "@app/db/schemas"; import { GatewaysV2Schema } from "@app/db/schemas";
import { zodBuffer } from "@app/lib/zod";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas"; import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -130,4 +131,25 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => {
return gateway; 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_LIST_SECRETS = "dashboard-list-secrets",
DASHBOARD_GET_SECRET_VALUE = "dashboard-get-secret-value", 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[] = [ 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 = export type Event =
| GetSecretsEvent | GetSecretsEvent
| GetSecretEvent | GetSecretEvent
@@ -4020,4 +4195,22 @@ export type Event =
| ProjectRoleDeleteEvent | ProjectRoleDeleteEvent
| OrgRoleCreateEvent | OrgRoleCreateEvent
| OrgRoleUpdateEvent | 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 $getClient = async (providerInputs: z.infer<typeof DynamicSecretElasticSearchSchema>) => {
const connection = new ElasticSearchClient({ const connection = new ElasticSearchClient({
requestTimeout: 30_000,
node: { node: {
url: new URL(`${providerInputs.host}:${providerInputs.port}`), url: new URL(`${providerInputs.host}:${providerInputs.port}`),
...(providerInputs.ca && { ...(providerInputs.ca && {
@@ -1,2 +1,3 @@
export const GATEWAY_ROUTING_INFO_OID = "1.3.6.1.4.1.12345.100.1"; 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 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 { KmsDataKey } from "@app/services/kms/kms-types";
import { TLicenseServiceFactory } from "../license/license-service"; import { TLicenseServiceFactory } from "../license/license-service";
import { PamResource } from "../pam-resource/pam-resource-enums";
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { TPermissionServiceFactory } from "../permission/permission-service-types";
import { TRelayDALFactory } from "../relay/relay-dal"; import { TRelayDALFactory } from "../relay/relay-dal";
import { TRelayServiceFactory } from "../relay/relay-service"; 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 { TGatewayV2DALFactory } from "./gateway-v2-dal";
import { TOrgGatewayConfigV2DALFactory } from "./org-gateway-config-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 ({ const registerGateway = async ({
orgId, orgId,
actorId, actorId,
@@ -645,14 +816,75 @@ export const gatewayV2ServiceFactory = ({
OrgPermissionSubjects.Gateway 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 { return {
listGateways, listGateways,
registerGateway, registerGateway,
getPlatformConnectionDetailsByGatewayId, getPlatformConnectionDetailsByGatewayId,
getPAMConnectionDetails,
deleteGatewayById, deleteGatewayById,
heartbeat heartbeat,
getPamSessionKey
}; };
}; };
@@ -66,7 +66,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
enterpriseAppConnections: false, enterpriseAppConnections: false,
fips: false, fips: false,
eventSubscriptions: false, eventSubscriptions: false,
machineIdentityAuthTemplates: false machineIdentityAuthTemplates: false,
pam: false
}); });
export const setupLicenseRequestWithStore = ( export const setupLicenseRequestWithStore = (
@@ -461,17 +461,16 @@ export const licenseServiceFactory = ({
}; };
const fetchPlanTableFromServer = async (customerId: string | null | undefined) => { const fetchPlanTableFromServer = async (customerId: string | null | undefined) => {
if (!customerId) { const baseUrl = `/api/license-server/v1/customers`;
throw new NotFoundError({ message: "Organization customer ID is required for plan table retrieval" });
}
const baseUrl = `/api/license-server/v1/customers/${customerId}`;
if (instanceType === InstanceType.Cloud) { 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<{ const { data } = await licenseServerCloudApi.request.get<{
head: { name: string }[]; head: { name: string }[];
rows: { name: string; allowed: boolean }[]; rows: { name: string; allowed: boolean }[];
}>(`${baseUrl}/cloud-plan/table`); }>(`${baseUrl}/${customerId}/cloud-plan/table`);
return data; return data;
} }
@@ -80,6 +80,7 @@ export type TFeatureSet = {
machineIdentityAuthTemplates: false; machineIdentityAuthTemplates: false;
fips: false; fips: false;
eventSubscriptions: false; eventSubscriptions: false;
pam: false;
}; };
export type TOrgPlansTableDTO = { export type TOrgPlansTableDTO = {
@@ -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 };
};
@@ -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 };
};
@@ -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
};
};
@@ -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;
};
@@ -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 };
};
@@ -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("/")}`;
};
@@ -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 };
};
@@ -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;
}
@@ -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
};
@@ -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;
};
@@ -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()
});
@@ -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
};
};
@@ -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;
@@ -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 };
};
@@ -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
}
@@ -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;
};
@@ -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()
});
@@ -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 };
};
@@ -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, ProjectPermissionIdentityActions,
ProjectPermissionKmipActions, ProjectPermissionKmipActions,
ProjectPermissionMemberActions, ProjectPermissionMemberActions,
ProjectPermissionPamAccountActions,
ProjectPermissionPamSessionActions,
ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiSubscriberActions,
ProjectPermissionPkiSyncActions, ProjectPermissionPkiSyncActions,
ProjectPermissionPkiTemplateActions, ProjectPermissionPkiTemplateActions,
@@ -49,7 +51,9 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.SshCertificateAuthorities, ProjectPermissionSub.SshCertificateAuthorities,
ProjectPermissionSub.SshCertificates, ProjectPermissionSub.SshCertificates,
ProjectPermissionSub.SshCertificateTemplates, ProjectPermissionSub.SshCertificateTemplates,
ProjectPermissionSub.SshHostGroups ProjectPermissionSub.SshHostGroups,
ProjectPermissionSub.PamFolders,
ProjectPermissionSub.PamResources
].forEach((el) => { ].forEach((el) => {
can( can(
[ [
@@ -290,6 +294,19 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.AppConnections ProjectPermissionSub.AppConnections
); );
can(
[
ProjectPermissionPamAccountActions.Access,
ProjectPermissionPamAccountActions.Read,
ProjectPermissionPamAccountActions.Create,
ProjectPermissionPamAccountActions.Edit,
ProjectPermissionPamAccountActions.Delete
],
ProjectPermissionSub.PamAccounts
);
can([ProjectPermissionPamSessionActions.Read], ProjectPermissionSub.PamSessions);
return rules; return rules;
}; };
@@ -518,6 +535,15 @@ const buildMemberPermissionRules = () => {
can(ProjectPermissionAppConnectionActions.Connect, ProjectPermissionSub.AppConnections); can(ProjectPermissionAppConnectionActions.Connect, ProjectPermissionSub.AppConnections);
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamFolders);
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamResources);
can(
[ProjectPermissionPamAccountActions.Access, ProjectPermissionPamAccountActions.Read],
ProjectPermissionSub.PamAccounts
);
return rules; return rules;
}; };
@@ -579,6 +605,12 @@ const buildViewerPermissionRules = () => {
ProjectPermissionSub.SecretEvents ProjectPermissionSub.SecretEvents
); );
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamFolders);
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamResources);
can([ProjectPermissionPamAccountActions.Read], ProjectPermissionSub.PamAccounts);
return rules; return rules;
}; };
@@ -186,6 +186,19 @@ export enum ProjectPermissionAuditLogsActions {
Read = "read" 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 { export enum ProjectPermissionSub {
Role = "role", Role = "role",
Member = "member", Member = "member",
@@ -228,7 +241,11 @@ export enum ProjectPermissionSub {
SecretScanningFindings = "secret-scanning-findings", SecretScanningFindings = "secret-scanning-findings",
SecretScanningConfigs = "secret-scanning-configs", SecretScanningConfigs = "secret-scanning-configs",
SecretEvents = "secret-events", SecretEvents = "secret-events",
AppConnections = "app-connections" AppConnections = "app-connections",
PamFolders = "pam-folders",
PamResources = "pam-resources",
PamAccounts = "pam-accounts",
PamSessions = "pam-sessions"
} }
export type SecretSubjectFields = { export type SecretSubjectFields = {
@@ -300,6 +317,12 @@ export type AppConnectionSubjectFields = {
connectionId: string; connectionId: string;
}; };
export type PamAccountSubjectFields = {
resourceName: string;
accountName: string;
accountPath: string;
};
export type ProjectPermissionSet = export type ProjectPermissionSet =
| [ | [
ProjectPermissionSecretActions, ProjectPermissionSecretActions,
@@ -404,7 +427,14 @@ export type ProjectPermissionSet =
| ProjectPermissionSub.AppConnections | ProjectPermissionSub.AppConnections
| (ForcedSubject<ProjectPermissionSub.AppConnections> & AppConnectionSubjectFields) | (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_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
@@ -427,6 +457,27 @@ const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
}) })
.partial() .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 // akhilmhdh: don't modify this for v2
// if you want to update create a new schema // if you want to update create a new schema
const SecretConditionV1Schema = z const SecretConditionV1Schema = z
@@ -650,6 +701,34 @@ const AppConnectionConditionSchema = z
}) })
.partial(); .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 = [ const GeneralPermissionSchema = [
z.object({ z.object({
subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."), subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."),
@@ -840,6 +919,34 @@ const GeneralPermissionSchema = [
conditions: AppConnectionConditionSchema.describe( conditions: AppConnectionConditionSchema.describe(
"When specified, only matching conditions will be allowed to access given resource." "When specified, only matching conditions will be allowed to access given resource."
).optional() ).optional()
}),
z.object({
subject: z.literal(ProjectPermissionSub.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."
)
}) })
]; ];
+11 -5
View File
@@ -708,7 +708,8 @@ export const relayServiceFactory = ({
relayPkiClientCaCertificate, relayPkiClientCaCertificate,
relayPkiClientCaPrivateKey, relayPkiClientCaPrivateKey,
relayPkiServerCaCertificate, relayPkiServerCaCertificate,
relayPkiServerCaCertificateChain relayPkiServerCaCertificateChain,
duration
}: { }: {
gatewayId: string; gatewayId: string;
gatewayName: string; gatewayName: string;
@@ -718,6 +719,7 @@ export const relayServiceFactory = ({
relayPkiClientCaPrivateKey: Buffer; relayPkiClientCaPrivateKey: Buffer;
relayPkiServerCaCertificate: Buffer; relayPkiServerCaCertificate: Buffer;
relayPkiServerCaCertificateChain: Buffer; relayPkiServerCaCertificateChain: Buffer;
duration?: number;
}) => { }) => {
const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048);
const relayClientCaCert = new x509.X509Certificate(relayPkiClientCaCertificate); const relayClientCaCert = new x509.X509Certificate(relayPkiClientCaCertificate);
@@ -737,7 +739,7 @@ export const relayServiceFactory = ({
); );
const clientCertIssuedAt = new Date(); 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 clientKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const clientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey); const clientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey);
const clientCertSerialNumber = createSerialNumber(); const clientCertSerialNumber = createSerialNumber();
@@ -866,13 +868,15 @@ export const relayServiceFactory = ({
orgId, orgId,
orgName, orgName,
gatewayId, gatewayId,
gatewayName gatewayName,
duration
}: { }: {
relayId: string; relayId: string;
orgId: string; orgId: string;
orgName: string; orgName: string;
gatewayId: string; gatewayId: string;
gatewayName: string; gatewayName: string;
duration?: number;
}) => { }) => {
const relay = await relayDAL.findOne({ const relay = await relayDAL.findOne({
id: relayId id: relayId
@@ -896,7 +900,8 @@ export const relayServiceFactory = ({
relayPkiClientCaCertificate: instanceCAs.instanceRelayPkiClientCaCertificate, relayPkiClientCaCertificate: instanceCAs.instanceRelayPkiClientCaCertificate,
relayPkiClientCaPrivateKey: instanceCAs.instanceRelayPkiClientCaPrivateKey, relayPkiClientCaPrivateKey: instanceCAs.instanceRelayPkiClientCaPrivateKey,
relayPkiServerCaCertificate: instanceCAs.instanceRelayPkiServerCaCertificate, relayPkiServerCaCertificate: instanceCAs.instanceRelayPkiServerCaCertificate,
relayPkiServerCaCertificateChain: instanceCAs.instanceRelayPkiServerCaCertificateChain relayPkiServerCaCertificateChain: instanceCAs.instanceRelayPkiServerCaCertificateChain,
duration
}); });
return { return {
@@ -914,7 +919,8 @@ export const relayServiceFactory = ({
relayPkiClientCaCertificate: orgCAs.relayPkiClientCaCertificate, relayPkiClientCaCertificate: orgCAs.relayPkiClientCaCertificate,
relayPkiClientCaPrivateKey: orgCAs.relayPkiClientCaPrivateKey, relayPkiClientCaPrivateKey: orgCAs.relayPkiClientCaPrivateKey,
relayPkiServerCaCertificate: orgCAs.relayPkiServerCaCertificate, relayPkiServerCaCertificate: orgCAs.relayPkiServerCaCertificate,
relayPkiServerCaCertificateChain: orgCAs.relayPkiServerCaCertificateChain relayPkiServerCaCertificateChain: orgCAs.relayPkiServerCaCertificateChain,
duration
}); });
return { return {
@@ -1,6 +1,17 @@
/* eslint-disable no-await-in-loop */
import { ForbiddenError } from "@casl/ability"; 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 { throwOnPlanSeatLimitReached } from "@app/ee/services/license/license-fns";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto"; 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 { AuthTokenType } from "@app/services/auth/auth-type";
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
import { TokenType } from "@app/services/auth-token/auth-token-types"; 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 { TIdentityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types"; import { KmsDataKey } from "@app/services/kms/kms-types";
import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal";
import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns"; import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns";
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; 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 { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { getServerCfg } from "@app/services/super-admin/super-admin-service";
import { LoginMethod } from "@app/services/super-admin/super-admin-types"; 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 { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
import { UserAliasType } from "@app/services/user-alias/user-alias-types"; 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 { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { TPermissionServiceFactory } from "../permission/permission-service-types";
import { TSamlConfigDALFactory } from "./saml-config-dal"; 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 = { type TSamlConfigServiceFactoryDep = {
samlConfigDAL: Pick<TSamlConfigDALFactory, "create" | "findOne" | "update" | "findById">; samlConfigDAL: Pick<TSamlConfigDALFactory, "create" | "findOne" | "update" | "findById">;
userDAL: Pick< userDAL: Pick<
TUserDALFactory, TUserDALFactory,
"create" | "findOne" | "transaction" | "updateById" | "findById" | "findUserEncKeyByUserId" | "create"
| "findOne"
| "find"
| "transaction"
| "updateById"
| "findById"
| "findUserEncKeyByUserId"
| "findUserEncKeyByUserIdsBatch"
>; >;
userAliasDAL: Pick<TUserAliasDALFactory, "create" | "findOne">; userAliasDAL: Pick<TUserAliasDALFactory, "create" | "findOne">;
orgDAL: Pick< orgDAL: Pick<
@@ -41,6 +69,15 @@ type TSamlConfigServiceFactoryDep = {
>; >;
identityMetadataDAL: Pick<TIdentityMetadataDALFactory, "delete" | "insertMany" | "transaction">; identityMetadataDAL: Pick<TIdentityMetadataDALFactory, "delete" | "insertMany" | "transaction">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "create">; 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">; permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan" | "updateSubscriptionOrgMemberCount">; licenseService: Pick<TLicenseServiceFactory, "getPlan" | "updateSubscriptionOrgMemberCount">;
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser">; tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser">;
@@ -54,6 +91,12 @@ export const samlConfigServiceFactory = ({
orgMembershipDAL, orgMembershipDAL,
userDAL, userDAL,
userAliasDAL, userAliasDAL,
groupDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectDAL,
projectBotDAL,
projectKeyDAL,
permissionService, permissionService,
licenseService, licenseService,
tokenService, tokenService,
@@ -61,6 +104,139 @@ export const samlConfigServiceFactory = ({
identityMetadataDAL, identityMetadataDAL,
kmsService kmsService
}: TSamlConfigServiceFactoryDep): TSamlConfigServiceFactory => { }: 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 ({ const createSamlCfg: TSamlConfigServiceFactory["createSamlCfg"] = async ({
idpCert, idpCert,
actor, actor,
@@ -71,7 +247,8 @@ export const samlConfigServiceFactory = ({
actorId, actorId,
isActive, isActive,
entryPoint, entryPoint,
authProvider authProvider,
enableGroupSync
}) => { }) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Sso); 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({ const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization, type: KmsDataKey.Organization,
orgId orgId
@@ -107,7 +296,8 @@ export const samlConfigServiceFactory = ({
isActive, isActive,
encryptedSamlCertificate: encryptor({ plainText: Buffer.from(idpCert) }).cipherTextBlob, encryptedSamlCertificate: encryptor({ plainText: Buffer.from(idpCert) }).cipherTextBlob,
encryptedSamlEntryPoint: encryptor({ plainText: Buffer.from(entryPoint) }).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; return samlConfig;
@@ -123,7 +313,8 @@ export const samlConfigServiceFactory = ({
issuer, issuer,
isActive, isActive,
entryPoint, entryPoint,
authProvider authProvider,
enableGroupSync
}) => { }) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); 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({ const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization, type: KmsDataKey.Organization,
orgId orgId
@@ -250,7 +461,8 @@ export const samlConfigServiceFactory = ({
entryPoint, entryPoint,
issuer, issuer,
cert, cert,
lastUsed: samlConfig.lastUsed lastUsed: samlConfig.lastUsed,
enableGroupSync: samlConfig.enableGroupSync
}; };
}; };
@@ -282,6 +494,12 @@ export const samlConfigServiceFactory = ({
const organization = await orgDAL.findOrgById(orgId); const organization = await orgDAL.findOrgById(orgId);
if (!organization) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); 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; let user: TUsers;
if (userAlias) { if (userAlias) {
user = await userDAL.transaction(async (tx) => { user = await userDAL.transaction(async (tx) => {
@@ -303,7 +521,7 @@ export const samlConfigServiceFactory = ({
orgId, orgId,
role, role,
roleId, 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 isActive: true
}, },
tx 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; return foundUser;
}); });
} else { } 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; return newUser;
}); });
} }
@@ -17,6 +17,7 @@ export type TCreateSamlCfgDTO = {
entryPoint: string; entryPoint: string;
issuer: string; issuer: string;
idpCert: string; idpCert: string;
enableGroupSync?: boolean;
} & TOrgPermission; } & TOrgPermission;
export type TUpdateSamlCfgDTO = Partial<{ export type TUpdateSamlCfgDTO = Partial<{
@@ -25,6 +26,7 @@ export type TUpdateSamlCfgDTO = Partial<{
entryPoint: string; entryPoint: string;
issuer: string; issuer: string;
idpCert: string; idpCert: string;
enableGroupSync?: boolean;
}> & }> &
TOrgPermission; TOrgPermission;
@@ -71,6 +73,7 @@ export type TSamlConfigServiceFactory = {
issuer: string; issuer: string;
cert: string; cert: string;
lastUsed: Date | null | undefined; lastUsed: Date | null | undefined;
enableGroupSync?: boolean;
}>; }>;
samlLogin: (arg: TSamlLoginDTO) => Promise<{ samlLogin: (arg: TSamlLoginDTO) => Promise<{
isUserCompleted: boolean; isUserCompleted: boolean;
+1
View File
@@ -23,6 +23,7 @@ export const PgSqlLock = {
InstanceRelayConfigInit: () => pgAdvisoryLockHashText("instance-relay-config-init"), InstanceRelayConfigInit: () => pgAdvisoryLockHashText("instance-relay-config-init"),
OrgGatewayV2Init: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-v2-init:${orgId}`), OrgGatewayV2Init: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-v2-init:${orgId}`),
OrgRelayConfigInit: (orgId: string) => pgAdvisoryLockHashText(`org-relay-config-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}`) IdentityLogin: (identityId: string, nonce: string) => pgAdvisoryLockHashText(`identity-login:${identityId}:${nonce}`)
} as const; } as const;
+6 -2
View File
@@ -2872,7 +2872,9 @@ export const SamlSso = {
entryPoint: 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.", "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.", 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: { CREATE_CONFIG: {
organizationId: "The ID of the organization to create the SAML config for.", organizationId: "The ID of the organization to create the SAML config for.",
@@ -2881,7 +2883,9 @@ export const SamlSso = {
entryPoint: 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.", "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.", 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."
} }
}; };
+50
View File
@@ -53,3 +53,53 @@ export const titleCaseToCamelCase = (obj: unknown): unknown => {
return result; 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);
};
+58
View File
@@ -66,6 +66,14 @@ import { licenseDALFactory } from "@app/ee/services/license/license-dal";
import { licenseServiceFactory } from "@app/ee/services/license/license-service"; import { licenseServiceFactory } from "@app/ee/services/license/license-service";
import { oidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; import { oidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal";
import { oidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service"; 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 { permissionDALFactory } from "@app/ee/services/permission/permission-dal";
import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { permissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { pitServiceFactory } from "@app/ee/services/pit/pit-service"; import { pitServiceFactory } from "@app/ee/services/pit/pit-service";
@@ -623,6 +631,12 @@ export const registerRoutes = async (
userDAL, userDAL,
userAliasDAL, userAliasDAL,
samlConfigDAL, samlConfigDAL,
groupDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectDAL,
projectBotDAL,
projectKeyDAL,
licenseService, licenseService,
tokenService, tokenService,
smtpService, smtpService,
@@ -2104,6 +2118,46 @@ export const registerRoutes = async (
appConnectionDAL 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 // setup the communication with license key server
await licenseService.init(); await licenseService.init();
@@ -2242,6 +2296,10 @@ export const registerRoutes = async (
bus: eventBusService, bus: eventBusService,
sse: sseService, sse: sseService,
notification: notificationService, notification: notificationService,
pamFolder: pamFolderService,
pamResource: pamResourceService,
pamAccount: pamAccountService,
pamSession: pamSessionService,
upgradePath: upgradePathService upgradePath: upgradePathService
}); });
+1 -1
View File
@@ -58,8 +58,8 @@ import { registerSecretRequestsRouter } from "./secret-requests-router";
import { registerSecretSharingRouter } from "./secret-sharing-router"; import { registerSecretSharingRouter } from "./secret-sharing-router";
import { registerSecretTagRouter } from "./secret-tag-router"; import { registerSecretTagRouter } from "./secret-tag-router";
import { registerSlackRouter } from "./slack-router"; import { registerSlackRouter } from "./slack-router";
import { registerUpgradePathRouter } from "./upgrade-path-router";
import { registerSsoRouter } from "./sso-router"; import { registerSsoRouter } from "./sso-router";
import { registerUpgradePathRouter } from "./upgrade-path-router";
import { registerUserActionRouter } from "./user-action-router"; import { registerUserActionRouter } from "./user-action-router";
import { registerUserEngagementRouter } from "./user-engagement-router"; import { registerUserEngagementRouter } from "./user-engagement-router";
import { registerUserRouter } from "./user-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 { 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"; import { registerAzureKeyVaultPkiSyncRouter } from "./azure-key-vault-pki-sync-router";
export * from "./pki-sync-router"; export * from "./pki-sync-router";
export const PKI_SYNC_REGISTER_ROUTER_MAP: Record<PkiSync, (server: FastifyZodProvider) => Promise<void>> = { 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 }; 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; return false;
case ProjectType.SSH: case ProjectType.SSH:
return false; return false;
case ProjectType.PAM:
return false;
default: default:
return true; return true;
} }
@@ -33,6 +33,7 @@ export const constructGroupOrgMembershipRoleMappings = async ({
let customRolesMap: Map<string, TOrgRoles> = new Map(); let customRolesMap: Map<string, TOrgRoles> = new Map();
if (customRoleSlugs.length > 0) { if (customRoleSlugs.length > 0) {
const customRoles = await orgRoleDAL.find({ const customRoles = await orgRoleDAL.find({
orgId,
$in: { $in: {
slug: customRoleSlugs 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 { export enum PkiSync {
AzureKeyVault = "azure-key-vault" AzureKeyVault = "azure-key-vault",
AwsCertificateManager = "aws-certificate-manager"
} }
export enum PkiSyncStatus { export enum PkiSyncStatus {
+26 -9
View File
@@ -6,6 +6,8 @@ import { BadRequestError } from "@app/lib/errors";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; 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 { 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 { azureKeyVaultPkiSyncFactory } from "./azure-key-vault/azure-key-vault-pki-sync-fns";
import { PkiSync } from "./pki-sync-enums"; import { PkiSync } from "./pki-sync-enums";
@@ -14,7 +16,8 @@ import { TCertificateMap, TPkiSyncWithCredentials } from "./pki-sync-types";
const ENTERPRISE_PKI_SYNCS: PkiSync[] = []; const ENTERPRISE_PKI_SYNCS: PkiSync[] = [];
const PKI_SYNC_LIST_OPTIONS = { 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 ( export const enterprisePkiSyncCheck = async (
@@ -144,8 +147,10 @@ export const matchesCertificateNameSchema = (name: string, environment: string,
return name.startsWith(prefix) && name.endsWith(suffix); return name.startsWith(prefix) && name.endsWith(suffix);
}; };
const isAzureKeyVaultPkiSync = (pkiSync: TPkiSyncWithCredentials): boolean => { const checkPkiSyncDestination = (pkiSync: TPkiSyncWithCredentials, destination: PkiSync): void => {
return pkiSync.destination === PkiSync.AzureKeyVault; if (pkiSync.destination !== destination) {
throw new Error(`Invalid PKI sync destination: ${pkiSync.destination}`);
}
}; };
export const PkiSyncFns = { 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)" "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: default:
throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`);
} }
@@ -188,12 +198,15 @@ export const PkiSyncFns = {
}> => { }> => {
switch (pkiSync.destination) { switch (pkiSync.destination) {
case PkiSync.AzureKeyVault: { case PkiSync.AzureKeyVault: {
if (!isAzureKeyVaultPkiSync(pkiSync)) { checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault);
throw new Error("Invalid Azure Key Vault PKI sync configuration");
}
const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies);
return azureKeyVaultPkiSync.syncCertificates(pkiSync, certificateMap); return azureKeyVaultPkiSync.syncCertificates(pkiSync, certificateMap);
} }
case PkiSync.AwsCertificateManager: {
checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager);
const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory(dependencies);
return awsCertificateManagerPkiSync.syncCertificates(pkiSync, certificateMap);
}
default: default:
throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`);
} }
@@ -209,13 +222,17 @@ export const PkiSyncFns = {
): Promise<void> => { ): Promise<void> => {
switch (pkiSync.destination) { switch (pkiSync.destination) {
case PkiSync.AzureKeyVault: { case PkiSync.AzureKeyVault: {
if (!isAzureKeyVaultPkiSync(pkiSync)) { checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault);
throw new Error("Invalid Azure Key Vault PKI sync configuration");
}
const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies);
await azureKeyVaultPkiSync.removeCertificates(pkiSync, certificateNames); await azureKeyVaultPkiSync.removeCertificates(pkiSync, certificateNames);
break; break;
} }
case PkiSync.AwsCertificateManager: {
checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager);
const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory(dependencies);
await awsCertificateManagerPkiSync.removeCertificates(pkiSync, certificateNames);
break;
}
default: default:
throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); 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"; import { PkiSync } from "./pki-sync-enums";
export const PKI_SYNC_NAME_MAP: Record<PkiSync, string> = { 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> = { 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 { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync, SecretSyncPlanType } from "@app/services/secret-sync/secret-sync-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> = { export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
[SecretSync.AWSParameterStore]: "AWS Parameter Store", [SecretSync.AWSParameterStore]: "AWS Parameter Store",
@@ -99,3 +100,104 @@ export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
[SecretSync.Netlify]: SecretSyncPlanType.Regular, [SecretSync.Netlify]: SecretSyncPlanType.Regular,
[SecretSync.Bitbucket]: 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 { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { DatabaseErrorCode } from "@app/lib/error-codes"; import { DatabaseErrorCode } from "@app/lib/error-codes";
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
import { deepEqualSkipFields } from "@app/lib/fn/object";
import { OrgServiceActor } from "@app/lib/types"; import { OrgServiceActor } from "@app/lib/types";
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-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 { enterpriseSyncCheck, listSecretSyncOptions } from "@app/services/secret-sync/secret-sync-fns";
import { import {
SecretSyncStatus, SecretSyncStatus,
TCheckDuplicateDestinationDTO,
TCreateSecretSyncDTO, TCreateSecretSyncDTO,
TDeleteSecretSyncDTO, TDeleteSecretSyncDTO,
TFindSecretSyncByIdDTO, TFindSecretSyncByIdDTO,
@@ -35,7 +37,12 @@ import {
import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
import { TSecretSyncDALFactory } from "./secret-sync-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"; import { TSecretSyncQueueFactory } from "./secret-sync-queue";
type TSecretSyncServiceFactoryDep = { type TSecretSyncServiceFactoryDep = {
@@ -696,6 +703,61 @@ export const secretSyncServiceFactory = ({
return updatedSecretSync as TSecretSync; 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 { return {
listSecretSyncOptions, listSecretSyncOptions,
listSecretSyncsByProjectId, listSecretSyncsByProjectId,
@@ -707,6 +769,7 @@ export const secretSyncServiceFactory = ({
deleteSecretSync, deleteSecretSync,
triggerSecretSyncSyncSecretsById, triggerSecretSyncSyncSecretsById,
triggerSecretSyncImportSecretsById, triggerSecretSyncImportSecretsById,
triggerSecretSyncRemoveSecretsById triggerSecretSyncRemoveSecretsById,
checkDuplicateDestination
}; };
}; };
@@ -324,6 +324,13 @@ export type TDeleteSecretSyncDTO = {
removeSecrets: boolean; removeSecrets: boolean;
}; };
export type TCheckDuplicateDestinationDTO = {
destination: SecretSync;
destinationConfig: Record<string, unknown>;
excludeSyncId?: string;
projectId: string;
};
export enum SecretSyncStatus { export enum SecretSyncStatus {
Pending = "pending", Pending = "pending",
Running = "running", Running = "running",
@@ -408,3 +415,8 @@ export type TSecretMap = Record<
secretMetadata?: ResourceMetadataDTO; 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: "[email protected]",
siteUrl: "https://infisical.com"
} as AccountDeletionConfirmationTemplateProps;
@@ -1,5 +1,6 @@
export * from "./AccessApprovalRequestTemplate"; export * from "./AccessApprovalRequestTemplate";
export * from "./AccessApprovalRequestUpdatedTemplate"; export * from "./AccessApprovalRequestUpdatedTemplate";
export * from "./AccountDeletionConfirmationTemplate";
export * from "./EmailMfaTemplate"; export * from "./EmailMfaTemplate";
export * from "./EmailVerificationTemplate"; export * from "./EmailVerificationTemplate";
export * from "./ExternalImportFailedTemplate"; export * from "./ExternalImportFailedTemplate";
+5 -2
View File
@@ -9,6 +9,7 @@ import { logger } from "@app/lib/logger";
import { import {
AccessApprovalRequestTemplate, AccessApprovalRequestTemplate,
AccessApprovalRequestUpdatedTemplate, AccessApprovalRequestUpdatedTemplate,
AccountDeletionConfirmationTemplate,
EmailMfaTemplate, EmailMfaTemplate,
EmailVerificationTemplate, EmailVerificationTemplate,
ExternalImportFailedTemplate, ExternalImportFailedTemplate,
@@ -83,7 +84,8 @@ export enum SmtpTemplates {
OrgAdminBreakglassAccess = "orgAdminBreakglassAccess", OrgAdminBreakglassAccess = "orgAdminBreakglassAccess",
ServiceTokenExpired = "serviceTokenExpired", ServiceTokenExpired = "serviceTokenExpired",
SecretScanningV2ScanFailed = "secretScanningV2ScanFailed", SecretScanningV2ScanFailed = "secretScanningV2ScanFailed",
SecretScanningV2SecretsDetected = "secretScanningV2SecretsDetected" SecretScanningV2SecretsDetected = "secretScanningV2SecretsDetected",
AccountDeletionConfirmation = "accountDeletionConfirmation"
} }
export enum SmtpHost { export enum SmtpHost {
@@ -128,7 +130,8 @@ const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = {
[SmtpTemplates.SetupPassword]: PasswordSetupTemplate, [SmtpTemplates.SetupPassword]: PasswordSetupTemplate,
[SmtpTemplates.PkiExpirationAlert]: PkiExpirationAlertTemplate, [SmtpTemplates.PkiExpirationAlert]: PkiExpirationAlertTemplate,
[SmtpTemplates.SecretScanningV2ScanFailed]: SecretScanningScanFailedTemplate, [SmtpTemplates.SecretScanningV2ScanFailed]: SecretScanningScanFailedTemplate,
[SmtpTemplates.SecretScanningV2SecretsDetected]: SecretScanningSecretsDetectedTemplate [SmtpTemplates.SecretScanningV2SecretsDetected]: SecretScanningSecretsDetectedTemplate,
[SmtpTemplates.AccountDeletionConfirmation]: AccountDeletionConfirmationTemplate
}; };
export const smtpServiceFactory = (cfg: TSmtpConfig) => { export const smtpServiceFactory = (cfg: TSmtpConfig) => {
+17
View File
@@ -347,6 +347,23 @@ export const userServiceFactory = ({
const deleteUser = async (userId: string) => { const deleteUser = async (userId: string) => {
const user = await userDAL.deleteById(userId); 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; return user;
}; };
+1 -1
View File
@@ -197,4 +197,4 @@ volumes:
driver: local driver: local
ldap_data: ldap_data:
ldap_config: 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