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

View File

@@ -65,6 +65,15 @@ jobs:
INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }}
DD_GIT_REPOSITORY_URL=${{ github.server_url }}/${{ github.repository }}
DD_GIT_COMMIT_SHA=${{ github.sha }}
- name: Snyk to check Docker image for vulnerabilities
continue-on-error: true
uses: snyk/actions/docker@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
image: infisical/infisical:${{ steps.extract_version.outputs.version }}
command: monitor
args: --file=Dockerfile.standalone-infisical --project-name="infisical-core-docker-image"
infisical-fips-standalone:
name: Build infisical standalone image postgres
@@ -141,4 +150,4 @@ jobs:
echo "Successfully created tag $TAG_NAME"
fi
env:
GH_TOKEN: ${{ secrets.OMNIBUS_RELEASE_TOKEN }}
GH_TOKEN: ${{ secrets.OMNIBUS_RELEASE_TOKEN }}

View File

@@ -3,7 +3,10 @@ ARG POSTHOG_API_KEY=posthog-api-key
ARG INTERCOM_ID=intercom-id
ARG CAPTCHA_SITE_KEY=captcha-site-key
FROM node:20-slim AS base
FROM node:20.19.5-trixie-slim AS base
# Fixes NPM vulnerability: https://security.snyk.io/vuln/SNYK-JS-CROSSSPAWN-8303230
RUN npm install -g npm@11
FROM base AS frontend-dependencies
WORKDIR /app
@@ -155,7 +158,7 @@ RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \
# Install Infisical CLI
RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \
&& apt-get update && apt-get install -y infisical=0.41.89 \
&& apt-get update && apt-get install -y infisical=0.42.6 \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd -r -g 1001 nodejs && useradd -r -u 1001 -g nodejs non-root-user

View File

@@ -3,7 +3,10 @@ ARG POSTHOG_API_KEY=posthog-api-key
ARG INTERCOM_ID=intercom-id
ARG CAPTCHA_SITE_KEY=captcha-site-key
FROM node:20-slim AS base
FROM node:20.19.5-trixie-slim AS base
# Fixes NPM vulnerability: https://security.snyk.io/vuln/SNYK-JS-CROSSSPAWN-8303230
RUN npm install -g npm@11
FROM base AS frontend-dependencies
@@ -139,7 +142,7 @@ RUN apt-get update && apt-get install -y \
# Install Infisical CLI
RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \
&& apt-get update && apt-get install -y infisical=0.41.89 \
&& apt-get update && apt-get install -y infisical=0.42.6 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /

View File

@@ -1,5 +1,5 @@
# Build stage
FROM node:20-slim AS build
FROM node:20.19.5-trixie-slim AS build
WORKDIR /app
@@ -26,7 +26,7 @@ COPY . .
RUN npm run build
# Production stage
FROM node:20-slim
FROM node:20.19.5-trixie-slim
WORKDIR /app
ENV npm_config_cache /home/node/.npm

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.

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.

4874
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -79,12 +79,17 @@
"keywords": [],
"author": "",
"license": "ISC",
"overrides": {
"cipher-base": "1.0.5",
"sha.js": "2.4.12"
},
"devDependencies": {
"@babel/cli": "^7.18.10",
"@babel/core": "^7.18.10",
"@babel/plugin-syntax-import-attributes": "^7.24.7",
"@babel/preset-env": "^7.18.10",
"@babel/preset-react": "^7.24.7",
"@react-email/preview-server": "^4.3.0",
"@smithy/types": "^4.3.1",
"@types/bcrypt": "^5.0.2",
"@types/jmespath": "^0.15.2",
@@ -120,7 +125,7 @@
"nodemon": "^3.0.2",
"pino-pretty": "^10.2.3",
"prompt-sync": "^4.2.0",
"react-email": "4.0.7",
"react-email": "^4.3.0",
"rimraf": "^5.0.5",
"ts-node": "^10.9.2",
"tsc-alias": "^1.8.8",
@@ -138,7 +143,7 @@
"@aws-sdk/client-secrets-manager": "^3.504.0",
"@aws-sdk/client-sts": "^3.600.0",
"@casl/ability": "^6.5.0",
"@elastic/elasticsearch": "^8.15.0",
"@elastic/elasticsearch": "^9.1.1",
"@fastify/cookie": "^9.3.1",
"@fastify/cors": "^8.5.0",
"@fastify/etag": "^5.1.0",
@@ -185,7 +190,7 @@
"ajv": "^8.12.0",
"argon2": "^0.31.2",
"aws-sdk": "^2.1553.0",
"axios": "^1.11.0",
"axios": "^1.12.0",
"axios-ntlm": "^1.4.4",
"axios-retry": "^4.0.0",
"bcrypt": "^5.1.1",
@@ -196,7 +201,7 @@
"cron": "^3.1.7",
"dd-trace": "^5.40.0",
"dotenv": "^16.4.1",
"fastify": "^4.28.1",
"fastify": "^4.29.1",
"fastify-plugin": "^4.5.1",
"google-auth-library": "^9.9.0",
"googleapis": "^137.1.0",

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 { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TOidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service";
import { TPamAccountServiceFactory } from "@app/ee/services/pam-account/pam-account-service";
import { TPamFolderServiceFactory } from "@app/ee/services/pam-folder/pam-folder-service";
import { TPamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-resource-service";
import { TPamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { TPitServiceFactory } from "@app/ee/services/pit/pit-service";
import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-types";
@@ -315,6 +319,10 @@ declare module "fastify" {
identityAuthTemplate: TIdentityAuthTemplateServiceFactory;
notification: TNotificationServiceFactory;
offlineUsageReport: TOfflineUsageReportServiceFactory;
pamFolder: TPamFolderServiceFactory;
pamResource: TPamResourceServiceFactory;
pamAccount: TPamAccountServiceFactory;
pamSession: TPamSessionServiceFactory;
upgradePath: TUpgradePathService;
};
// this is exclusive use for middlewares in which we need to inject data

View File

@@ -530,6 +530,10 @@ import {
TMicrosoftTeamsIntegrationsInsert,
TMicrosoftTeamsIntegrationsUpdate
} from "@app/db/schemas/microsoft-teams-integrations";
import { TPamAccounts, TPamAccountsInsert, TPamAccountsUpdate } from "@app/db/schemas/pam-accounts";
import { TPamFolders, TPamFoldersInsert, TPamFoldersUpdate } from "@app/db/schemas/pam-folders";
import { TPamResources, TPamResourcesInsert, TPamResourcesUpdate } from "@app/db/schemas/pam-resources";
import { TPamSessions, TPamSessionsInsert, TPamSessionsUpdate } from "@app/db/schemas/pam-sessions";
import {
TProjectMicrosoftTeamsConfigs,
TProjectMicrosoftTeamsConfigsInsert,
@@ -1308,5 +1312,9 @@ declare module "knex/types/tables" {
TKeyValueStoreInsert,
TKeyValueStoreUpdate
>;
[TableName.PamFolder]: KnexOriginal.CompositeTableType<TPamFolders, TPamFoldersInsert, TPamFoldersUpdate>;
[TableName.PamResource]: KnexOriginal.CompositeTableType<TPamResources, TPamResourcesInsert, TPamResourcesUpdate>;
[TableName.PamAccount]: KnexOriginal.CompositeTableType<TPamAccounts, TPamAccountsInsert, TPamAccountsUpdate>;
[TableName.PamSession]: KnexOriginal.CompositeTableType<TPamSessions, TPamSessionsInsert, TPamSessionsUpdate>;
}
}

View File

@@ -226,7 +226,7 @@ export async function up(knex: Knex): Promise<void> {
// Insert New Commits in batches of 9000
const newCommits = foldersCommitsList.map((folderCommit) => folderCommit.commit);
const commitBatches = chunkArray(newCommits, 9000);
let pendingDeepTreeCommitResources: TFolderCommits[] = [];
let j = 0;
for (const commitBatch of commitBatches) {
j += 1;
@@ -265,12 +265,14 @@ export async function up(knex: Knex): Promise<void> {
});
// Create folder commit changes
const currentBatchFolderIds = new Set(newCommitsInserted.map((commit) => commit.folderId));
// eslint-disable-next-line no-await-in-loop
await knex.batchInsert(
TableName.FolderCommitChanges,
foldersCommitsList
.map((folderCommit) => folderCommit.changes)
.flat()
.filter((change) => currentBatchFolderIds.has(change.folderId))
.map((change) => ({
folderCommitId: newCommitsMap[change.folderId],
changeType: change.changeType,
@@ -289,6 +291,7 @@ export async function up(knex: Knex): Promise<void> {
foldersCommitsList
.map((folderCommit) => folderCommit.changes)
.flat()
.filter((change) => currentBatchFolderIds.has(change.folderId))
.map((change) => ({
folderCheckpointId: newCheckpointsMap[change.folderId],
folderVersionId: change.folderVersionId,
@@ -303,9 +306,11 @@ export async function up(knex: Knex): Promise<void> {
const newTreeCheckpoints = (await knex
.batchInsert(
TableName.FolderTreeCheckpoint,
Object.keys(rootFoldersMap).map((folderId) => ({
folderCommitId: newCommitsMap[folderId]
}))
Object.keys(rootFoldersMap)
.filter((folderId) => currentBatchFolderIds.has(folderId))
.map((folderId) => ({
folderCommitId: newCommitsMap[folderId]
}))
)
.returning("*")) as TFolderTreeCheckpoints[];
@@ -317,18 +322,31 @@ export async function up(knex: Knex): Promise<void> {
});
// Create Folder Tree Checkpoint Resources
// eslint-disable-next-line no-await-in-loop
await knex
.batchInsert(
TableName.FolderTreeCheckpointResources,
newCommitsInserted.map((folderCommit) => ({
folderTreeCheckpointId: newTreeCheckpointsMap[folderCommit.envId],
folderId: folderCommit.folderId,
folderCommitId: folderCommit.id
}))
)
.returning("*");
const commitsToProcess = pendingDeepTreeCommitResources.concat(newCommitsInserted);
const unprocessableCommits: TFolderCommits[] = [];
const processableCommits = commitsToProcess.filter((folderCommit) => {
const isProcessable = newTreeCheckpointsMap[folderCommit.envId];
if (!isProcessable) {
unprocessableCommits.push(folderCommit);
}
return isProcessable;
});
if (processableCommits.length > 0) {
// eslint-disable-next-line no-await-in-loop
await knex
.batchInsert(
TableName.FolderTreeCheckpointResources,
processableCommits.map((folderCommit) => ({
folderTreeCheckpointId: newTreeCheckpointsMap[folderCommit.envId],
folderId: folderCommit.folderId,
folderCommitId: folderCommit.id
}))
)
.returning("*");
}
pendingDeepTreeCommitResources = unprocessableCommits;
logger.info(`Finished inserting folder tree checkpoint resources - batch ${j} of ${commitBatches.length}`);
}
}

View File

@@ -0,0 +1,165 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
// PAM Folders
if (!(await knex.schema.hasTable(TableName.PamFolder))) {
await knex.schema.createTable(TableName.PamFolder, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.index("projectId");
t.uuid("parentId").nullable();
t.foreign("parentId").references("id").inTable(TableName.PamFolder).onDelete("CASCADE");
t.index("parentId");
t.string("name").notNullable();
t.index("name");
// Enforce uniqueness for sub-folders
t.unique(["projectId", "parentId", "name"], {
indexName: "uidx_pam_folder_children_name",
predicate: knex.whereNotNull("parentId")
});
// Enforce uniqueness for root-level folders
t.unique(["projectId", "name"], {
indexName: "uidx_pam_folder_root_name",
predicate: knex.whereNull("parentId")
});
t.text("description").nullable();
t.timestamps(true, true, true);
});
}
// PAM Resources
if (!(await knex.schema.hasTable(TableName.PamResource))) {
await knex.schema.createTable(TableName.PamResource, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.index("projectId");
t.string("name").notNullable();
t.index("name");
t.uuid("gatewayId").notNullable();
t.foreign("gatewayId").references("id").inTable(TableName.GatewayV2);
t.index("gatewayId");
t.string("resourceType").notNullable();
t.index("resourceType");
t.binary("encryptedConnectionDetails").notNullable();
t.timestamps(true, true, true);
});
}
// PAM Accounts
if (!(await knex.schema.hasTable(TableName.PamAccount))) {
await knex.schema.createTable(TableName.PamAccount, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.index("projectId");
t.uuid("folderId").nullable();
t.foreign("folderId").references("id").inTable(TableName.PamFolder).onDelete("CASCADE");
t.index("folderId");
t.uuid("resourceId").notNullable();
t.foreign("resourceId").references("id").inTable(TableName.PamResource);
t.index("resourceId");
t.string("name").notNullable();
t.index("name");
// Enforce uniqueness for folders
t.unique(["projectId", "folderId", "name"], {
indexName: "uidx_pam_account_children_name",
predicate: knex.whereNotNull("folderId")
});
// Enforce uniqueness for root-level
t.unique(["projectId", "name"], {
indexName: "uidx_pam_account_root_name",
predicate: knex.whereNull("folderId")
});
t.text("description").nullable();
t.binary("encryptedCredentials").notNullable();
t.timestamps(true, true, true);
});
}
// PAM Sessions
if (!(await knex.schema.hasTable(TableName.PamSession))) {
await knex.schema.createTable(TableName.PamSession, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.index("projectId");
t.uuid("accountId").nullable();
t.foreign("accountId").references("id").inTable(TableName.PamAccount).onDelete("SET NULL");
t.index("accountId");
// To be used in the event of an account deletion
t.string("resourceType").notNullable();
t.string("resourceName").notNullable();
t.string("accountName").notNullable();
t.uuid("userId").nullable();
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("SET NULL");
t.index("userId");
// To be used in the event of user deletion
t.string("actorName").notNullable();
t.string("actorEmail").notNullable();
t.string("actorIp").notNullable();
t.string("actorUserAgent").notNullable();
t.string("status").notNullable();
t.index("status");
t.binary("encryptedLogsBlob").nullable();
t.datetime("expiresAt").notNullable();
t.datetime("startedAt").nullable(); // Not when the row is created, but when the end-to-end connection between user and resource is established
t.datetime("endedAt").nullable();
t.index(["startedAt", "endedAt"]);
t.timestamps(true, true, true);
});
}
await createOnUpdateTrigger(knex, TableName.PamFolder);
await createOnUpdateTrigger(knex, TableName.PamResource);
await createOnUpdateTrigger(knex, TableName.PamAccount);
await createOnUpdateTrigger(knex, TableName.PamSession);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.PamSession);
await knex.schema.dropTableIfExists(TableName.PamAccount);
await knex.schema.dropTableIfExists(TableName.PamResource);
await knex.schema.dropTableIfExists(TableName.PamFolder);
await dropOnUpdateTrigger(knex, TableName.PamSession);
await dropOnUpdateTrigger(knex, TableName.PamAccount);
await dropOnUpdateTrigger(knex, TableName.PamResource);
await dropOnUpdateTrigger(knex, TableName.PamFolder);
}

View File

@@ -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");
});
}
}

View File

@@ -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");
});
}
}

View File

@@ -5,6 +5,8 @@
import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const GatewaysV2Schema = z.object({
@@ -15,7 +17,8 @@ export const GatewaysV2Schema = z.object({
identityId: z.string().uuid(),
relayId: z.string().uuid().nullable().optional(),
name: z.string(),
heartbeat: z.date().nullable().optional()
heartbeat: z.date().nullable().optional(),
encryptedPamSessionKey: zodBuffer.nullable().optional()
});
export type TGatewaysV2 = z.infer<typeof GatewaysV2Schema>;

View File

@@ -83,6 +83,10 @@ export * from "./org-memberships";
export * from "./org-relay-config";
export * from "./org-roles";
export * from "./organizations";
export * from "./pam-accounts";
export * from "./pam-folders";
export * from "./pam-resources";
export * from "./pam-sessions";
export * from "./pki-alerts";
export * from "./pki-collection-items";
export * from "./pki-collections";

View File

@@ -189,7 +189,13 @@ export enum TableName {
Relay = "relays",
GatewayV2 = "gateways_v2",
KeyValueStore = "key_value_store"
KeyValueStore = "key_value_store",
// PAM
PamFolder = "pam_folders",
PamResource = "pam_resources",
PamAccount = "pam_accounts",
PamSession = "pam_sessions"
}
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId";
@@ -281,7 +287,8 @@ export enum ProjectType {
CertificateManager = "cert-manager",
KMS = "kms",
SSH = "ssh",
SecretScanning = "secret-scanning"
SecretScanning = "secret-scanning",
PAM = "pam"
}
export enum ActionProjectType {
@@ -290,6 +297,7 @@ export enum ActionProjectType {
KMS = ProjectType.KMS,
SSH = ProjectType.SSH,
SecretScanning = ProjectType.SecretScanning,
PAM = ProjectType.PAM,
// project operations that happen on all types
Any = "any"
}

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>>;

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>>;

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>>;

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>>;

View File

@@ -28,7 +28,8 @@ export const SamlConfigsSchema = z.object({
lastUsed: z.date().nullable().optional(),
encryptedSamlEntryPoint: zodBuffer,
encryptedSamlIssuer: zodBuffer,
encryptedSamlCertificate: zodBuffer
encryptedSamlCertificate: zodBuffer,
enableGroupSync: z.boolean().default(false)
});
export type TSamlConfigs = z.infer<typeof SamlConfigsSchema>;

View File

@@ -23,6 +23,12 @@ import { registerLdapRouter } from "./ldap-router";
import { registerLicenseRouter } from "./license-router";
import { registerOidcRouter } from "./oidc-router";
import { registerOrgRoleRouter } from "./org-role-router";
import { PAM_ACCOUNT_REGISTER_ROUTER_MAP } from "./pam-account-routers";
import { registerPamAccountRouter } from "./pam-account-routers/pam-account-router";
import { registerPamFolderRouter } from "./pam-folder-router";
import { PAM_RESOURCE_REGISTER_ROUTER_MAP } from "./pam-resource-routers";
import { registerPamResourceRouter } from "./pam-resource-routers/pam-resource-router";
import { registerPamSessionRouter } from "./pam-session-router";
import { registerPITRouter } from "./pit-router";
import { registerProjectRoleRouter } from "./project-role-router";
import { registerProjectRouter } from "./project-router";
@@ -166,4 +172,40 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
},
{ prefix: "/kmip" }
);
await server.register(
async (pamRouter) => {
await pamRouter.register(registerPamFolderRouter, { prefix: "/folders" });
await pamRouter.register(registerPamSessionRouter, { prefix: "/sessions" });
await pamRouter.register(
async (pamAccountRouter) => {
await pamAccountRouter.register(registerPamAccountRouter);
// Provider-specific endpoints
await Promise.all(
Object.entries(PAM_ACCOUNT_REGISTER_ROUTER_MAP).map(([provider, router]) =>
pamAccountRouter.register(router, { prefix: `/${provider}` })
)
);
},
{ prefix: "/accounts" }
);
await pamRouter.register(
async (pamResourceRouter) => {
await pamResourceRouter.register(registerPamResourceRouter);
// Provider-specific endpoints
await Promise.all(
Object.entries(PAM_RESOURCE_REGISTER_ROUTER_MAP).map(([provider, router]) =>
pamResourceRouter.register(router, { prefix: `/${provider}` })
)
);
},
{ prefix: "/resources" }
);
},
{ prefix: "/pam" }
);
};

View File

@@ -0,0 +1,20 @@
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
import {
CreatePostgresAccountSchema,
SanitizedPostgresAccountWithResourceSchema,
UpdatePostgresAccountSchema
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import { registerPamResourceEndpoints } from "./pam-account-endpoints";
export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record<PamResource, (server: FastifyZodProvider) => Promise<void>> = {
[PamResource.Postgres]: async (server: FastifyZodProvider) => {
registerPamResourceEndpoints({
server,
resourceType: PamResource.Postgres,
accountResponseSchema: SanitizedPostgresAccountWithResourceSchema,
createAccountSchema: CreatePostgresAccountSchema,
updateAccountSchema: UpdatePostgresAccountSchema
});
}
};

View File

@@ -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 };
}
});
};

View File

@@ -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;
}
});
};

View File

@@ -0,0 +1,150 @@
import { z } from "zod";
import { PamFoldersSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { isValidFolderName } from "@app/lib/validator";
import { writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerPamFolderRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/",
config: {
rateLimit: writeLimit
},
schema: {
description: "Create PAM folder",
body: z.object({
projectId: z.string().uuid(),
parentId: z.string().uuid().nullable().optional(),
name: z
.string()
.trim()
.refine((name) => isValidFolderName(name), {
message: "Folder name can only contain alphanumeric characters, dashes, and underscores."
}),
description: z.string().trim().max(512).nullable().optional()
}),
response: {
200: z.object({
folder: PamFoldersSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const folder = await server.services.pamFolder.createFolder(req.body, req.permission);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: req.body.projectId,
event: {
type: EventType.PAM_FOLDER_CREATE,
metadata: {
name: req.body.name,
description: req.body.description,
parentId: req.body.parentId
}
}
});
return { folder };
}
});
server.route({
method: "PATCH",
url: "/:folderId",
config: {
rateLimit: writeLimit
},
schema: {
description: "Update PAM folder",
params: z.object({
folderId: z.string().uuid()
}),
body: z.object({
name: z
.string()
.trim()
.optional()
.refine((name) => (name ? isValidFolderName(name) : true), {
message: "Folder name can only contain alphanumeric characters, dashes, and underscores."
}),
description: z.string().trim().max(512).nullable().optional()
}),
response: {
200: z.object({
folder: PamFoldersSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const folder = await server.services.pamFolder.updateFolder(
{
...req.body,
id: req.params.folderId
},
req.permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: folder.projectId,
event: {
type: EventType.PAM_FOLDER_UPDATE,
metadata: {
folderId: req.params.folderId,
name: req.body.name,
description: req.body.description
}
}
});
return { folder };
}
});
server.route({
method: "DELETE",
url: "/:folderId",
config: {
rateLimit: writeLimit
},
schema: {
description: "Delete PAM folder",
params: z.object({
folderId: z.string().uuid()
}),
response: {
200: z.object({
folder: PamFoldersSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const folder = await server.services.pamFolder.deleteFolder(req.params.folderId, req.permission);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: folder.projectId,
event: {
type: EventType.PAM_FOLDER_DELETE,
metadata: {
folderName: folder.name,
folderId: req.params.folderId
}
}
});
return { folder };
}
});
};

View File

@@ -0,0 +1,20 @@
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
import {
CreatePostgresResourceSchema,
PostgresResourceSchema,
UpdatePostgresResourceSchema
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import { registerPamResourceEndpoints } from "./pam-resource-endpoints";
export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record<PamResource, (server: FastifyZodProvider) => Promise<void>> = {
[PamResource.Postgres]: async (server: FastifyZodProvider) => {
registerPamResourceEndpoints({
server,
resourceType: PamResource.Postgres,
resourceResponseSchema: PostgresResourceSchema,
createResourceSchema: CreatePostgresResourceSchema,
updateResourceSchema: UpdatePostgresResourceSchema
});
}
};

View File

@@ -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 };
}
});
};

View File

@@ -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;
}
});
};

View File

@@ -0,0 +1,224 @@
import { z } from "zod";
import { PamSessionsSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "@app/ee/services/pam-session/pam-session-schemas";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
// Use z.union([]) once there's multiple
const SessionCredentialsSchema = PostgresSessionCredentialsSchema;
export const registerPamSessionRouter = async (server: FastifyZodProvider) => {
// Meant to be hit solely by gateway identities
server.route({
method: "GET",
url: "/:sessionId/credentials",
config: {
rateLimit: readLimit
},
schema: {
description: "Get PAM session credentials and start session",
params: z.object({
sessionId: z.string().uuid()
}),
response: {
200: z.object({
credentials: SessionCredentialsSchema
})
}
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { credentials, projectId, account } = await server.services.pamAccount.getSessionCredentials(
req.params.sessionId,
req.permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId,
event: {
type: EventType.PAM_SESSION_START,
metadata: {
sessionId: req.params.sessionId,
accountName: account.name
}
}
});
return { credentials };
}
});
// Meant to be hit solely by gateway identities
server.route({
method: "POST",
url: "/:sessionId/logs",
config: {
rateLimit: writeLimit
},
schema: {
description: "Update PAM session logs",
params: z.object({
sessionId: z.string().uuid()
}),
body: z.object({
logs: PamSessionCommandLogSchema.array()
}),
response: {
200: z.object({
session: PamSessionsSchema.omit({
encryptedLogsBlob: true
})
})
}
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { session, projectId } = await server.services.pamSession.updateLogsById(
{
sessionId: req.params.sessionId,
logs: req.body.logs
},
req.permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId,
event: {
type: EventType.PAM_SESSION_LOGS_UPDATE,
metadata: {
sessionId: req.params.sessionId,
accountName: session.accountName
}
}
});
return { session };
}
});
// Meant to be hit solely by gateway identities
server.route({
method: "POST",
url: "/:sessionId/end",
config: {
rateLimit: writeLimit
},
schema: {
description: "End PAM session",
params: z.object({
sessionId: z.string().uuid()
}),
response: {
200: z.object({
session: PamSessionsSchema.omit({
encryptedLogsBlob: true
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { session, projectId } = await server.services.pamSession.endSessionById(
req.params.sessionId,
req.permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId,
event: {
type: EventType.PAM_SESSION_END,
metadata: {
sessionId: req.params.sessionId,
accountName: session.accountName
}
}
});
return { session };
}
});
server.route({
method: "GET",
url: "/:sessionId",
config: {
rateLimit: readLimit
},
schema: {
description: "Get PAM session",
params: z.object({
sessionId: z.string().uuid()
}),
response: {
200: z.object({
session: SanitizedSessionSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const response = await server.services.pamSession.getById(req.params.sessionId, req.permission);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: response.session.projectId,
event: {
type: EventType.PAM_SESSION_GET,
metadata: {
sessionId: req.params.sessionId
}
}
});
return response;
}
});
server.route({
method: "GET",
url: "/",
config: {
rateLimit: readLimit
},
schema: {
description: "List PAM sessions",
querystring: z.object({
projectId: z.string().uuid()
}),
response: {
200: z.object({
sessions: SanitizedSessionSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const response = await server.services.pamSession.list(req.query.projectId, req.permission);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: req.query.projectId,
event: {
type: EventType.PAM_SESSION_LIST,
metadata: {
count: response.sessions.length
}
}
});
return response;
}
});
};

View File

@@ -286,7 +286,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
entryPoint: z.string(),
issuer: z.string(),
cert: z.string(),
lastUsed: z.date().nullable().optional()
lastUsed: z.date().nullable().optional(),
enableGroupSync: z.boolean().optional()
})
}
},
@@ -325,14 +326,15 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
isActive: z.boolean().describe(SamlSso.CREATE_CONFIG.isActive),
entryPoint: z.string().trim().describe(SamlSso.CREATE_CONFIG.entryPoint),
issuer: z.string().trim().describe(SamlSso.CREATE_CONFIG.issuer),
cert: z.string().trim().describe(SamlSso.CREATE_CONFIG.cert)
cert: z.string().trim().describe(SamlSso.CREATE_CONFIG.cert),
enableGroupSync: z.boolean().optional().describe(SamlSso.CREATE_CONFIG.enableGroupSync)
}),
response: {
200: SanitizedSamlConfigSchema
}
},
handler: async (req) => {
const { isActive, authProvider, issuer, entryPoint, cert } = req.body;
const { isActive, authProvider, issuer, entryPoint, cert, enableGroupSync } = req.body;
const { permission } = req;
return server.services.saml.createSamlCfg({
@@ -341,6 +343,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
issuer,
entryPoint,
idpCert: cert,
enableGroupSync,
actor: permission.type,
actorId: permission.id,
actorAuthMethod: permission.authMethod,
@@ -372,7 +375,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
isActive: z.boolean().describe(SamlSso.UPDATE_CONFIG.isActive),
entryPoint: z.string().trim().describe(SamlSso.UPDATE_CONFIG.entryPoint),
issuer: z.string().trim().describe(SamlSso.UPDATE_CONFIG.issuer),
cert: z.string().trim().describe(SamlSso.UPDATE_CONFIG.cert)
cert: z.string().trim().describe(SamlSso.UPDATE_CONFIG.cert),
enableGroupSync: z.boolean().optional().describe(SamlSso.UPDATE_CONFIG.enableGroupSync)
})
.partial()
.merge(z.object({ organizationId: z.string().trim().describe(SamlSso.UPDATE_CONFIG.organizationId) })),
@@ -381,7 +385,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
}
},
handler: async (req) => {
const { isActive, authProvider, issuer, entryPoint, cert } = req.body;
const { isActive, authProvider, issuer, entryPoint, cert, enableGroupSync } = req.body;
const { permission } = req;
return server.services.saml.updateSamlCfg({
@@ -390,6 +394,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
issuer,
entryPoint,
idpCert: cert,
enableGroupSync,
actor: permission.type,
actorId: permission.id,
actorAuthMethod: permission.authMethod,

View File

@@ -1,6 +1,7 @@
import z from "zod";
import { GatewaysV2Schema } from "@app/db/schemas";
import { zodBuffer } from "@app/lib/zod";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -130,4 +131,25 @@ export const registerGatewayV2Router = async (server: FastifyZodProvider) => {
return gateway;
}
});
server.route({
method: "GET",
url: "/pam-session-key",
config: {
rateLimit: readLimit
},
schema: {
response: {
200: zodBuffer
}
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const pamSessionKey = await server.services.gatewayV2.getPamSessionKey({
orgPermission: req.permission
});
return pamSessionKey;
}
});
};

View File

@@ -500,7 +500,26 @@ export enum EventType {
DASHBOARD_LIST_SECRETS = "dashboard-list-secrets",
DASHBOARD_GET_SECRET_VALUE = "dashboard-get-secret-value",
DASHBOARD_GET_SECRET_VERSION_VALUE = "dashboard-get-secret-version-value"
DASHBOARD_GET_SECRET_VERSION_VALUE = "dashboard-get-secret-version-value",
PAM_SESSION_START = "pam-session-start",
PAM_SESSION_LOGS_UPDATE = "pam-session-logs-update",
PAM_SESSION_END = "pam-session-end",
PAM_SESSION_GET = "pam-session-get",
PAM_SESSION_LIST = "pam-session-list",
PAM_FOLDER_CREATE = "pam-folder-create",
PAM_FOLDER_UPDATE = "pam-folder-update",
PAM_FOLDER_DELETE = "pam-folder-delete",
PAM_ACCOUNT_LIST = "pam-account-list",
PAM_ACCOUNT_ACCESS = "pam-account-access",
PAM_ACCOUNT_CREATE = "pam-account-create",
PAM_ACCOUNT_UPDATE = "pam-account-update",
PAM_ACCOUNT_DELETE = "pam-account-delete",
PAM_RESOURCE_LIST = "pam-resource-list",
PAM_RESOURCE_GET = "pam-resource-get",
PAM_RESOURCE_CREATE = "pam-resource-create",
PAM_RESOURCE_UPDATE = "pam-resource-update",
PAM_RESOURCE_DELETE = "pam-resource-delete"
}
export const filterableSecretEvents: EventType[] = [
@@ -3687,6 +3706,162 @@ interface OrgRoleDeleteEvent {
};
}
interface PamSessionStartEvent {
type: EventType.PAM_SESSION_START;
metadata: {
sessionId: string;
accountName: string;
};
}
interface PamSessionLogsUpdateEvent {
type: EventType.PAM_SESSION_LOGS_UPDATE;
metadata: {
sessionId: string;
accountName: string;
};
}
interface PamSessionEndEvent {
type: EventType.PAM_SESSION_END;
metadata: {
sessionId: string;
accountName: string;
};
}
interface PamSessionGetEvent {
type: EventType.PAM_SESSION_GET;
metadata: {
sessionId: string;
};
}
interface PamSessionListEvent {
type: EventType.PAM_SESSION_LIST;
metadata: {
count: number;
};
}
interface PamFolderCreateEvent {
type: EventType.PAM_FOLDER_CREATE;
metadata: {
parentId?: string | null;
name: string;
description?: string | null;
};
}
interface PamFolderUpdateEvent {
type: EventType.PAM_FOLDER_UPDATE;
metadata: {
folderId: string;
name?: string;
description?: string | null;
};
}
interface PamFolderDeleteEvent {
type: EventType.PAM_FOLDER_DELETE;
metadata: {
folderId: string;
folderName: string;
};
}
interface PamAccountListEvent {
type: EventType.PAM_ACCOUNT_LIST;
metadata: {
accountCount: number;
folderCount: number;
};
}
interface PamAccountAccessEvent {
type: EventType.PAM_ACCOUNT_ACCESS;
metadata: {
accountId: string;
accountName: string;
duration?: string;
};
}
interface PamAccountCreateEvent {
type: EventType.PAM_ACCOUNT_CREATE;
metadata: {
resourceId: string;
resourceType: string;
folderId?: string | null;
name: string;
description?: string | null;
};
}
interface PamAccountUpdateEvent {
type: EventType.PAM_ACCOUNT_UPDATE;
metadata: {
accountId: string;
resourceId: string;
resourceType: string;
name?: string;
description?: string | null;
};
}
interface PamAccountDeleteEvent {
type: EventType.PAM_ACCOUNT_DELETE;
metadata: {
accountName: string;
accountId: string;
resourceId: string;
resourceType: string;
};
}
interface PamResourceListEvent {
type: EventType.PAM_RESOURCE_LIST;
metadata: {
count: number;
};
}
interface PamResourceGetEvent {
type: EventType.PAM_RESOURCE_GET;
metadata: {
resourceId: string;
resourceType: string;
name: string;
};
}
interface PamResourceCreateEvent {
type: EventType.PAM_RESOURCE_CREATE;
metadata: {
resourceType: string;
gatewayId: string;
name: string;
};
}
interface PamResourceUpdateEvent {
type: EventType.PAM_RESOURCE_UPDATE;
metadata: {
resourceId: string;
resourceType: string;
gatewayId?: string;
name?: string;
};
}
interface PamResourceDeleteEvent {
type: EventType.PAM_RESOURCE_DELETE;
metadata: {
resourceId: string;
resourceType: string;
};
}
export type Event =
| GetSecretsEvent
| GetSecretEvent
@@ -4020,4 +4195,22 @@ export type Event =
| ProjectRoleDeleteEvent
| OrgRoleCreateEvent
| OrgRoleUpdateEvent
| OrgRoleDeleteEvent;
| OrgRoleDeleteEvent
| PamSessionStartEvent
| PamSessionLogsUpdateEvent
| PamSessionEndEvent
| PamSessionGetEvent
| PamSessionListEvent
| PamFolderCreateEvent
| PamFolderUpdateEvent
| PamFolderDeleteEvent
| PamAccountListEvent
| PamAccountAccessEvent
| PamAccountCreateEvent
| PamAccountUpdateEvent
| PamAccountDeleteEvent
| PamResourceListEvent
| PamResourceGetEvent
| PamResourceCreateEvent
| PamResourceUpdateEvent
| PamResourceDeleteEvent;

View File

@@ -34,6 +34,7 @@ export const ElasticSearchProvider = (): TDynamicProviderFns => {
const $getClient = async (providerInputs: z.infer<typeof DynamicSecretElasticSearchSchema>) => {
const connection = new ElasticSearchClient({
requestTimeout: 30_000,
node: {
url: new URL(`${providerInputs.host}:${providerInputs.port}`),
...(providerInputs.ca && {

View File

@@ -1,2 +1,3 @@
export const GATEWAY_ROUTING_INFO_OID = "1.3.6.1.4.1.12345.100.1";
export const GATEWAY_ACTOR_OID = "1.3.6.1.4.1.12345.100.2";
export const PAM_INFO_OID = "1.3.6.1.4.1.12345.100.3";

View File

@@ -22,11 +22,12 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TLicenseServiceFactory } from "../license/license-service";
import { PamResource } from "../pam-resource/pam-resource-enums";
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service-types";
import { TRelayDALFactory } from "../relay/relay-dal";
import { TRelayServiceFactory } from "../relay/relay-service";
import { GATEWAY_ACTOR_OID, GATEWAY_ROUTING_INFO_OID } from "./gateway-v2-constants";
import { GATEWAY_ACTOR_OID, GATEWAY_ROUTING_INFO_OID, PAM_INFO_OID } from "./gateway-v2-constants";
import { TGatewayV2DALFactory } from "./gateway-v2-dal";
import { TOrgGatewayConfigV2DALFactory } from "./org-gateway-config-v2-dal";
@@ -414,6 +415,176 @@ export const gatewayV2ServiceFactory = ({
};
};
const getPAMConnectionDetails = async ({
gatewayId,
sessionId,
duration,
resourceType,
host,
port,
actorMetadata
}: {
gatewayId: string;
sessionId: string;
resourceType: PamResource;
duration?: number;
host: string;
port: number;
actorMetadata: { id: string; type: ActorType; name: string };
}) => {
const gateway = await gatewayV2DAL.findById(gatewayId);
if (!gateway) {
return;
}
const orgGatewayConfig = await orgGatewayConfigV2DAL.findOne({ orgId: gateway.orgId });
if (!orgGatewayConfig) {
throw new NotFoundError({ message: `Gateway Config for org ${gateway.orgId} not found.` });
}
if (!gateway.relayId) {
throw new BadRequestError({
message: "Gateway is not associated with a relay"
});
}
const orgLicensePlan = await licenseService.getPlan(orgGatewayConfig.orgId);
if (!orgLicensePlan.gateway) {
throw new BadRequestError({
message: "Please upgrade your instance to Infisical's Enterprise plan to use gateways."
});
}
const { decryptor: orgKmsDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId: orgGatewayConfig.orgId
});
const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048);
const rootGatewayCaCert = new x509.X509Certificate(
orgKmsDecryptor({
cipherTextBlob: orgGatewayConfig.encryptedRootGatewayCaCertificate
})
);
const gatewayClientCaCert = new x509.X509Certificate(
orgKmsDecryptor({
cipherTextBlob: orgGatewayConfig.encryptedGatewayClientCaCertificate
})
);
const gatewayServerCaCert = new x509.X509Certificate(
orgKmsDecryptor({
cipherTextBlob: orgGatewayConfig.encryptedGatewayServerCaCertificate
})
);
const gatewayClientCaPrivateKey = orgKmsDecryptor({
cipherTextBlob: orgGatewayConfig.encryptedGatewayClientCaPrivateKey
});
const gatewayClientCaSkObj = crypto.nativeCrypto.createPrivateKey({
key: gatewayClientCaPrivateKey,
format: "der",
type: "pkcs8"
});
const importedGatewayClientCaPrivateKey = await crypto.nativeCrypto.subtle.importKey(
"pkcs8",
gatewayClientCaSkObj.export({ format: "der", type: "pkcs8" }),
alg,
true,
["sign"]
);
const clientCertIssuedAt = new Date();
const clientCertExpiration = new Date(new Date().getTime() + (duration ?? 5 * 60 * 1000));
const clientKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const clientCertSerialNumber = createSerialNumber();
const routingInfo = {
targetHost: host,
targetPort: port
};
const routingExtension = new x509.Extension(
GATEWAY_ROUTING_INFO_OID,
false,
Buffer.from(JSON.stringify(routingInfo))
);
const pamInfoExtension = new x509.Extension(
PAM_INFO_OID,
false,
Buffer.from(
JSON.stringify({
sessionId,
resourceType
})
)
);
const actorExtension = new x509.Extension(
GATEWAY_ACTOR_OID,
false,
Buffer.from(JSON.stringify({ type: actorMetadata.type, id: actorMetadata.id, name: actorMetadata.name }))
);
const clientCert = await x509.X509CertificateGenerator.create({
serialNumber: clientCertSerialNumber,
subject: `O=${orgGatewayConfig.orgId},OU=gateway-client,CN=${actorMetadata.type}:${gatewayId}`,
issuer: gatewayClientCaCert.subject,
notAfter: clientCertExpiration,
notBefore: clientCertIssuedAt,
signingKey: importedGatewayClientCaPrivateKey,
publicKey: clientKeys.publicKey,
signingAlgorithm: alg,
extensions: [
new x509.BasicConstraintsExtension(false),
await x509.AuthorityKeyIdentifierExtension.create(gatewayClientCaCert, false),
await x509.SubjectKeyIdentifierExtension.create(clientKeys.publicKey),
new x509.CertificatePolicyExtension(["2.5.29.32.0"]), // anyPolicy
new x509.KeyUsagesExtension(
// eslint-disable-next-line no-bitwise
x509.KeyUsageFlags[CertKeyUsage.DIGITAL_SIGNATURE] |
x509.KeyUsageFlags[CertKeyUsage.KEY_ENCIPHERMENT] |
x509.KeyUsageFlags[CertKeyUsage.KEY_AGREEMENT],
true
),
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.CLIENT_AUTH]], true),
routingExtension,
actorExtension,
pamInfoExtension
]
});
const gatewayClientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey);
const relayCredentials = await relayService.getCredentialsForClient({
relayId: gateway.relayId,
orgId: gateway.orgId,
orgName: gateway.orgName,
gatewayId,
gatewayName: gateway.name,
duration
});
return {
relayHost: relayCredentials.relayHost,
gateway: {
clientCertificate: clientCert.toString("pem"),
clientPrivateKey: gatewayClientCertPrivateKey.export({ format: "pem", type: "pkcs8" }).toString(),
serverCertificateChain: constructPemChainFromCerts([gatewayServerCaCert, rootGatewayCaCert])
},
relay: {
clientCertificate: relayCredentials.clientCertificate,
clientPrivateKey: relayCredentials.clientPrivateKey,
serverCertificateChain: relayCredentials.serverCertificateChain
}
};
};
const registerGateway = async ({
orgId,
actorId,
@@ -645,14 +816,75 @@ export const gatewayV2ServiceFactory = ({
OrgPermissionSubjects.Gateway
);
return gatewayV2DAL.deleteById(gateway.id);
try {
return await gatewayV2DAL.deleteById(gateway.id);
} catch (err) {
if (
err instanceof DatabaseError &&
(err.error as { code: string })?.code === DatabaseErrorCode.ForeignKeyViolation
) {
throw new BadRequestError({
message: "Failed to delete gateway because it is attached to active resources"
});
}
throw err;
}
};
const getPamSessionKey = async ({ orgPermission }: { orgPermission: OrgServiceActor }) => {
const { permission } = await permissionService.getOrgPermission(
orgPermission.type,
orgPermission.id,
orgPermission.orgId,
orgPermission.authMethod,
orgPermission.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionGatewayActions.CreateGateways,
OrgPermissionSubjects.Gateway
);
return gatewayV2DAL.transaction(async (tx) => {
const gateway = await gatewayV2DAL.findOne(
{
identityId: orgPermission.id
},
tx
);
if (!gateway) {
throw new NotFoundError({ message: "Gateway not found" });
}
const { encryptor, decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId: orgPermission.orgId
});
if (gateway.encryptedPamSessionKey) {
return decryptor({ cipherTextBlob: gateway.encryptedPamSessionKey });
}
await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.GatewayPamSessionKey(gateway.id)]);
const newPamSessionKey = crypto.randomBytes(32);
const { cipherTextBlob: encryptedPamSessionKey } = encryptor({ plainText: newPamSessionKey });
await gatewayV2DAL.updateById(gateway.id, { encryptedPamSessionKey }, tx);
return newPamSessionKey;
});
};
return {
listGateways,
registerGateway,
getPlatformConnectionDetailsByGatewayId,
getPAMConnectionDetails,
deleteGatewayById,
heartbeat
heartbeat,
getPamSessionKey
};
};

View File

@@ -66,7 +66,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
enterpriseAppConnections: false,
fips: false,
eventSubscriptions: false,
machineIdentityAuthTemplates: false
machineIdentityAuthTemplates: false,
pam: false
});
export const setupLicenseRequestWithStore = (

View File

@@ -461,17 +461,16 @@ export const licenseServiceFactory = ({
};
const fetchPlanTableFromServer = async (customerId: string | null | undefined) => {
if (!customerId) {
throw new NotFoundError({ message: "Organization customer ID is required for plan table retrieval" });
}
const baseUrl = `/api/license-server/v1/customers/${customerId}`;
const baseUrl = `/api/license-server/v1/customers`;
if (instanceType === InstanceType.Cloud) {
if (!customerId) {
throw new NotFoundError({ message: "Organization customer ID is required for plan table retrieval" });
}
const { data } = await licenseServerCloudApi.request.get<{
head: { name: string }[];
rows: { name: string; allowed: boolean }[];
}>(`${baseUrl}/cloud-plan/table`);
}>(`${baseUrl}/${customerId}/cloud-plan/table`);
return data;
}

View File

@@ -80,6 +80,7 @@ export type TFeatureSet = {
machineIdentityAuthTemplates: false;
fips: false;
eventSubscriptions: false;
pam: false;
};
export type TOrgPlansTableDTO = {

View File

@@ -0,0 +1,43 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName, TPamAccounts } from "@app/db/schemas";
import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex";
export type TPamAccountDALFactory = ReturnType<typeof pamAccountDALFactory>;
type PamAccountFindFilter = Parameters<typeof buildFindFilter<TPamAccounts>>[0];
export const pamAccountDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.PamAccount);
const findWithResourceDetails = async (filter: PamAccountFindFilter, tx?: Knex) => {
const query = (tx || db.replicaNode())(TableName.PamAccount)
.leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`)
.select(selectAllTableCols(TableName.PamAccount))
.select(
// resource
db.ref("name").withSchema(TableName.PamResource).as("resourceName"),
db.ref("resourceType").withSchema(TableName.PamResource)
);
if (filter) {
/* eslint-disable @typescript-eslint/no-misused-promises */
void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PamAccount, filter)));
}
const accounts = await query;
return accounts.map(({ resourceId, resourceName, resourceType, ...account }) => ({
...account,
resourceId,
resource: {
id: resourceId,
name: resourceName,
resourceType
}
}));
};
return { ...orm, findWithResourceDetails };
};

View File

@@ -0,0 +1,61 @@
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TPamAccountCredentials } from "../pam-resource/pam-resource-types";
export const encryptAccountCredentials = async ({
projectId,
credentials,
kmsService
}: {
projectId: string;
credentials: TPamAccountCredentials;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({
plainText: Buffer.from(JSON.stringify(credentials))
});
return encryptedCredentialsBlob;
};
export const decryptAccountCredentials = async ({
projectId,
encryptedCredentials,
kmsService
}: {
projectId: string;
encryptedCredentials: Buffer;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
const { decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
const decryptedPlainTextBlob = decryptor({
cipherTextBlob: encryptedCredentials
});
return JSON.parse(decryptedPlainTextBlob.toString()) as TPamAccountCredentials;
};
export const decryptAccount = async <T extends { encryptedCredentials: Buffer }>(
account: T,
projectId: string,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
): Promise<T & { credentials: TPamAccountCredentials }> => {
return {
...account,
credentials: await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
projectId,
kmsService
})
} as T & { credentials: TPamAccountCredentials };
};

View File

@@ -0,0 +1,527 @@
import { ForbiddenError, subject } from "@casl/ability";
import { ActionProjectType, TPamAccounts, TPamResources } from "@app/db/schemas";
import { PAM_RESOURCE_FACTORY_MAP } from "@app/ee/services/pam-resource/pam-resource-factory";
import { decryptResource, decryptResourceConnectionDetails } from "@app/ee/services/pam-resource/pam-resource-fns";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import {
ProjectPermissionActions,
ProjectPermissionPamAccountActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { DatabaseErrorCode } from "@app/lib/error-codes";
import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { ActorType } from "@app/services/auth/auth-type";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
import { TLicenseServiceFactory } from "../license/license-service";
import { TPamFolderDALFactory } from "../pam-folder/pam-folder-dal";
import { getFullPamFolderPath } from "../pam-folder/pam-folder-fns";
import { TPamResourceDALFactory } from "../pam-resource/pam-resource-dal";
import { PamResource } from "../pam-resource/pam-resource-enums";
import { TPamAccountCredentials } from "../pam-resource/pam-resource-types";
import { TPamSessionDALFactory } from "../pam-session/pam-session-dal";
import { PamSessionStatus } from "../pam-session/pam-session-enums";
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPamAccountDALFactory } from "./pam-account-dal";
import { decryptAccount, decryptAccountCredentials, encryptAccountCredentials } from "./pam-account-fns";
import { TAccessAccountDTO, TCreateAccountDTO, TUpdateAccountDTO } from "./pam-account-types";
type TPamAccountServiceFactoryDep = {
pamResourceDAL: TPamResourceDALFactory;
pamSessionDAL: TPamSessionDALFactory;
pamAccountDAL: TPamAccountDALFactory;
pamFolderDAL: TPamFolderDALFactory;
projectDAL: TProjectDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
gatewayV2Service: Pick<
TGatewayV2ServiceFactory,
"getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId"
>;
userDAL: TUserDALFactory;
};
export type TPamAccountServiceFactory = ReturnType<typeof pamAccountServiceFactory>;
export const pamAccountServiceFactory = ({
pamResourceDAL,
pamSessionDAL,
pamAccountDAL,
pamFolderDAL,
projectDAL,
userDAL,
permissionService,
licenseService,
kmsService,
gatewayV2Service
}: TPamAccountServiceFactoryDep) => {
const create = async (
{ credentials, resourceId, name, description, folderId }: TCreateAccountDTO,
actor: OrgServiceActor
) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
const resource = await pamResourceDAL.findById(resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: resource.projectId,
actionProjectType: ActionProjectType.PAM
});
const accountPath = await getFullPamFolderPath({
pamFolderDAL,
folderId,
projectId: resource.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamAccountActions.Create,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: resource.name,
accountName: name,
accountPath
})
);
const connectionDetails = await decryptResourceConnectionDetails({
projectId: resource.projectId,
encryptedConnectionDetails: resource.encryptedConnectionDetails,
kmsService
});
const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource](
resource.resourceType as PamResource,
connectionDetails,
resource.gatewayId,
gatewayV2Service
);
const validatedCredentials = await factory.validateAccountCredentials(credentials);
const encryptedCredentials = await encryptAccountCredentials({
credentials: validatedCredentials,
projectId: resource.projectId,
kmsService
});
try {
const account = await pamAccountDAL.create({
projectId: resource.projectId,
resourceId: resource.id,
encryptedCredentials,
name,
description,
folderId
});
return {
...(await decryptAccount(account, resource.projectId, kmsService)),
resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType }
};
} catch (err) {
if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) {
throw new BadRequestError({
message: `Account with name '${name}' already exists for this path`
});
}
throw err;
}
};
const updateById = async (
{ accountId, credentials, description, name }: TUpdateAccountDTO,
actor: OrgServiceActor
) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
const account = await pamAccountDAL.findById(accountId);
if (!account) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` });
const resource = await pamResourceDAL.findById(account.resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: account.projectId,
actionProjectType: ActionProjectType.PAM
});
const accountPath = await getFullPamFolderPath({
pamFolderDAL,
folderId: account.folderId,
projectId: account.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamAccountActions.Edit,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: resource.name,
accountName: account.name,
accountPath
})
);
const updateDoc: Partial<TPamAccounts> = {};
if (name !== undefined) {
updateDoc.name = name;
}
if (description !== undefined) {
updateDoc.description = description;
}
if (credentials !== undefined) {
const connectionDetails = await decryptResourceConnectionDetails({
projectId: account.projectId,
encryptedConnectionDetails: resource.encryptedConnectionDetails,
kmsService
});
const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource](
resource.resourceType as PamResource,
connectionDetails,
resource.gatewayId,
gatewayV2Service
);
// Logic to prevent overwriting unedited censored values
const finalCredentials = { ...credentials };
if (credentials.password === "******") {
const decryptedCredentials = await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
projectId: account.projectId,
kmsService
});
finalCredentials.password = decryptedCredentials.password;
}
const validatedCredentials = await factory.validateAccountCredentials(finalCredentials);
const encryptedCredentials = await encryptAccountCredentials({
credentials: validatedCredentials,
projectId: account.projectId,
kmsService
});
updateDoc.encryptedCredentials = encryptedCredentials;
}
// If nothing was updated, return the fetched account
if (Object.keys(updateDoc).length === 0) {
return decryptAccount(account, account.projectId, kmsService);
}
const updatedAccount = await pamAccountDAL.updateById(accountId, updateDoc);
return {
...(await decryptAccount(updatedAccount, account.projectId, kmsService)),
resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType }
};
};
const deleteById = async (id: string, actor: OrgServiceActor) => {
const account = await pamAccountDAL.findById(id);
if (!account) throw new NotFoundError({ message: `Account with ID '${id}' not found` });
const resource = await pamResourceDAL.findById(account.resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: account.projectId,
actionProjectType: ActionProjectType.PAM
});
const accountPath = await getFullPamFolderPath({
pamFolderDAL,
folderId: account.folderId,
projectId: account.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamAccountActions.Delete,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: resource.name,
accountName: account.name,
accountPath
})
);
const deletedAccount = await pamAccountDAL.deleteById(id);
return {
...(await decryptAccount(deletedAccount, account.projectId, kmsService)),
resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType }
};
};
const list = async (projectId: string, actor: OrgServiceActor) => {
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId,
actionProjectType: ActionProjectType.PAM
});
const accountsWithResourceDetails = await pamAccountDAL.findWithResourceDetails({ projectId });
const canReadFolders = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.PamFolders);
const folders = canReadFolders ? await pamFolderDAL.find({ projectId }) : [];
const decryptedAndPermittedAccounts: Array<
TPamAccounts & {
resource: Pick<TPamResources, "id" | "name" | "resourceType">;
credentials: TPamAccountCredentials;
}
> = [];
for await (const account of accountsWithResourceDetails) {
const accountPath = await getFullPamFolderPath({
pamFolderDAL,
folderId: account.folderId,
projectId: account.projectId
});
// Check permission for each individual account
if (
permission.can(
ProjectPermissionPamAccountActions.Read,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: account.resource.name,
accountName: account.name,
accountPath
})
)
) {
// Decrypt the account only if the user has permission to read it
const decryptedAccount = await decryptAccount(account, account.projectId, kmsService);
decryptedAndPermittedAccounts.push({
...decryptedAccount,
resource: {
id: account.resource.id,
name: account.resource.name,
resourceType: account.resource.resourceType
}
});
}
}
return {
accounts: decryptedAndPermittedAccounts,
folders
};
};
const access = async (
{ accountId, actorEmail, actorIp, actorName, actorUserAgent, duration }: TAccessAccountDTO,
actor: OrgServiceActor
) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
const account = await pamAccountDAL.findById(accountId);
if (!account) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` });
const resource = await pamResourceDAL.findById(account.resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: account.projectId,
actionProjectType: ActionProjectType.PAM
});
const accountPath = await getFullPamFolderPath({
pamFolderDAL,
folderId: account.folderId,
projectId: account.projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamAccountActions.Access,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: resource.name,
accountName: account.name,
accountPath
})
);
const session = await pamSessionDAL.create({
accountName: account.name,
actorEmail,
actorIp,
actorName,
actorUserAgent,
projectId: account.projectId,
resourceName: resource.name,
resourceType: resource.resourceType,
status: PamSessionStatus.Starting,
accountId: account.id,
userId: actor.id,
expiresAt: new Date(Date.now() + duration)
});
const { connectionDetails, gatewayId, resourceType } = await decryptResource(
resource,
account.projectId,
kmsService
);
const user = await userDAL.findById(actor.id);
if (!user) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` });
const gatewayConnectionDetails = await gatewayV2Service.getPAMConnectionDetails({
gatewayId,
duration,
sessionId: session.id,
resourceType: resource.resourceType as PamResource,
host: connectionDetails.host,
port: connectionDetails.port,
actorMetadata: {
id: actor.id,
type: actor.type,
name: user.email ?? ""
}
});
if (!gatewayConnectionDetails) {
throw new NotFoundError({ message: `Gateway connection details for gateway '${gatewayId}' not found.` });
}
return {
sessionId: session.id,
resourceType,
relayClientCertificate: gatewayConnectionDetails.relay.clientCertificate,
relayClientPrivateKey: gatewayConnectionDetails.relay.clientPrivateKey,
relayServerCertificateChain: gatewayConnectionDetails.relay.serverCertificateChain,
gatewayClientCertificate: gatewayConnectionDetails.gateway.clientCertificate,
gatewayClientPrivateKey: gatewayConnectionDetails.gateway.clientPrivateKey,
gatewayServerCertificateChain: gatewayConnectionDetails.gateway.serverCertificateChain,
relayHost: gatewayConnectionDetails.relayHost,
projectId: account.projectId,
account
};
};
const getSessionCredentials = async (sessionId: string, actor: OrgServiceActor) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
// To be hit by gateways only
if (actor.type !== ActorType.IDENTITY) {
throw new ForbiddenRequestError({ message: "Only gateways can perform this action" });
}
const session = await pamSessionDAL.findById(sessionId);
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
const project = await projectDAL.findById(session.projectId);
if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` });
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
project.orgId,
actor.authMethod,
actor.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionGatewayActions.CreateGateways,
OrgPermissionSubjects.Gateway
);
if (!session.accountId) throw new NotFoundError({ message: "Session is missing accountId column" });
// Verify that the session has not ended
if (session.endedAt || (session.expiresAt && session.expiresAt < new Date())) {
throw new BadRequestError({ message: "Session has ended or expired" });
}
// Verify that the session has not already had credentials fetched
if (session.status !== PamSessionStatus.Starting) {
throw new BadRequestError({ message: "Session has already been started" });
}
const account = await pamAccountDAL.findById(session.accountId);
if (!account) throw new NotFoundError({ message: `Account with ID '${session.accountId}' not found` });
const resource = await pamResourceDAL.findById(account.resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
if (resource.gatewayIdentityId !== actor.id) {
throw new ForbiddenRequestError({
message: "Identity does not have access to fetch the PAM session credentials"
});
}
const decryptedAccount = await decryptAccount(account, session.projectId, kmsService);
const decryptedResource = await decryptResource(resource, session.projectId, kmsService);
// Mark session as started
await pamSessionDAL.updateById(sessionId, {
status: PamSessionStatus.Active,
startedAt: new Date()
});
return {
credentials: {
...decryptedResource.connectionDetails,
...decryptedAccount.credentials
},
projectId: project.id,
account
};
};
return {
create,
updateById,
deleteById,
list,
access,
getSessionCredentials
};
};

View File

@@ -0,0 +1,17 @@
import { TPamAccount } from "../pam-resource/pam-resource-types";
// DTOs
export type TCreateAccountDTO = Pick<TPamAccount, "name" | "description" | "credentials" | "folderId" | "resourceId">;
export type TUpdateAccountDTO = Partial<Omit<TCreateAccountDTO, "folderId" | "resourceId">> & {
accountId: string;
};
export type TAccessAccountDTO = {
accountId: string;
actorEmail: string;
actorIp: string;
actorName: string;
actorUserAgent: string;
duration: number;
};

View File

@@ -0,0 +1,9 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TPamFolderDALFactory = ReturnType<typeof pamFolderDALFactory>;
export const pamFolderDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.PamFolder);
return { ...orm };
};

View File

@@ -0,0 +1,33 @@
import { TPamFolderDALFactory } from "./pam-folder-dal";
type GetFullFolderPath = {
pamFolderDAL: Pick<TPamFolderDALFactory, "find">;
folderId?: string | null;
projectId: string;
};
export const getFullPamFolderPath = async ({
pamFolderDAL,
folderId,
projectId
}: GetFullFolderPath): Promise<string> => {
if (!folderId) return "/";
const folders = await pamFolderDAL.find({ projectId });
const folderMap = new Map(folders.map((folder) => [folder.id, folder]));
if (!folderMap.has(folderId)) return "";
const path: string[] = [];
let currentFolderId: string | null | undefined = folderId;
while (currentFolderId) {
const folder = folderMap.get(currentFolderId);
if (!folder) break;
path.unshift(folder.name);
currentFolderId = folder.parentId;
}
return `/${path.join("/")}`;
};

View File

@@ -0,0 +1,146 @@
import { ForbiddenError } from "@casl/ability";
import { ActionProjectType, TPamFolders } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { DatabaseErrorCode } from "@app/lib/error-codes";
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { TLicenseServiceFactory } from "../license/license-service";
import { TPamFolderDALFactory } from "./pam-folder-dal";
import { TCreateFolderDTO, TUpdateFolderDTO } from "./pam-folder-types";
type TPamFolderServiceFactoryDep = {
pamFolderDAL: TPamFolderDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
};
export type TPamFolderServiceFactory = ReturnType<typeof pamFolderServiceFactory>;
export const pamFolderServiceFactory = ({
pamFolderDAL,
permissionService,
licenseService
}: TPamFolderServiceFactoryDep) => {
const createFolder = async ({ name, description, parentId, projectId }: TCreateFolderDTO, actor: OrgServiceActor) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PamFolders);
if (parentId) {
if (!(await pamFolderDAL.findOne({ id: parentId, projectId }))) {
throw new NotFoundError({
message: `Parent folder '${parentId}' not found for project '${projectId}'`
});
}
}
try {
const folder = await pamFolderDAL.create({
name,
description: description ?? null,
parentId: parentId || null,
projectId
});
return folder;
} catch (err) {
if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) {
throw new BadRequestError({
message: `Folder with name '${name}' already exists for this path`
});
}
throw err;
}
};
const updateFolder = async ({ id, name, description }: TUpdateFolderDTO, actor: OrgServiceActor) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
const folder = await pamFolderDAL.findById(id);
if (!folder) throw new NotFoundError({ message: `Folder with ID '${id}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: folder.projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PamFolders);
const updateDoc: Partial<TPamFolders> = {};
if (name !== undefined) {
updateDoc.name = name;
}
if (description !== undefined) {
updateDoc.description = description;
}
if (Object.keys(updateDoc).length === 0) {
return folder;
}
try {
const updatedFolder = await pamFolderDAL.updateById(id, updateDoc);
return updatedFolder;
} catch (err) {
if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) {
throw new BadRequestError({
message: `Folder with name '${name}' already exists for this path`
});
}
throw err;
}
};
const deleteFolder = async (id: string, actor: OrgServiceActor) => {
const folder = await pamFolderDAL.findById(id);
if (!folder) throw new NotFoundError({ message: `Folder with ID '${id}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: folder.projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PamFolders);
const deletedFolder = await pamFolderDAL.deleteById(id);
return deletedFolder;
};
return { createFolder, updateFolder, deleteFolder };
};

View File

@@ -0,0 +1,13 @@
// DTOs
export interface TCreateFolderDTO {
projectId: string;
parentId?: string | null;
name: string;
description?: string | null;
}
export interface TUpdateFolderDTO {
id: string;
name?: string;
description?: string | null;
}

View File

@@ -0,0 +1,24 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify, selectAllTableCols } from "@app/lib/knex";
export type TPamResourceDALFactory = ReturnType<typeof pamResourceDALFactory>;
export const pamResourceDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.PamResource);
const findById = async (id: string, tx?: Knex) => {
const doc = await (tx || db.replicaNode())(TableName.PamResource)
.join(TableName.GatewayV2, `${TableName.PamResource}.gatewayId`, `${TableName.GatewayV2}.id`)
.select(selectAllTableCols(TableName.PamResource))
.select(db.ref("name").withSchema(TableName.GatewayV2).as("gatewayName"))
.select(db.ref("identityId").withSchema(TableName.GatewayV2).as("gatewayIdentityId"))
.where(`${TableName.PamResource}.id`, id)
.first();
return doc;
};
return { ...orm, findById };
};

View File

@@ -0,0 +1,3 @@
export enum PamResource {
Postgres = "postgres"
}

View File

@@ -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
};

View File

@@ -0,0 +1,68 @@
import { TPamResources } from "@app/db/schemas";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types";
import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns";
export const listResourceOptions = () => {
return [getPostgresResourceListItem()].sort((a, b) => a.name.localeCompare(b.name));
};
// Resource
export const encryptResourceConnectionDetails = async ({
projectId,
connectionDetails,
kmsService
}: {
projectId: string;
connectionDetails: TPamResourceConnectionDetails;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
const { cipherTextBlob: encryptedConnectionDetailsBlob } = encryptor({
plainText: Buffer.from(JSON.stringify(connectionDetails))
});
return encryptedConnectionDetailsBlob;
};
export const decryptResourceConnectionDetails = async ({
projectId,
encryptedConnectionDetails,
kmsService
}: {
projectId: string;
encryptedConnectionDetails: Buffer;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
const { decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
const decryptedPlainTextBlob = decryptor({
cipherTextBlob: encryptedConnectionDetails
});
return JSON.parse(decryptedPlainTextBlob.toString()) as TPamResourceConnectionDetails;
};
export const decryptResource = async (
resource: TPamResources,
projectId: string,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
return {
...resource,
connectionDetails: await decryptResourceConnectionDetails({
encryptedConnectionDetails: resource.encryptedConnectionDetails,
projectId,
kmsService
})
} as TPamResource;
};

View File

@@ -0,0 +1,46 @@
import { z } from "zod";
import { PamAccountsSchema, PamResourcesSchema } from "@app/db/schemas";
import { slugSchema } from "@app/server/lib/schemas";
// Resources
export const BasePamResourceSchema = PamResourcesSchema.omit({
encryptedConnectionDetails: true,
resourceType: true
});
export const BaseCreatePamResourceSchema = z.object({
projectId: z.string().uuid(),
gatewayId: z.string().uuid(),
name: slugSchema({ field: "name" })
});
export const BaseUpdatePamResourceSchema = z.object({
gatewayId: z.string().uuid().optional(),
name: slugSchema({ field: "name" }).optional()
});
// Accounts
export const BasePamAccountSchema = PamAccountsSchema.omit({
encryptedCredentials: true
});
export const BasePamAccountSchemaWithResource = BasePamAccountSchema.extend({
resource: PamResourcesSchema.pick({
id: true,
name: true,
resourceType: true
})
});
export const BaseCreatePamAccountSchema = z.object({
resourceId: z.string().uuid(),
folderId: z.string().uuid().optional(),
name: slugSchema({ field: "name" }),
description: z.string().max(512).nullable().optional()
});
export const BaseUpdatePamAccountSchema = z.object({
name: slugSchema({ field: "name" }).optional(),
description: z.string().max(512).nullable().optional()
});

View File

@@ -0,0 +1,222 @@
import { ForbiddenError } from "@casl/ability";
import { ActionProjectType, TPamResources } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { DatabaseErrorCode } from "@app/lib/error-codes";
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
import { TLicenseServiceFactory } from "../license/license-service";
import { TPamResourceDALFactory } from "./pam-resource-dal";
import { PamResource } from "./pam-resource-enums";
import { PAM_RESOURCE_FACTORY_MAP } from "./pam-resource-factory";
import { decryptResource, encryptResourceConnectionDetails, listResourceOptions } from "./pam-resource-fns";
import { TCreateResourceDTO, TUpdateResourceDTO } from "./pam-resource-types";
type TPamResourceServiceFactoryDep = {
pamResourceDAL: TPamResourceDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
gatewayV2Service: Pick<
TGatewayV2ServiceFactory,
"getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId"
>;
};
export type TPamResourceServiceFactory = ReturnType<typeof pamResourceServiceFactory>;
export const pamResourceServiceFactory = ({
pamResourceDAL,
permissionService,
licenseService,
kmsService,
gatewayV2Service
}: TPamResourceServiceFactoryDep) => {
const getById = async (id: string, resourceType: PamResource, actor: OrgServiceActor) => {
const resource = await pamResourceDAL.findById(id);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${id}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: resource.projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources);
if (resource.resourceType !== resourceType) {
throw new BadRequestError({
message: `Resource with ID '${id}' is not of type '${resourceType}'`
});
}
return decryptResource(resource, resource.projectId, kmsService);
};
const create = async (
{ resourceType, connectionDetails, gatewayId, name, projectId }: TCreateResourceDTO,
actor: OrgServiceActor
) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PamResources);
const factory = PAM_RESOURCE_FACTORY_MAP[resourceType](
resourceType,
connectionDetails,
gatewayId,
gatewayV2Service
);
const validatedConnectionDetails = await factory.validateConnection();
const encryptedConnectionDetails = await encryptResourceConnectionDetails({
connectionDetails: validatedConnectionDetails,
projectId,
kmsService
});
const resource = await pamResourceDAL.create({
resourceType,
encryptedConnectionDetails,
gatewayId,
name,
projectId
});
return decryptResource(resource, projectId, kmsService);
};
const updateById = async ({ connectionDetails, resourceId, name }: TUpdateResourceDTO, actor: OrgServiceActor) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
const resource = await pamResourceDAL.findById(resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: resource.projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PamResources);
const updateDoc: Partial<TPamResources> = {};
if (name !== undefined) {
updateDoc.name = name;
}
if (connectionDetails !== undefined) {
const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource](
resource.resourceType as PamResource,
connectionDetails,
resource.gatewayId,
gatewayV2Service
);
const validatedConnectionDetails = await factory.validateConnection();
const encryptedConnectionDetails = await encryptResourceConnectionDetails({
connectionDetails: validatedConnectionDetails,
projectId: resource.projectId,
kmsService
});
updateDoc.encryptedConnectionDetails = encryptedConnectionDetails;
}
// If nothing was updated, return the fetched resource
if (Object.keys(updateDoc).length === 0) {
return decryptResource(resource, resource.projectId, kmsService);
}
const updatedResource = await pamResourceDAL.updateById(resourceId, updateDoc);
return decryptResource(updatedResource, resource.projectId, kmsService);
};
const deleteById = async (id: string, actor: OrgServiceActor) => {
const resource = await pamResourceDAL.findById(id);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${id}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: resource.projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PamResources);
try {
const deletedResource = await pamResourceDAL.deleteById(id);
return await decryptResource(deletedResource, resource.projectId, kmsService);
} catch (err) {
if (
err instanceof DatabaseError &&
(err.error as { code: string })?.code === DatabaseErrorCode.ForeignKeyViolation
) {
throw new BadRequestError({
message: "Failed to delete resource because it is attached to active PAM accounts"
});
}
throw err;
}
};
const list = async (projectId: string, actor: OrgServiceActor) => {
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources);
const resources = await pamResourceDAL.find({ projectId });
return {
resources: await Promise.all(resources.map((resource) => decryptResource(resource, projectId, kmsService)))
};
};
return {
getById,
create,
updateById,
deleteById,
list,
listResourceOptions
};
};

View File

@@ -0,0 +1,42 @@
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
import { PamResource } from "./pam-resource-enums";
import {
TPostgresAccount,
TPostgresAccountCredentials,
TPostgresResource,
TPostgresResourceConnectionDetails
} from "./postgres/postgres-resource-types";
// Resource types
export type TPamResource = TPostgresResource;
export type TPamResourceConnectionDetails = TPostgresResourceConnectionDetails;
// Account types
export type TPamAccount = TPostgresAccount;
export type TPamAccountCredentials = TPostgresAccountCredentials;
// Resource DTOs
export type TCreateResourceDTO = Pick<
TPamResource,
"name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId"
>;
export type TUpdateResourceDTO = Partial<Omit<TCreateResourceDTO, "resourceType" | "projectId">> & {
resourceId: string;
};
// Resource factory
export type TPamResourceFactoryValidateConnection<T extends TPamResourceConnectionDetails> = () => Promise<T>;
export type TPamResourceFactoryValidateAccountCredentials<C extends TPamAccountCredentials> = (
credentials: C
) => Promise<C>;
export type TPamResourceFactory<T extends TPamResourceConnectionDetails, C extends TPamAccountCredentials> = (
resourceType: PamResource,
connectionDetails: T,
gatewayId: string,
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">
) => {
validateConnection: TPamResourceFactoryValidateConnection<T>;
validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<C>;
};

View File

@@ -0,0 +1,8 @@
import { PostgresResourceListItemSchema } from "./postgres-resource-schemas";
export const getPostgresResourceListItem = () => {
return {
name: PostgresResourceListItemSchema.shape.name.value,
resource: PostgresResourceListItemSchema.shape.resource.value
};
};

View File

@@ -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
);

View File

@@ -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>;

View File

@@ -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
};
};

View File

@@ -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)
});

View File

@@ -0,0 +1,7 @@
import {
TPostgresAccountCredentials,
TPostgresResourceConnectionDetails
} from "../../postgres/postgres-resource-types";
export type TSqlResourceConnectionDetails = TPostgresResourceConnectionDetails;
export type TSqlAccountCredentials = TPostgresAccountCredentials;

View File

@@ -0,0 +1,26 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify, selectAllTableCols } from "@app/lib/knex";
export type TPamSessionDALFactory = ReturnType<typeof pamSessionDALFactory>;
export const pamSessionDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.PamSession);
const findById = async (id: string, tx?: Knex) => {
const session = await (tx || db.replicaNode())(TableName.PamSession)
.leftJoin(TableName.PamAccount, `${TableName.PamSession}.accountId`, `${TableName.PamAccount}.id`)
.leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`)
.leftJoin(TableName.GatewayV2, `${TableName.PamResource}.gatewayId`, `${TableName.GatewayV2}.id`)
.select(selectAllTableCols(TableName.PamSession))
.select(db.ref("name").withSchema(TableName.GatewayV2).as("gatewayName"))
.select(db.ref("identityId").withSchema(TableName.GatewayV2).as("gatewayIdentityId"))
.where(`${TableName.PamSession}.id`, id)
.first();
return session;
};
return { ...orm, findById };
};

View File

@@ -0,0 +1,6 @@
export enum PamSessionStatus {
Starting = "starting", // Starting, user connecting to resource
Active = "active", // Active, user is connected to resource
Ended = "ended", // Ended by user
Terminated = "terminated" // Terminated by an admin
}

View File

@@ -0,0 +1,43 @@
import { TPamSessions } from "@app/db/schemas";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TPamSanitizedSession, TPamSessionCommandLog } from "./pam-session.types";
export const decryptSessionCommandLogs = async ({
projectId,
encryptedLogs,
kmsService
}: {
projectId: string;
encryptedLogs: Buffer;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
const { decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId
});
const decryptedPlainTextBlob = decryptor({
cipherTextBlob: encryptedLogs
});
return JSON.parse(decryptedPlainTextBlob.toString()) as TPamSessionCommandLog;
};
export const decryptSession = async (
session: TPamSessions,
projectId: string,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
return {
...session,
commandLogs: session.encryptedLogsBlob
? await decryptSessionCommandLogs({
projectId,
encryptedLogs: session.encryptedLogsBlob,
kmsService
})
: []
} as TPamSanitizedSession;
};

View File

@@ -0,0 +1,15 @@
import { z } from "zod";
import { PamSessionsSchema } from "@app/db/schemas";
export const PamSessionCommandLogSchema = z.object({
input: z.string(),
output: z.string(),
timestamp: z.coerce.date()
});
export const SanitizedSessionSchema = PamSessionsSchema.omit({
encryptedLogsBlob: true
}).extend({
commandLogs: PamSessionCommandLogSchema.array()
});

View File

@@ -0,0 +1,190 @@
import { ForbiddenError } from "@casl/ability";
import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { ActorType } from "@app/services/auth/auth-type";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission";
import { ProjectPermissionPamSessionActions, ProjectPermissionSub } from "../permission/project-permission";
import { TUpdateSessionLogsDTO } from "./pam-session.types";
import { TPamSessionDALFactory } from "./pam-session-dal";
import { PamSessionStatus } from "./pam-session-enums";
import { decryptSession } from "./pam-session-fns";
type TPamSessionServiceFactoryDep = {
pamSessionDAL: TPamSessionDALFactory;
projectDAL: TProjectDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
};
export type TPamSessionServiceFactory = ReturnType<typeof pamSessionServiceFactory>;
export const pamSessionServiceFactory = ({
pamSessionDAL,
projectDAL,
permissionService,
licenseService,
kmsService
}: TPamSessionServiceFactoryDep) => {
const getById = async (sessionId: string, actor: OrgServiceActor) => {
const session = await pamSessionDAL.findById(sessionId);
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: session.projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamSessionActions.Read,
ProjectPermissionSub.PamSessions
);
return {
session: await decryptSession(session, session.projectId, kmsService)
};
};
const list = async (projectId: string, actor: OrgServiceActor) => {
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamSessionActions.Read,
ProjectPermissionSub.PamSessions
);
const sessions = await pamSessionDAL.find({ projectId });
return {
sessions: await Promise.all(sessions.map((session) => decryptSession(session, projectId, kmsService)))
};
};
const updateLogsById = async ({ sessionId, logs }: TUpdateSessionLogsDTO, actor: OrgServiceActor) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) {
throw new BadRequestError({
message: "PAM operation failed due to organization plan restrictions."
});
}
// To be hit by gateways only
if (actor.type !== ActorType.IDENTITY) {
throw new ForbiddenRequestError({ message: "Only gateways can perform this action" });
}
const session = await pamSessionDAL.findById(sessionId);
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
if (session.encryptedLogsBlob) {
throw new BadRequestError({ message: "Cannot update logs for sessions with existing logs" });
}
const project = await projectDAL.findById(session.projectId);
if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` });
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
project.orgId,
actor.authMethod,
actor.orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionGatewayActions.CreateGateways,
OrgPermissionSubjects.Gateway
);
if (session.gatewayIdentityId !== actor.id) {
throw new ForbiddenRequestError({ message: "Identity does not have access to update logs for this session" });
}
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId: session.projectId
});
const { cipherTextBlob } = encryptor({
plainText: Buffer.from(JSON.stringify(logs))
});
const updatedSession = await pamSessionDAL.updateById(sessionId, {
encryptedLogsBlob: cipherTextBlob
});
return { session: updatedSession, projectId: project.id };
};
const endSessionById = async (sessionId: string, actor: OrgServiceActor) => {
const session = await pamSessionDAL.findById(sessionId);
if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` });
const project = await projectDAL.findById(session.projectId);
if (!project) throw new NotFoundError({ message: `Project with ID '${session.projectId}' not found` });
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
project.orgId,
actor.authMethod,
actor.orgId
);
if (actor.type === ActorType.IDENTITY) {
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionGatewayActions.CreateGateways,
OrgPermissionSubjects.Gateway
);
if (session.gatewayIdentityId !== actor.id) {
throw new ForbiddenRequestError({ message: "Identity does not have access to end this session" });
}
} else if (actor.type === ActorType.USER) {
if (session.userId !== actor.id) {
throw new ForbiddenRequestError({ message: "You are not authorized to end this session" });
}
} else {
throw new ForbiddenRequestError({ message: "Only identities and users can perform this action" });
}
if (session.status === PamSessionStatus.Ended) {
return {
session,
projectId: project.id
};
}
if (session.status !== PamSessionStatus.Active && session.status !== PamSessionStatus.Starting) {
throw new BadRequestError({ message: "Cannot end sessions that are not active or starting" });
}
const updatedSession = await pamSessionDAL.updateById(sessionId, {
endedAt: new Date(),
status: PamSessionStatus.Ended
});
return { session: updatedSession, projectId: project.id };
};
return { getById, list, updateLogsById, endSessionById };
};

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "./pam-session-schemas";
export type TPamSessionCommandLog = z.infer<typeof PamSessionCommandLogSchema>;
export type TPamSanitizedSession = z.infer<typeof SanitizedSessionSchema>;
// DTOs
export type TUpdateSessionLogsDTO = {
sessionId: string;
logs: TPamSessionCommandLog[];
};

View File

@@ -12,6 +12,8 @@ import {
ProjectPermissionIdentityActions,
ProjectPermissionKmipActions,
ProjectPermissionMemberActions,
ProjectPermissionPamAccountActions,
ProjectPermissionPamSessionActions,
ProjectPermissionPkiSubscriberActions,
ProjectPermissionPkiSyncActions,
ProjectPermissionPkiTemplateActions,
@@ -49,7 +51,9 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.SshCertificateAuthorities,
ProjectPermissionSub.SshCertificates,
ProjectPermissionSub.SshCertificateTemplates,
ProjectPermissionSub.SshHostGroups
ProjectPermissionSub.SshHostGroups,
ProjectPermissionSub.PamFolders,
ProjectPermissionSub.PamResources
].forEach((el) => {
can(
[
@@ -290,6 +294,19 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.AppConnections
);
can(
[
ProjectPermissionPamAccountActions.Access,
ProjectPermissionPamAccountActions.Read,
ProjectPermissionPamAccountActions.Create,
ProjectPermissionPamAccountActions.Edit,
ProjectPermissionPamAccountActions.Delete
],
ProjectPermissionSub.PamAccounts
);
can([ProjectPermissionPamSessionActions.Read], ProjectPermissionSub.PamSessions);
return rules;
};
@@ -518,6 +535,15 @@ const buildMemberPermissionRules = () => {
can(ProjectPermissionAppConnectionActions.Connect, ProjectPermissionSub.AppConnections);
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamFolders);
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamResources);
can(
[ProjectPermissionPamAccountActions.Access, ProjectPermissionPamAccountActions.Read],
ProjectPermissionSub.PamAccounts
);
return rules;
};
@@ -579,6 +605,12 @@ const buildViewerPermissionRules = () => {
ProjectPermissionSub.SecretEvents
);
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamFolders);
can([ProjectPermissionActions.Read], ProjectPermissionSub.PamResources);
can([ProjectPermissionPamAccountActions.Read], ProjectPermissionSub.PamAccounts);
return rules;
};

View File

@@ -186,6 +186,19 @@ export enum ProjectPermissionAuditLogsActions {
Read = "read"
}
export enum ProjectPermissionPamAccountActions {
Access = "access",
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete"
}
export enum ProjectPermissionPamSessionActions {
Read = "read"
// Terminate = "terminate"
}
export enum ProjectPermissionSub {
Role = "role",
Member = "member",
@@ -228,7 +241,11 @@ export enum ProjectPermissionSub {
SecretScanningFindings = "secret-scanning-findings",
SecretScanningConfigs = "secret-scanning-configs",
SecretEvents = "secret-events",
AppConnections = "app-connections"
AppConnections = "app-connections",
PamFolders = "pam-folders",
PamResources = "pam-resources",
PamAccounts = "pam-accounts",
PamSessions = "pam-sessions"
}
export type SecretSubjectFields = {
@@ -300,6 +317,12 @@ export type AppConnectionSubjectFields = {
connectionId: string;
};
export type PamAccountSubjectFields = {
resourceName: string;
accountName: string;
accountPath: string;
};
export type ProjectPermissionSet =
| [
ProjectPermissionSecretActions,
@@ -404,7 +427,14 @@ export type ProjectPermissionSet =
| ProjectPermissionSub.AppConnections
| (ForcedSubject<ProjectPermissionSub.AppConnections> & AppConnectionSubjectFields)
)
];
]
| [ProjectPermissionActions, ProjectPermissionSub.PamFolders]
| [ProjectPermissionActions, ProjectPermissionSub.PamResources]
| [
ProjectPermissionPamAccountActions,
ProjectPermissionSub.PamAccounts | (ForcedSubject<ProjectPermissionSub.PamAccounts> & PamAccountSubjectFields)
]
| [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions];
const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
@@ -427,6 +457,27 @@ const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
})
.partial()
]);
const PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
const PAM_ACCOUNT_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([
z.string().refine((val) => val.startsWith("/"), SECRET_PATH_MISSING_SLASH_ERR_MSG),
z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ].refine(
(val) => val.startsWith("/"),
PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG
),
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ].refine(
(val) => val.startsWith("/"),
PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG
),
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN].refine(
(val) => val.every((el) => el.startsWith("/")),
PAM_ACCOUNT_PATH_MISSING_SLASH_ERR_MSG
),
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
})
.partial()
]);
// akhilmhdh: don't modify this for v2
// if you want to update create a new schema
const SecretConditionV1Schema = z
@@ -650,6 +701,34 @@ const AppConnectionConditionSchema = z
})
.partial();
const PamAccountConditionSchema = z
.object({
resourceName: z.union([
z.string(),
z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
})
.partial()
]),
accountName: z.union([
z.string(),
z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
})
.partial()
]),
accountPath: PAM_ACCOUNT_PATH_PERMISSION_OPERATOR_SCHEMA
})
.partial();
const GeneralPermissionSchema = [
z.object({
subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."),
@@ -840,6 +919,34 @@ const GeneralPermissionSchema = [
conditions: AppConnectionConditionSchema.describe(
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
z.object({
subject: z.literal(ProjectPermissionSub.PamFolders).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.PamResources).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.PamAccounts).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPamAccountActions).describe(
"Describe what action an entity can take."
),
conditions: PamAccountConditionSchema.describe(
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
z.object({
subject: z.literal(ProjectPermissionSub.PamSessions).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPamSessionActions).describe(
"Describe what action an entity can take."
)
})
];

View File

@@ -708,7 +708,8 @@ export const relayServiceFactory = ({
relayPkiClientCaCertificate,
relayPkiClientCaPrivateKey,
relayPkiServerCaCertificate,
relayPkiServerCaCertificateChain
relayPkiServerCaCertificateChain,
duration
}: {
gatewayId: string;
gatewayName: string;
@@ -718,6 +719,7 @@ export const relayServiceFactory = ({
relayPkiClientCaPrivateKey: Buffer;
relayPkiServerCaCertificate: Buffer;
relayPkiServerCaCertificateChain: Buffer;
duration?: number;
}) => {
const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048);
const relayClientCaCert = new x509.X509Certificate(relayPkiClientCaCertificate);
@@ -737,7 +739,7 @@ export const relayServiceFactory = ({
);
const clientCertIssuedAt = new Date();
const clientCertExpiration = new Date(new Date().getTime() + 5 * 60 * 1000);
const clientCertExpiration = new Date(new Date().getTime() + (duration ?? 5 * 60 * 1000));
const clientKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const clientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey);
const clientCertSerialNumber = createSerialNumber();
@@ -866,13 +868,15 @@ export const relayServiceFactory = ({
orgId,
orgName,
gatewayId,
gatewayName
gatewayName,
duration
}: {
relayId: string;
orgId: string;
orgName: string;
gatewayId: string;
gatewayName: string;
duration?: number;
}) => {
const relay = await relayDAL.findOne({
id: relayId
@@ -896,7 +900,8 @@ export const relayServiceFactory = ({
relayPkiClientCaCertificate: instanceCAs.instanceRelayPkiClientCaCertificate,
relayPkiClientCaPrivateKey: instanceCAs.instanceRelayPkiClientCaPrivateKey,
relayPkiServerCaCertificate: instanceCAs.instanceRelayPkiServerCaCertificate,
relayPkiServerCaCertificateChain: instanceCAs.instanceRelayPkiServerCaCertificateChain
relayPkiServerCaCertificateChain: instanceCAs.instanceRelayPkiServerCaCertificateChain,
duration
});
return {
@@ -914,7 +919,8 @@ export const relayServiceFactory = ({
relayPkiClientCaCertificate: orgCAs.relayPkiClientCaCertificate,
relayPkiClientCaPrivateKey: orgCAs.relayPkiClientCaPrivateKey,
relayPkiServerCaCertificate: orgCAs.relayPkiServerCaCertificate,
relayPkiServerCaCertificateChain: orgCAs.relayPkiServerCaCertificateChain
relayPkiServerCaCertificateChain: orgCAs.relayPkiServerCaCertificateChain,
duration
});
return {

View File

@@ -1,6 +1,17 @@
/* eslint-disable no-await-in-loop */
import { ForbiddenError } from "@casl/ability";
import { Knex } from "knex";
import RE2 from "re2";
import { OrgMembershipStatus, TableName, TSamlConfigs, TSamlConfigsUpdate, TUsers } from "@app/db/schemas";
import {
OrgMembershipRole,
OrgMembershipStatus,
TableName,
TGroups,
TSamlConfigs,
TSamlConfigsUpdate,
TUsers
} from "@app/db/schemas";
import { throwOnPlanSeatLimitReached } from "@app/ee/services/license/license-fns";
import { getConfig } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto";
@@ -8,12 +19,16 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/
import { AuthTokenType } from "@app/services/auth/auth-type";
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
import { TokenType } from "@app/services/auth-token/auth-token-types";
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
import { TIdentityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns";
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { getServerCfg } from "@app/services/super-admin/super-admin-service";
import { LoginMethod } from "@app/services/super-admin/super-admin-types";
@@ -22,17 +37,30 @@ import { normalizeUsername } from "@app/services/user/user-fns";
import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
import { UserAliasType } from "@app/services/user-alias/user-alias-types";
import { TGroupDALFactory } from "../group/group-dal";
import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "../group/group-fns";
import { TUserGroupMembershipDALFactory } from "../group/user-group-membership-dal";
import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service-types";
import { TSamlConfigDALFactory } from "./saml-config-dal";
import { TSamlConfigServiceFactory } from "./saml-config-types";
import { SamlProviders, TSamlConfigServiceFactory } from "./saml-config-types";
// SAML providers that support group sync
const GROUP_SYNC_SUPPORTED_PROVIDERS = [SamlProviders.GOOGLE_SAML] as SamlProviders[];
type TSamlConfigServiceFactoryDep = {
samlConfigDAL: Pick<TSamlConfigDALFactory, "create" | "findOne" | "update" | "findById">;
userDAL: Pick<
TUserDALFactory,
"create" | "findOne" | "transaction" | "updateById" | "findById" | "findUserEncKeyByUserId"
| "create"
| "findOne"
| "find"
| "transaction"
| "updateById"
| "findById"
| "findUserEncKeyByUserId"
| "findUserEncKeyByUserIdsBatch"
>;
userAliasDAL: Pick<TUserAliasDALFactory, "create" | "findOne">;
orgDAL: Pick<
@@ -41,6 +69,15 @@ type TSamlConfigServiceFactoryDep = {
>;
identityMetadataDAL: Pick<TIdentityMetadataDALFactory, "delete" | "insertMany" | "transaction">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "create">;
groupDAL: Pick<TGroupDALFactory, "create" | "findOne" | "find" | "transaction">;
userGroupMembershipDAL: Pick<
TUserGroupMembershipDALFactory,
"find" | "delete" | "transaction" | "insertMany" | "filterProjectsByUserMembership"
>;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
projectDAL: Pick<TProjectDALFactory, "findById" | "findProjectGhostUser">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "delete" | "findLatestProjectKey" | "insertMany">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan" | "updateSubscriptionOrgMemberCount">;
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser">;
@@ -54,6 +91,12 @@ export const samlConfigServiceFactory = ({
orgMembershipDAL,
userDAL,
userAliasDAL,
groupDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectDAL,
projectBotDAL,
projectKeyDAL,
permissionService,
licenseService,
tokenService,
@@ -61,6 +104,139 @@ export const samlConfigServiceFactory = ({
identityMetadataDAL,
kmsService
}: TSamlConfigServiceFactoryDep): TSamlConfigServiceFactory => {
const parseSamlGroups = (groupsValue: string): string[] => {
let samlGroups: string[] = [];
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const parsed = JSON.parse(groupsValue);
if (Array.isArray(parsed)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
samlGroups = parsed;
} else if (typeof parsed === "string") {
samlGroups = parsed
.split(",")
.map((g) => g.trim())
.filter(Boolean);
}
} catch {
samlGroups = groupsValue
.split(",")
.map((g) => g.trim())
.filter(Boolean);
}
return samlGroups;
};
const syncUserGroupMemberships = async ({
userId,
orgId,
samlGroups,
tx
}: {
userId: string;
orgId: string;
samlGroups: string[];
tx?: Knex;
}) => {
const processGroupSync = async (transaction: Knex) => {
const currentGroupMemberships = await userGroupMembershipDAL.find(
{
userId
},
{ tx: transaction }
);
const orgGroups = await groupDAL.find({ orgId }, { tx: transaction });
const orgGroupsMap = new Map(orgGroups.map((g: TGroups) => [g.name, g]));
const orgGroupIds = new Set(orgGroups.map((g) => g.id));
const currentOrgGroupMemberships = currentGroupMemberships.filter((m) => orgGroupIds.has(m.groupId));
const currentGroupNames = new Set(
currentOrgGroupMemberships
.map((m) => {
const group = orgGroups.find((g) => g.id === m.groupId);
return group?.name;
})
.filter(Boolean)
);
const targetGroupNames = new Set(samlGroups);
const groupsToAdd = samlGroups.filter((groupName) => !currentGroupNames.has(groupName));
const groupsToRemove = Array.from(currentGroupNames).filter(
(groupName) => groupName && !targetGroupNames.has(groupName)
);
// eslint-disable-next-line no-await-in-loop
for (const groupName of groupsToAdd) {
if (!orgGroupsMap.has(groupName)) {
const newGroup = await groupDAL.create(
{
name: groupName,
slug: `${groupName.toLowerCase().replace(new RE2("[^a-z0-9]", "g"), "-")}-${Date.now()}`,
orgId,
role: OrgMembershipRole.NoAccess,
roleId: null
},
transaction
);
orgGroupsMap.set(groupName, newGroup);
}
}
// eslint-disable-next-line no-await-in-loop
for (const groupName of groupsToAdd) {
const group = orgGroupsMap.get(groupName);
if (group) {
try {
await addUsersToGroupByUserIds({
userIds: [userId],
group,
userDAL,
userGroupMembershipDAL,
orgDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx: transaction
});
} catch (error) {
// Continue if user already in group
}
}
}
// eslint-disable-next-line no-await-in-loop
for (const groupName of groupsToRemove) {
if (groupName) {
const group = orgGroupsMap.get(groupName);
if (group) {
try {
await removeUsersFromGroupByUserIds({
userIds: [userId],
group,
userDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectKeyDAL,
tx: transaction
});
} catch (error) {
// Continue if user not in group
}
}
}
}
};
if (tx) {
await processGroupSync(tx);
} else {
await userDAL.transaction(processGroupSync);
}
};
const createSamlCfg: TSamlConfigServiceFactory["createSamlCfg"] = async ({
idpCert,
actor,
@@ -71,7 +247,8 @@ export const samlConfigServiceFactory = ({
actorId,
isActive,
entryPoint,
authProvider
authProvider,
enableGroupSync
}) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Sso);
@@ -96,6 +273,18 @@ export const samlConfigServiceFactory = ({
});
}
if (enableGroupSync && !GROUP_SYNC_SUPPORTED_PROVIDERS.includes(authProvider)) {
throw new BadRequestError({
message: "Group sync is not supported for this SAML provider."
});
}
if (enableGroupSync && !plan.groups) {
throw new BadRequestError({
message: "Failed to enable SAML group sync due to plan restriction. Upgrade plan to enable group sync."
});
}
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId
@@ -107,7 +296,8 @@ export const samlConfigServiceFactory = ({
isActive,
encryptedSamlCertificate: encryptor({ plainText: Buffer.from(idpCert) }).cipherTextBlob,
encryptedSamlEntryPoint: encryptor({ plainText: Buffer.from(entryPoint) }).cipherTextBlob,
encryptedSamlIssuer: encryptor({ plainText: Buffer.from(issuer) }).cipherTextBlob
encryptedSamlIssuer: encryptor({ plainText: Buffer.from(issuer) }).cipherTextBlob,
enableGroupSync: enableGroupSync || false
});
return samlConfig;
@@ -123,7 +313,8 @@ export const samlConfigServiceFactory = ({
issuer,
isActive,
entryPoint,
authProvider
authProvider,
enableGroupSync
}) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
@@ -147,7 +338,27 @@ export const samlConfigServiceFactory = ({
});
}
const updateQuery: TSamlConfigsUpdate = { authProvider, isActive, lastUsed: null };
if (enableGroupSync && authProvider && !GROUP_SYNC_SUPPORTED_PROVIDERS.includes(authProvider)) {
throw new BadRequestError({
message: "Group sync is not supported for this SAML provider."
});
}
if (enableGroupSync && !plan.groups) {
throw new BadRequestError({
message: "Failed to enable SAML group sync due to plan restriction. Upgrade plan to enable group sync."
});
}
const updateQuery: TSamlConfigsUpdate = {
authProvider,
isActive,
lastUsed: null
};
if (enableGroupSync !== undefined) {
updateQuery.enableGroupSync = enableGroupSync;
}
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId
@@ -250,7 +461,8 @@ export const samlConfigServiceFactory = ({
entryPoint,
issuer,
cert,
lastUsed: samlConfig.lastUsed
lastUsed: samlConfig.lastUsed,
enableGroupSync: samlConfig.enableGroupSync
};
};
@@ -282,6 +494,12 @@ export const samlConfigServiceFactory = ({
const organization = await orgDAL.findOrgById(orgId);
if (!organization) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
const samlConfig = await samlConfigDAL.findOne({ orgId });
const groupsMetadata = metadata?.find(({ key }) => key === "groups");
const plan = await licenseService.getPlan(orgId);
const shouldSyncGroups = !!samlConfig?.enableGroupSync && !!plan.groups;
let user: TUsers;
if (userAlias) {
user = await userDAL.transaction(async (tx) => {
@@ -303,7 +521,7 @@ export const samlConfigServiceFactory = ({
orgId,
role,
roleId,
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited,
isActive: true
},
tx
@@ -334,6 +552,17 @@ export const samlConfigServiceFactory = ({
}
}
if (shouldSyncGroups && metadata && foundUser.id) {
const samlGroups = groupsMetadata?.value ? parseSamlGroups(groupsMetadata.value) : [];
await syncUserGroupMemberships({
userId: foundUser.id,
orgId,
samlGroups,
tx
});
}
return foundUser;
});
} else {
@@ -425,6 +654,18 @@ export const samlConfigServiceFactory = ({
);
}
}
if (shouldSyncGroups && metadata && newUser.id) {
const samlGroups = groupsMetadata?.value ? parseSamlGroups(groupsMetadata.value) : [];
await syncUserGroupMemberships({
userId: newUser.id,
orgId,
samlGroups,
tx
});
}
return newUser;
});
}

View File

@@ -17,6 +17,7 @@ export type TCreateSamlCfgDTO = {
entryPoint: string;
issuer: string;
idpCert: string;
enableGroupSync?: boolean;
} & TOrgPermission;
export type TUpdateSamlCfgDTO = Partial<{
@@ -25,6 +26,7 @@ export type TUpdateSamlCfgDTO = Partial<{
entryPoint: string;
issuer: string;
idpCert: string;
enableGroupSync?: boolean;
}> &
TOrgPermission;
@@ -71,6 +73,7 @@ export type TSamlConfigServiceFactory = {
issuer: string;
cert: string;
lastUsed: Date | null | undefined;
enableGroupSync?: boolean;
}>;
samlLogin: (arg: TSamlLoginDTO) => Promise<{
isUserCompleted: boolean;

View File

@@ -23,6 +23,7 @@ export const PgSqlLock = {
InstanceRelayConfigInit: () => pgAdvisoryLockHashText("instance-relay-config-init"),
OrgGatewayV2Init: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-v2-init:${orgId}`),
OrgRelayConfigInit: (orgId: string) => pgAdvisoryLockHashText(`org-relay-config-init:${orgId}`),
GatewayPamSessionKey: (gatewayId: string) => pgAdvisoryLockHashText(`gateway-pam-session-key:${gatewayId}`),
IdentityLogin: (identityId: string, nonce: string) => pgAdvisoryLockHashText(`identity-login:${identityId}:${nonce}`)
} as const;

View File

@@ -2872,7 +2872,9 @@ export const SamlSso = {
entryPoint:
"The entry point for the SAML authentication. This is the URL that the user will be redirected to after they have authenticated with the SAML provider.",
issuer: "The SAML provider issuer URL or entity ID.",
cert: "The certificate to use for SAML authentication."
cert: "The certificate to use for SAML authentication.",
enableGroupSync:
"Whether to enable automatic synchronization of group memberships from the SAML provider to Infisical groups."
},
CREATE_CONFIG: {
organizationId: "The ID of the organization to create the SAML config for.",
@@ -2881,7 +2883,9 @@ export const SamlSso = {
entryPoint:
"The entry point for the SAML authentication. This is the URL that the user will be redirected to after they have authenticated with the SAML provider.",
issuer: "The SAML provider issuer URL or entity ID.",
cert: "The certificate to use for SAML authentication."
cert: "The certificate to use for SAML authentication.",
enableGroupSync:
"Whether to enable automatic synchronization of group memberships from the SAML provider to Infisical groups."
}
};

View File

@@ -53,3 +53,53 @@ export const titleCaseToCamelCase = (obj: unknown): unknown => {
return result;
};
export const deepEqual = (obj1: unknown, obj2: unknown): boolean => {
if (obj1 === obj2) return true;
if (obj1 === null || obj2 === null || obj1 === undefined || obj2 === undefined) {
return obj1 === obj2;
}
if (typeof obj1 !== typeof obj2) return false;
if (typeof obj1 !== "object") return obj1 === obj2;
if (Array.isArray(obj1) !== Array.isArray(obj2)) return false;
if (Array.isArray(obj1)) {
const arr1 = obj1 as unknown[];
const arr2 = obj2 as unknown[];
if (arr1.length !== arr2.length) return false;
return arr1.every((val, idx) => deepEqual(val, arr2[idx]));
}
const keys1 = Object.keys(obj1 as Record<string, unknown>).sort();
const keys2 = Object.keys(obj2 as Record<string, unknown>).sort();
if (keys1.length !== keys2.length) return false;
if (keys1.some((key, idx) => key !== keys2[idx])) return false;
return keys1.every((key) =>
deepEqual((obj1 as Record<string, unknown>)[key], (obj2 as Record<string, unknown>)[key])
);
};
export const deepEqualSkipFields = (obj1: unknown, obj2: unknown, skipFields: string[] = []): boolean => {
if (skipFields.length === 0) {
return deepEqual(obj1, obj2);
}
if (typeof obj1 !== "object" || typeof obj2 !== "object" || obj1 === null || obj2 === null) {
return deepEqual(obj1, obj2);
}
const filtered1 = Object.fromEntries(
Object.entries(obj1 as Record<string, unknown>).filter(([key]) => !skipFields.includes(key))
);
const filtered2 = Object.fromEntries(
Object.entries(obj2 as Record<string, unknown>).filter(([key]) => !skipFields.includes(key))
);
return deepEqual(filtered1, filtered2);
};

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 { oidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal";
import { oidcConfigServiceFactory } from "@app/ee/services/oidc/oidc-config-service";
import { pamAccountDALFactory } from "@app/ee/services/pam-account/pam-account-dal";
import { pamAccountServiceFactory } from "@app/ee/services/pam-account/pam-account-service";
import { pamFolderDALFactory } from "@app/ee/services/pam-folder/pam-folder-dal";
import { pamFolderServiceFactory } from "@app/ee/services/pam-folder/pam-folder-service";
import { pamResourceDALFactory } from "@app/ee/services/pam-resource/pam-resource-dal";
import { pamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-resource-service";
import { pamSessionDALFactory } from "@app/ee/services/pam-session/pam-session-dal";
import { pamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service";
import { permissionDALFactory } from "@app/ee/services/permission/permission-dal";
import { permissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { pitServiceFactory } from "@app/ee/services/pit/pit-service";
@@ -623,6 +631,12 @@ export const registerRoutes = async (
userDAL,
userAliasDAL,
samlConfigDAL,
groupDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectDAL,
projectBotDAL,
projectKeyDAL,
licenseService,
tokenService,
smtpService,
@@ -2104,6 +2118,46 @@ export const registerRoutes = async (
appConnectionDAL
});
const pamFolderDAL = pamFolderDALFactory(db);
const pamResourceDAL = pamResourceDALFactory(db);
const pamAccountDAL = pamAccountDALFactory(db);
const pamSessionDAL = pamSessionDALFactory(db);
const pamFolderService = pamFolderServiceFactory({
pamFolderDAL,
permissionService,
licenseService
});
const pamResourceService = pamResourceServiceFactory({
pamResourceDAL,
permissionService,
licenseService,
kmsService,
gatewayV2Service
});
const pamAccountService = pamAccountServiceFactory({
pamAccountDAL,
gatewayV2Service,
kmsService,
licenseService,
pamFolderDAL,
pamResourceDAL,
pamSessionDAL,
permissionService,
projectDAL,
userDAL
});
const pamSessionService = pamSessionServiceFactory({
pamSessionDAL,
projectDAL,
permissionService,
licenseService,
kmsService
});
// setup the communication with license key server
await licenseService.init();
@@ -2242,6 +2296,10 @@ export const registerRoutes = async (
bus: eventBusService,
sse: sseService,
notification: notificationService,
pamFolder: pamFolderService,
pamResource: pamResourceService,
pamAccount: pamAccountService,
pamSession: pamSessionService,
upgradePath: upgradePathService
});

View File

@@ -58,8 +58,8 @@ import { registerSecretRequestsRouter } from "./secret-requests-router";
import { registerSecretSharingRouter } from "./secret-sharing-router";
import { registerSecretTagRouter } from "./secret-tag-router";
import { registerSlackRouter } from "./slack-router";
import { registerUpgradePathRouter } from "./upgrade-path-router";
import { registerSsoRouter } from "./sso-router";
import { registerUpgradePathRouter } from "./upgrade-path-router";
import { registerUserActionRouter } from "./user-action-router";
import { registerUserEngagementRouter } from "./user-engagement-router";
import { registerUserRouter } from "./user-router";

View File

@@ -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
}
});

View File

@@ -1,9 +1,11 @@
import { PkiSync } from "@app/services/pki-sync/pki-sync-enums";
import { registerAwsCertificateManagerPkiSyncRouter } from "./aws-certificate-manager-pki-sync-router";
import { registerAzureKeyVaultPkiSyncRouter } from "./azure-key-vault-pki-sync-router";
export * from "./pki-sync-router";
export const PKI_SYNC_REGISTER_ROUTER_MAP: Record<PkiSync, (server: FastifyZodProvider) => Promise<void>> = {
[PkiSync.AzureKeyVault]: registerAzureKeyVaultPkiSyncRouter
[PkiSync.AzureKeyVault]: registerAzureKeyVaultPkiSyncRouter,
[PkiSync.AwsCertificateManager]: registerAwsCertificateManagerPkiSyncRouter
};

View File

@@ -425,4 +425,42 @@ export const registerSyncSecretsEndpoints = <T extends TSecretSync, I extends TS
return { secretSync };
}
});
server.route({
method: "POST",
url: "/check-destination",
config: {
rateLimit: readLimit
},
schema: {
tags: [ApiDocsTags.SecretSyncs],
body: z.object({
destinationConfig: z.unknown(),
excludeSyncId: z.string().uuid().optional(),
projectId: z.string().uuid()
}),
response: {
200: z.object({
hasDuplicate: z.boolean(),
duplicateProjectId: z.string().uuid().optional()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { destinationConfig, excludeSyncId, projectId } = req.body;
const result = await server.services.secretSync.checkDuplicateDestination(
{
destinationConfig: destinationConfig as Record<string, unknown>,
destination,
excludeSyncId,
projectId
},
req.permission
);
return result;
}
});
};

View File

@@ -215,6 +215,8 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => {
return false;
case ProjectType.SSH:
return false;
case ProjectType.PAM:
return false;
default:
return true;
}

View File

@@ -33,6 +33,7 @@ export const constructGroupOrgMembershipRoleMappings = async ({
let customRolesMap: Map<string, TOrgRoles> = new Map();
if (customRoleSlugs.length > 0) {
const customRoles = await orgRoleDAL.find({
orgId,
$in: {
slug: customRoleSlugs
}

View File

@@ -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;

View File

@@ -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
};
};

View File

@@ -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)
});

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -1,5 +1,6 @@
export enum PkiSync {
AzureKeyVault = "azure-key-vault"
AzureKeyVault = "azure-key-vault",
AwsCertificateManager = "aws-certificate-manager"
}
export enum PkiSyncStatus {

View File

@@ -6,6 +6,8 @@ import { BadRequestError } from "@app/lib/errors";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION } from "./aws-certificate-manager/aws-certificate-manager-pki-sync-constants";
import { awsCertificateManagerPkiSyncFactory } from "./aws-certificate-manager/aws-certificate-manager-pki-sync-fns";
import { AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION } from "./azure-key-vault/azure-key-vault-pki-sync-constants";
import { azureKeyVaultPkiSyncFactory } from "./azure-key-vault/azure-key-vault-pki-sync-fns";
import { PkiSync } from "./pki-sync-enums";
@@ -14,7 +16,8 @@ import { TCertificateMap, TPkiSyncWithCredentials } from "./pki-sync-types";
const ENTERPRISE_PKI_SYNCS: PkiSync[] = [];
const PKI_SYNC_LIST_OPTIONS = {
[PkiSync.AzureKeyVault]: AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION
[PkiSync.AzureKeyVault]: AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION,
[PkiSync.AwsCertificateManager]: AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION
};
export const enterprisePkiSyncCheck = async (
@@ -144,8 +147,10 @@ export const matchesCertificateNameSchema = (name: string, environment: string,
return name.startsWith(prefix) && name.endsWith(suffix);
};
const isAzureKeyVaultPkiSync = (pkiSync: TPkiSyncWithCredentials): boolean => {
return pkiSync.destination === PkiSync.AzureKeyVault;
const checkPkiSyncDestination = (pkiSync: TPkiSyncWithCredentials, destination: PkiSync): void => {
if (pkiSync.destination !== destination) {
throw new Error(`Invalid PKI sync destination: ${pkiSync.destination}`);
}
};
export const PkiSyncFns = {
@@ -163,6 +168,11 @@ export const PkiSyncFns = {
"Azure Key Vault does not support importing certificates into Infisical (private keys cannot be extracted)"
);
}
case PkiSync.AwsCertificateManager: {
throw new Error(
"AWS Certificate Manager does not support importing certificates into Infisical (private keys cannot be extracted)"
);
}
default:
throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`);
}
@@ -188,12 +198,15 @@ export const PkiSyncFns = {
}> => {
switch (pkiSync.destination) {
case PkiSync.AzureKeyVault: {
if (!isAzureKeyVaultPkiSync(pkiSync)) {
throw new Error("Invalid Azure Key Vault PKI sync configuration");
}
checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault);
const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies);
return azureKeyVaultPkiSync.syncCertificates(pkiSync, certificateMap);
}
case PkiSync.AwsCertificateManager: {
checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager);
const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory(dependencies);
return awsCertificateManagerPkiSync.syncCertificates(pkiSync, certificateMap);
}
default:
throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`);
}
@@ -209,13 +222,17 @@ export const PkiSyncFns = {
): Promise<void> => {
switch (pkiSync.destination) {
case PkiSync.AzureKeyVault: {
if (!isAzureKeyVaultPkiSync(pkiSync)) {
throw new Error("Invalid Azure Key Vault PKI sync configuration");
}
checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault);
const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies);
await azureKeyVaultPkiSync.removeCertificates(pkiSync, certificateNames);
break;
}
case PkiSync.AwsCertificateManager: {
checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager);
const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory(dependencies);
await awsCertificateManagerPkiSync.removeCertificates(pkiSync, certificateNames);
break;
}
default:
throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`);
}

View File

@@ -3,9 +3,11 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums
import { PkiSync } from "./pki-sync-enums";
export const PKI_SYNC_NAME_MAP: Record<PkiSync, string> = {
[PkiSync.AzureKeyVault]: "Azure Key Vault"
[PkiSync.AzureKeyVault]: "Azure Key Vault",
[PkiSync.AwsCertificateManager]: "AWS Certificate Manager"
};
export const PKI_SYNC_CONNECTION_MAP: Record<PkiSync, AppConnection> = {
[PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault
[PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault,
[PkiSync.AwsCertificateManager]: AppConnection.AWS
};

View File

@@ -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 };
};

View File

@@ -1,5 +1,6 @@
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync, SecretSyncPlanType } from "@app/services/secret-sync/secret-sync-enums";
import { DestinationDuplicateCheckFn } from "@app/services/secret-sync/secret-sync-types";
export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
[SecretSync.AWSParameterStore]: "AWS Parameter Store",
@@ -99,3 +100,104 @@ export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
[SecretSync.Netlify]: SecretSyncPlanType.Regular,
[SecretSync.Bitbucket]: SecretSyncPlanType.Regular
};
export const SECRET_SYNC_SKIP_FIELDS_MAP: Record<SecretSync, string[]> = {
[SecretSync.AWSParameterStore]: [],
[SecretSync.AWSSecretsManager]: ["mappingBehavior", "secretName"],
[SecretSync.GitHub]: [],
[SecretSync.GCPSecretManager]: [],
[SecretSync.AzureKeyVault]: [],
[SecretSync.AzureAppConfiguration]: ["label"],
[SecretSync.AzureDevOps]: ["devopsProjectName"],
[SecretSync.Databricks]: [],
[SecretSync.Humanitec]: [],
[SecretSync.TerraformCloud]: ["variableSetName", "workspaceName"],
[SecretSync.Camunda]: [],
[SecretSync.Vercel]: ["appName"],
[SecretSync.Windmill]: [],
[SecretSync.HCVault]: [],
[SecretSync.TeamCity]: [],
[SecretSync.OCIVault]: [],
[SecretSync.OnePass]: ["valueLabel"],
[SecretSync.Heroku]: ["appName"],
[SecretSync.Render]: [],
[SecretSync.Flyio]: [],
[SecretSync.GitLab]: [
"projectName",
"shouldProtectSecrets",
"shouldMaskSecrets",
"shouldHideSecrets",
"targetEnvironment",
"groupName",
"groupId",
"projectId"
],
[SecretSync.CloudflarePages]: [],
[SecretSync.CloudflareWorkers]: [],
[SecretSync.Supabase]: ["projectName"],
[SecretSync.Zabbix]: ["hostName", "macroType"],
[SecretSync.Railway]: ["projectName", "environmentName", "serviceName"],
[SecretSync.Checkly]: ["groupName", "accountName"],
[SecretSync.DigitalOceanAppPlatform]: ["appName"],
[SecretSync.Netlify]: ["accountName", "siteName"],
[SecretSync.Bitbucket]: []
};
const defaultDuplicateCheck: DestinationDuplicateCheckFn = () => true;
export const DESTINATION_DUPLICATE_CHECK_MAP: Record<SecretSync, DestinationDuplicateCheckFn> = {
[SecretSync.AWSParameterStore]: defaultDuplicateCheck,
[SecretSync.AWSSecretsManager]: defaultDuplicateCheck,
[SecretSync.GitHub]: defaultDuplicateCheck,
[SecretSync.GCPSecretManager]: defaultDuplicateCheck,
[SecretSync.AzureKeyVault]: defaultDuplicateCheck,
[SecretSync.AzureAppConfiguration]: defaultDuplicateCheck,
[SecretSync.AzureDevOps]: defaultDuplicateCheck,
[SecretSync.Databricks]: defaultDuplicateCheck,
[SecretSync.Humanitec]: defaultDuplicateCheck,
[SecretSync.TerraformCloud]: defaultDuplicateCheck,
[SecretSync.Camunda]: defaultDuplicateCheck,
[SecretSync.Vercel]: defaultDuplicateCheck,
[SecretSync.Windmill]: defaultDuplicateCheck,
[SecretSync.HCVault]: defaultDuplicateCheck,
[SecretSync.TeamCity]: defaultDuplicateCheck,
[SecretSync.OCIVault]: defaultDuplicateCheck,
[SecretSync.OnePass]: defaultDuplicateCheck,
[SecretSync.Heroku]: defaultDuplicateCheck,
[SecretSync.Render]: defaultDuplicateCheck,
[SecretSync.Flyio]: defaultDuplicateCheck,
[SecretSync.GitLab]: (existingConfig, newConfig) => {
const existingTargetEnv = existingConfig.targetEnvironment as string | undefined;
const newTargetEnv = newConfig.targetEnvironment as string | undefined;
const wildcardValues = ["*", ""];
if (
(newConfig.scope as string) === "group"
? existingConfig.groupId !== newConfig.groupId
: existingConfig.projectId !== newConfig.projectId
)
return false;
// If either has wildcard, it conflicts with any targetEnvironment
if (
!existingTargetEnv ||
!newTargetEnv ||
wildcardValues.includes(existingTargetEnv) ||
wildcardValues.includes(newTargetEnv)
) {
return true;
}
return existingTargetEnv === newTargetEnv;
},
[SecretSync.CloudflarePages]: defaultDuplicateCheck,
[SecretSync.CloudflareWorkers]: defaultDuplicateCheck,
[SecretSync.Supabase]: defaultDuplicateCheck,
[SecretSync.Zabbix]: defaultDuplicateCheck,
[SecretSync.Railway]: defaultDuplicateCheck,
[SecretSync.Checkly]: defaultDuplicateCheck,
[SecretSync.DigitalOceanAppPlatform]: defaultDuplicateCheck,
[SecretSync.Netlify]: defaultDuplicateCheck,
[SecretSync.Bitbucket]: defaultDuplicateCheck
};

View File

@@ -12,6 +12,7 @@ import {
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { DatabaseErrorCode } from "@app/lib/error-codes";
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
import { deepEqualSkipFields } from "@app/lib/fn/object";
import { OrgServiceActor } from "@app/lib/types";
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
@@ -20,6 +21,7 @@ import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { enterpriseSyncCheck, listSecretSyncOptions } from "@app/services/secret-sync/secret-sync-fns";
import {
SecretSyncStatus,
TCheckDuplicateDestinationDTO,
TCreateSecretSyncDTO,
TDeleteSecretSyncDTO,
TFindSecretSyncByIdDTO,
@@ -35,7 +37,12 @@ import {
import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
import { TSecretSyncDALFactory } from "./secret-sync-dal";
import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "./secret-sync-maps";
import {
DESTINATION_DUPLICATE_CHECK_MAP,
SECRET_SYNC_CONNECTION_MAP,
SECRET_SYNC_NAME_MAP,
SECRET_SYNC_SKIP_FIELDS_MAP
} from "./secret-sync-maps";
import { TSecretSyncQueueFactory } from "./secret-sync-queue";
type TSecretSyncServiceFactoryDep = {
@@ -696,6 +703,61 @@ export const secretSyncServiceFactory = ({
return updatedSecretSync as TSecretSync;
};
const checkDuplicateDestination = async (
{ destination, destinationConfig, excludeSyncId, projectId }: TCheckDuplicateDestinationDTO,
actor: OrgServiceActor
) => {
const skipFields = SECRET_SYNC_SKIP_FIELDS_MAP[destination];
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.SecretManager,
projectId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretSyncActions.Read,
ProjectPermissionSub.SecretSyncs
);
if (!destinationConfig || Object.keys(destinationConfig).length === 0) {
return { hasDuplicate: false, duplicateProjectId: undefined };
}
try {
const existingSyncs = await secretSyncDAL.findByDestinationAndOrgId(destination, actor.orgId);
const duplicates = existingSyncs.filter((sync) => {
if (sync.id === excludeSyncId) {
return false;
}
try {
const baseFieldsMatch = deepEqualSkipFields(sync.destinationConfig, destinationConfig, skipFields);
if (baseFieldsMatch) {
return DESTINATION_DUPLICATE_CHECK_MAP[destination](
sync.destinationConfig as Record<string, unknown>,
destinationConfig
);
}
return false;
} catch {
return false;
}
});
const hasDuplicate = duplicates.length > 0;
return {
hasDuplicate,
duplicateProjectId: hasDuplicate ? duplicates[0].projectId : undefined
};
} catch (error) {
return { hasDuplicate: false, duplicateProjectId: undefined };
}
};
return {
listSecretSyncOptions,
listSecretSyncsByProjectId,
@@ -707,6 +769,7 @@ export const secretSyncServiceFactory = ({
deleteSecretSync,
triggerSecretSyncSyncSecretsById,
triggerSecretSyncImportSecretsById,
triggerSecretSyncRemoveSecretsById
triggerSecretSyncRemoveSecretsById,
checkDuplicateDestination
};
};

View File

@@ -324,6 +324,13 @@ export type TDeleteSecretSyncDTO = {
removeSecrets: boolean;
};
export type TCheckDuplicateDestinationDTO = {
destination: SecretSync;
destinationConfig: Record<string, unknown>;
excludeSyncId?: string;
projectId: string;
};
export enum SecretSyncStatus {
Pending = "pending",
Running = "running",
@@ -408,3 +415,8 @@ export type TSecretMap = Record<
secretMetadata?: ResourceMetadataDTO;
}
>;
export type DestinationDuplicateCheckFn = (
existingConfig: Record<string, unknown>,
newConfig: Record<string, unknown>
) => boolean;

View File

@@ -0,0 +1,36 @@
import { Heading, Section, Text } from "@react-email/components";
import React from "react";
import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper";
interface AccountDeletionConfirmationTemplateProps
extends Omit<BaseEmailWrapperProps, "title" | "preview" | "children"> {
email: string;
}
export const AccountDeletionConfirmationTemplate = ({ email, siteUrl }: AccountDeletionConfirmationTemplateProps) => {
return (
<BaseEmailWrapper
title="Your Infisical Account Has Been Deleted"
preview="Confirmation that your account and associated data have been deleted."
siteUrl={siteUrl}
>
<Heading className="text-black text-[18px] leading-[28px] text-center font-normal p-0 mx-0">
Account Deleted
</Heading>
<Section className="px-[24px] mt-[20px] pt-[12px] pb-[8px] border border-solid border-gray-200 rounded-md bg-gray-50">
<Text className="text-[14px]">
This email confirms that your Infisical account <strong>{email}</strong> has been deleted, including all
associated data.
</Text>
</Section>
</BaseEmailWrapper>
);
};
export default AccountDeletionConfirmationTemplate;
AccountDeletionConfirmationTemplate.PreviewProps = {
email: "test@infisical.com",
siteUrl: "https://infisical.com"
} as AccountDeletionConfirmationTemplateProps;

View File

@@ -1,5 +1,6 @@
export * from "./AccessApprovalRequestTemplate";
export * from "./AccessApprovalRequestUpdatedTemplate";
export * from "./AccountDeletionConfirmationTemplate";
export * from "./EmailMfaTemplate";
export * from "./EmailVerificationTemplate";
export * from "./ExternalImportFailedTemplate";

View File

@@ -9,6 +9,7 @@ import { logger } from "@app/lib/logger";
import {
AccessApprovalRequestTemplate,
AccessApprovalRequestUpdatedTemplate,
AccountDeletionConfirmationTemplate,
EmailMfaTemplate,
EmailVerificationTemplate,
ExternalImportFailedTemplate,
@@ -83,7 +84,8 @@ export enum SmtpTemplates {
OrgAdminBreakglassAccess = "orgAdminBreakglassAccess",
ServiceTokenExpired = "serviceTokenExpired",
SecretScanningV2ScanFailed = "secretScanningV2ScanFailed",
SecretScanningV2SecretsDetected = "secretScanningV2SecretsDetected"
SecretScanningV2SecretsDetected = "secretScanningV2SecretsDetected",
AccountDeletionConfirmation = "accountDeletionConfirmation"
}
export enum SmtpHost {
@@ -128,7 +130,8 @@ const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = {
[SmtpTemplates.SetupPassword]: PasswordSetupTemplate,
[SmtpTemplates.PkiExpirationAlert]: PkiExpirationAlertTemplate,
[SmtpTemplates.SecretScanningV2ScanFailed]: SecretScanningScanFailedTemplate,
[SmtpTemplates.SecretScanningV2SecretsDetected]: SecretScanningSecretsDetectedTemplate
[SmtpTemplates.SecretScanningV2SecretsDetected]: SecretScanningSecretsDetectedTemplate,
[SmtpTemplates.AccountDeletionConfirmation]: AccountDeletionConfirmationTemplate
};
export const smtpServiceFactory = (cfg: TSmtpConfig) => {

View File

@@ -347,6 +347,23 @@ export const userServiceFactory = ({
const deleteUser = async (userId: string) => {
const user = await userDAL.deleteById(userId);
try {
if (user?.email) {
// Send email to user to confirm account deletion
await smtpService.sendMail({
template: SmtpTemplates.AccountDeletionConfirmation,
subjectLine: "Your Infisical account has been deleted",
recipients: [user.email],
substitutions: {
email: user.email
}
});
}
} catch (error) {
logger.error(error, `Failed to send account deletion confirmation email to ${user.email}`);
}
return user;
};

View File

@@ -197,4 +197,4 @@ volumes:
driver: local
ldap_data:
ldap_config:
grafana_storage:
grafana_storage:

View File

@@ -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