Merge branch 'main' into ENG-3506-LDAP

This commit is contained in:
x032205
2025-09-09 16:59:23 -04:00
222 changed files with 9373 additions and 2227 deletions
@@ -314,8 +314,8 @@ describe("Secret expansion", () => {
expect(listSecrets.imports).toEqual( expect(listSecrets.imports).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
secretPath: `/__reserve_replication_${secretImportFromProdToDev.id}`, secretPath: "/deep/nested",
environment: seedData1.environment.slug, environment: "prod",
secrets: expect.arrayContaining([ secrets: expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
secretKey: "NESTED_KEY_1", secretKey: "NESTED_KEY_1",
+41 -3
View File
@@ -25,6 +25,7 @@
"@fastify/multipart": "8.3.1", "@fastify/multipart": "8.3.1",
"@fastify/passport": "^2.4.0", "@fastify/passport": "^2.4.0",
"@fastify/rate-limit": "^9.0.0", "@fastify/rate-limit": "^9.0.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/request-context": "^5.1.0", "@fastify/request-context": "^5.1.0",
"@fastify/session": "^10.7.0", "@fastify/session": "^10.7.0",
"@fastify/static": "^7.0.4", "@fastify/static": "^7.0.4",
@@ -8044,6 +8045,42 @@
"toad-cache": "^3.3.0" "toad-cache": "^3.3.0"
} }
}, },
"node_modules/@fastify/reply-from": {
"version": "9.8.0",
"resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-9.8.0.tgz",
"integrity": "sha512-bPNVaFhEeNI0Lyl6404YZaPFokudCplidE3QoOcr78yOy6H9sYw97p5KPYvY/NJNUHfFtvxOaSAHnK+YSiv/Mg==",
"license": "MIT",
"dependencies": {
"@fastify/error": "^3.0.0",
"end-of-stream": "^1.4.4",
"fast-content-type-parse": "^1.1.0",
"fast-querystring": "^1.0.0",
"fastify-plugin": "^4.0.0",
"toad-cache": "^3.7.0",
"undici": "^5.19.1"
}
},
"node_modules/@fastify/reply-from/node_modules/@fastify/busboy": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz",
"integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@fastify/reply-from/node_modules/undici": {
"version": "5.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz",
"integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==",
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^2.0.0"
},
"engines": {
"node": ">=14.0"
}
},
"node_modules/@fastify/request-context": { "node_modules/@fastify/request-context": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/@fastify/request-context/-/request-context-5.1.0.tgz", "resolved": "https://registry.npmjs.org/@fastify/request-context/-/request-context-5.1.0.tgz",
@@ -29330,9 +29367,10 @@
} }
}, },
"node_modules/toad-cache": { "node_modules/toad-cache": {
"version": "3.3.0", "version": "3.7.0",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.3.0.tgz", "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
"integrity": "sha512-3oDzcogWGHZdkwrHyvJVpPjA7oNzY6ENOV3PsWJY9XYPZ6INo94Yd47s5may1U+nleBPwDhrRiTPMIvKaa3MQg==", "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==",
"license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
+1
View File
@@ -145,6 +145,7 @@
"@fastify/multipart": "8.3.1", "@fastify/multipart": "8.3.1",
"@fastify/passport": "^2.4.0", "@fastify/passport": "^2.4.0",
"@fastify/rate-limit": "^9.0.0", "@fastify/rate-limit": "^9.0.0",
"@fastify/reply-from": "^9.8.0",
"@fastify/request-context": "^5.1.0", "@fastify/request-context": "^5.1.0",
"@fastify/session": "^10.7.0", "@fastify/session": "^10.7.0",
"@fastify/static": "^7.0.4", "@fastify/static": "^7.0.4",
+8 -3
View File
@@ -1,13 +1,13 @@
import "fastify"; import "fastify";
import { Redis } from "ioredis"; import { Cluster, Redis } from "ioredis";
import { TUsers } from "@app/db/schemas"; import { TUsers } from "@app/db/schemas";
import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; import { TAccessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-types";
import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-types"; import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-types";
import { TAssumePrivilegeServiceFactory } from "@app/ee/services/assume-privilege/assume-privilege-types"; import { TAssumePrivilegeServiceFactory } from "@app/ee/services/assume-privilege/assume-privilege-types";
import { TAuditLogServiceFactory, TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; import { TAuditLogServiceFactory, TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-types"; import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service";
import { TCertificateAuthorityCrlServiceFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-types"; import { TCertificateAuthorityCrlServiceFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-types";
import { TCertificateEstServiceFactory } from "@app/ee/services/certificate-est/certificate-est-service"; import { TCertificateEstServiceFactory } from "@app/ee/services/certificate-est/certificate-est-service";
import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-types"; import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-types";
@@ -83,6 +83,8 @@ import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua
import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; import { TIntegrationServiceFactory } from "@app/services/integration/integration-service";
import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service";
import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service";
import { TNotificationServiceFactory } from "@app/services/notification/notification-service";
import { TOfflineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service";
import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service"; import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service";
import { TOrgServiceFactory } from "@app/services/org/org-service"; import { TOrgServiceFactory } from "@app/services/org/org-service";
import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service";
@@ -161,6 +163,7 @@ declare module "fastify" {
}; };
// identity injection. depending on which kinda of token the information is filled in auth // identity injection. depending on which kinda of token the information is filled in auth
auth: TAuthMode; auth: TAuthMode;
shouldForwardWritesToPrimaryInstance: boolean;
permission: { permission: {
authMethod: ActorAuthMethod; authMethod: ActorAuthMethod;
type: ActorType; type: ActorType;
@@ -194,7 +197,7 @@ declare module "fastify" {
} }
interface FastifyInstance { interface FastifyInstance {
redis: Redis; redis: Redis | Cluster;
services: { services: {
login: TAuthLoginFactory; login: TAuthLoginFactory;
password: TAuthPasswordFactory; password: TAuthPasswordFactory;
@@ -303,6 +306,8 @@ declare module "fastify" {
bus: TEventBusService; bus: TEventBusService;
sse: TServerSentEventsService; sse: TServerSentEventsService;
identityAuthTemplate: TIdentityAuthTemplateServiceFactory; identityAuthTemplate: TIdentityAuthTemplateServiceFactory;
notification: TNotificationServiceFactory;
offlineUsageReport: TOfflineUsageReportServiceFactory;
}; };
// this is exclusive use for middlewares in which we need to inject data // this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer // everywhere else access using service layer
+10
View File
@@ -530,6 +530,11 @@ import {
TSecretReminderRecipientsInsert, TSecretReminderRecipientsInsert,
TSecretReminderRecipientsUpdate TSecretReminderRecipientsUpdate
} from "@app/db/schemas/secret-reminder-recipients"; } from "@app/db/schemas/secret-reminder-recipients";
import {
TUserNotifications,
TUserNotificationsInsert,
TUserNotificationsUpdate
} from "@app/db/schemas/user-notifications";
declare module "knex" { declare module "knex" {
namespace Knex { namespace Knex {
@@ -1254,5 +1259,10 @@ declare module "knex/types/tables" {
TRemindersRecipientsInsert, TRemindersRecipientsInsert,
TRemindersRecipientsUpdate TRemindersRecipientsUpdate
>; >;
[TableName.UserNotifications]: KnexOriginal.CompositeTableType<
TUserNotifications,
TUserNotificationsInsert,
TUserNotificationsUpdate
>;
} }
} }
@@ -0,0 +1,50 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.UserNotifications))) {
const createTableSql = knex.schema
.createTable(TableName.UserNotifications, (t) => {
t.uuid("id").defaultTo(knex.fn.uuid());
t.uuid("userId").notNullable();
t.uuid("orgId").nullable();
t.string("type").notNullable();
t.string("title").notNullable(); // Markdown
t.text("body").nullable(); // Markdown
t.string("link").nullable();
t.boolean("isRead").notNullable().defaultTo(false);
t.timestamps(true, true, true);
t.primary(["id", "createdAt"]);
})
.toString();
await knex.schema.raw(`
${createTableSql} PARTITION BY RANGE ("createdAt");
`);
await knex.schema.raw(
`CREATE TABLE ${TableName.UserNotifications}_default PARTITION OF ${TableName.UserNotifications} DEFAULT`
);
await knex.schema.alterTable(TableName.UserNotifications, (t) => {
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE");
t.index("type");
t.index(["userId", "isRead"]);
t.index(["userId", "createdAt", "orgId"]);
});
await createOnUpdateTrigger(knex, TableName.UserNotifications);
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.UserNotifications);
await dropOnUpdateTrigger(knex, TableName.UserNotifications);
}
@@ -0,0 +1,221 @@
import { Knex } from "knex";
import { inMemoryKeyStore } from "@app/keystore/memory";
import { crypto } from "@app/lib/crypto/cryptography";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal";
import { SecretKeyEncoding, TableName } from "../schemas";
import { getMigrationEnvConfig } from "./utils/env-config";
import { createCircularCache } from "./utils/ring-buffer";
import { getMigrationEncryptionServices } from "./utils/services";
const BATCH_SIZE = 500;
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.AuditLogStream)) {
const hasProvider = await knex.schema.hasColumn(TableName.AuditLogStream, "provider");
const hasEncryptedCredentials = await knex.schema.hasColumn(TableName.AuditLogStream, "encryptedCredentials");
await knex.schema.alterTable(TableName.AuditLogStream, (t) => {
if (!hasProvider) t.string("provider").notNullable().defaultTo("custom");
if (!hasEncryptedCredentials) t.binary("encryptedCredentials");
// This column will no longer be used but we're not dropping it so that we can have a backup in case the migration goes wrong
t.string("url").nullable().alter();
});
if (!hasEncryptedCredentials) {
const superAdminDAL = superAdminDALFactory(knex);
const envConfig = await getMigrationEnvConfig(superAdminDAL);
const keyStore = inMemoryKeyStore();
const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex });
const orgEncryptionRingBuffer =
createCircularCache<Awaited<ReturnType<(typeof kmsService)["createCipherPairWithDataKey"]>>>(25);
const logStreams = await knex(TableName.AuditLogStream).select(
"id",
"orgId",
"url",
"encryptedHeadersAlgorithm",
"encryptedHeadersCiphertext",
"encryptedHeadersIV",
"encryptedHeadersKeyEncoding",
"encryptedHeadersTag"
);
const updatedLogStreams = await Promise.all(
logStreams.map(async (el) => {
let orgKmsService = orgEncryptionRingBuffer.getItem(el.orgId);
if (!orgKmsService) {
orgKmsService = await kmsService.createCipherPairWithDataKey(
{
type: KmsDataKey.Organization,
orgId: el.orgId
},
knex
);
orgEncryptionRingBuffer.push(el.orgId, orgKmsService);
}
const provider = "custom";
let credentials;
if (
el.encryptedHeadersTag &&
el.encryptedHeadersIV &&
el.encryptedHeadersCiphertext &&
el.encryptedHeadersKeyEncoding
) {
const decryptedHeaders = crypto
.encryption()
.symmetric()
.decryptWithRootEncryptionKey({
tag: el.encryptedHeadersTag,
iv: el.encryptedHeadersIV,
ciphertext: el.encryptedHeadersCiphertext,
keyEncoding: el.encryptedHeadersKeyEncoding as SecretKeyEncoding
});
credentials = {
url: el.url,
headers: JSON.parse(decryptedHeaders)
};
} else {
credentials = {
url: el.url,
headers: []
};
}
const encryptedCredentials = orgKmsService.encryptor({
plainText: Buffer.from(JSON.stringify(credentials), "utf8")
}).cipherTextBlob;
return {
id: el.id,
orgId: el.orgId,
url: el.url,
provider,
encryptedCredentials
};
})
);
for (let i = 0; i < updatedLogStreams.length; i += BATCH_SIZE) {
// eslint-disable-next-line no-await-in-loop
await knex(TableName.AuditLogStream)
.insert(updatedLogStreams.slice(i, i + BATCH_SIZE))
.onConflict("id")
.merge();
}
await knex.schema.alterTable(TableName.AuditLogStream, (t) => {
t.binary("encryptedCredentials").notNullable().alter();
});
}
}
}
// IMPORTANT: The down migration does not utilize the existing "url" and encrypted header columns
// because we're taking the latest data from the credentials column and re-encrypting it into relevant columns
//
// If this down migration was to fail, you can fall-back to the existing URL and encrypted header columns to retrieve
// data that was created prior to this migration
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.AuditLogStream)) {
const hasProvider = await knex.schema.hasColumn(TableName.AuditLogStream, "provider");
const hasEncryptedCredentials = await knex.schema.hasColumn(TableName.AuditLogStream, "encryptedCredentials");
if (hasEncryptedCredentials) {
const superAdminDAL = superAdminDALFactory(knex);
const envConfig = await getMigrationEnvConfig(superAdminDAL);
const keyStore = inMemoryKeyStore();
const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex });
const orgEncryptionRingBuffer =
createCircularCache<Awaited<ReturnType<(typeof kmsService)["createCipherPairWithDataKey"]>>>(25);
const logStreamsToRevert = await knex(TableName.AuditLogStream)
.select("id", "orgId", "encryptedCredentials")
.where("provider", "custom")
.whereNotNull("encryptedCredentials");
const updatedLogStreams = await Promise.all(
logStreamsToRevert.map(async (el) => {
let orgKmsService = orgEncryptionRingBuffer.getItem(el.orgId);
if (!orgKmsService) {
orgKmsService = await kmsService.createCipherPairWithDataKey(
{
type: KmsDataKey.Organization,
orgId: el.orgId
},
knex
);
orgEncryptionRingBuffer.push(el.orgId, orgKmsService);
}
const decryptedCredentials = orgKmsService
.decryptor({
cipherTextBlob: el.encryptedCredentials
})
.toString();
const credentials: { url: string; headers: { key: string; value: string }[] } =
JSON.parse(decryptedCredentials);
const originalUrl: string = credentials.url;
const encryptedHeadersResult = crypto
.encryption()
.symmetric()
.encryptWithRootEncryptionKey(JSON.stringify(credentials.headers), envConfig);
const encryptedHeadersAlgorithm: string = encryptedHeadersResult.algorithm;
const encryptedHeadersCiphertext: string = encryptedHeadersResult.ciphertext;
const encryptedHeadersIV: string = encryptedHeadersResult.iv;
const encryptedHeadersKeyEncoding: string = encryptedHeadersResult.encoding;
const encryptedHeadersTag: string = encryptedHeadersResult.tag;
return {
id: el.id,
orgId: el.orgId,
encryptedCredentials: el.encryptedCredentials,
url: originalUrl,
encryptedHeadersAlgorithm,
encryptedHeadersCiphertext,
encryptedHeadersIV,
encryptedHeadersKeyEncoding,
encryptedHeadersTag
};
})
);
for (let i = 0; i < updatedLogStreams.length; i += BATCH_SIZE) {
// eslint-disable-next-line no-await-in-loop
await knex(TableName.AuditLogStream)
.insert(updatedLogStreams.slice(i, i + BATCH_SIZE))
.onConflict("id")
.merge();
}
await knex(TableName.AuditLogStream)
.where((qb) => {
void qb.whereNot("provider", "custom").orWhereNull("url");
})
.del();
}
await knex.schema.alterTable(TableName.AuditLogStream, (t) => {
t.string("url").notNullable().alter();
if (hasProvider) t.dropColumn("provider");
if (hasEncryptedCredentials) t.dropColumn("encryptedCredentials");
});
}
}
+6 -2
View File
@@ -5,11 +5,13 @@
import { z } from "zod"; import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models"; import { TImmutableDBKeys } from "./models";
export const AuditLogStreamsSchema = z.object({ export const AuditLogStreamsSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
url: z.string(), url: z.string().nullable().optional(),
encryptedHeadersCiphertext: z.string().nullable().optional(), encryptedHeadersCiphertext: z.string().nullable().optional(),
encryptedHeadersIV: z.string().nullable().optional(), encryptedHeadersIV: z.string().nullable().optional(),
encryptedHeadersTag: z.string().nullable().optional(), encryptedHeadersTag: z.string().nullable().optional(),
@@ -17,7 +19,9 @@ export const AuditLogStreamsSchema = z.object({
encryptedHeadersKeyEncoding: z.string().nullable().optional(), encryptedHeadersKeyEncoding: z.string().nullable().optional(),
orgId: z.string().uuid(), orgId: z.string().uuid(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
provider: z.string().default("custom"),
encryptedCredentials: zodBuffer
}); });
export type TAuditLogStreams = z.infer<typeof AuditLogStreamsSchema>; export type TAuditLogStreams = z.infer<typeof AuditLogStreamsSchema>;
+1
View File
@@ -131,6 +131,7 @@ export enum TableName {
SecretApprovalRequestSecretTagV2 = "secret_approval_request_secret_tags_v2", SecretApprovalRequestSecretTagV2 = "secret_approval_request_secret_tags_v2",
SnapshotSecretV2 = "secret_snapshot_secrets_v2", SnapshotSecretV2 = "secret_snapshot_secrets_v2",
ProjectSplitBackfillIds = "project_split_backfill_ids", ProjectSplitBackfillIds = "project_split_backfill_ids",
UserNotifications = "user_notifications",
// Gateway // Gateway
OrgGatewayConfig = "org_gateway_config", OrgGatewayConfig = "org_gateway_config",
Gateway = "gateways", Gateway = "gateways",
@@ -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 { TImmutableDBKeys } from "./models";
export const UserNotificationsSchema = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
orgId: z.string().uuid().nullable().optional(),
type: z.string(),
title: z.string(),
body: z.string().nullable().optional(),
link: z.string().nullable().optional(),
isRead: z.boolean().default(false),
createdAt: z.date(),
updatedAt: z.date()
});
export type TUserNotifications = z.infer<typeof UserNotificationsSchema>;
export type TUserNotificationsInsert = Omit<z.input<typeof UserNotificationsSchema>, TImmutableDBKeys>;
export type TUserNotificationsUpdate = Partial<Omit<z.input<typeof UserNotificationsSchema>, TImmutableDBKeys>>;
@@ -1,215 +0,0 @@
import { z } from "zod";
import { AUDIT_LOG_STREAMS } from "@app/lib/api-docs";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { SanitizedAuditLogStreamSchema } from "@app/server/routes/sanitizedSchemas";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerAuditLogStreamRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/",
config: {
rateLimit: readLimit
},
schema: {
description: "Create an Audit Log Stream.",
security: [
{
bearerAuth: []
}
],
body: z.object({
url: z.string().min(1).describe(AUDIT_LOG_STREAMS.CREATE.url),
headers: z
.object({
key: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.CREATE.headers.key),
value: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.CREATE.headers.value)
})
.describe(AUDIT_LOG_STREAMS.CREATE.headers.desc)
.array()
.optional()
}),
response: {
200: z.object({
auditLogStream: SanitizedAuditLogStreamSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const auditLogStream = await server.services.auditLogStream.create({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
url: req.body.url,
headers: req.body.headers
});
return { auditLogStream };
}
});
server.route({
method: "PATCH",
url: "/:id",
config: {
rateLimit: readLimit
},
schema: {
description: "Update an Audit Log Stream by ID.",
security: [
{
bearerAuth: []
}
],
params: z.object({
id: z.string().describe(AUDIT_LOG_STREAMS.UPDATE.id)
}),
body: z.object({
url: z.string().optional().describe(AUDIT_LOG_STREAMS.UPDATE.url),
headers: z
.object({
key: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.UPDATE.headers.key),
value: z.string().min(1).trim().describe(AUDIT_LOG_STREAMS.UPDATE.headers.value)
})
.describe(AUDIT_LOG_STREAMS.UPDATE.headers.desc)
.array()
.optional()
}),
response: {
200: z.object({
auditLogStream: SanitizedAuditLogStreamSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const auditLogStream = await server.services.auditLogStream.updateById({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
id: req.params.id,
url: req.body.url,
headers: req.body.headers
});
return { auditLogStream };
}
});
server.route({
method: "DELETE",
url: "/:id",
config: {
rateLimit: readLimit
},
schema: {
description: "Delete an Audit Log Stream by ID.",
security: [
{
bearerAuth: []
}
],
params: z.object({
id: z.string().describe(AUDIT_LOG_STREAMS.DELETE.id)
}),
response: {
200: z.object({
auditLogStream: SanitizedAuditLogStreamSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const auditLogStream = await server.services.auditLogStream.deleteById({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
id: req.params.id
});
return { auditLogStream };
}
});
server.route({
method: "GET",
url: "/:id",
config: {
rateLimit: readLimit
},
schema: {
description: "Get an Audit Log Stream by ID.",
security: [
{
bearerAuth: []
}
],
params: z.object({
id: z.string().describe(AUDIT_LOG_STREAMS.GET_BY_ID.id)
}),
response: {
200: z.object({
auditLogStream: SanitizedAuditLogStreamSchema.extend({
headers: z
.object({
key: z.string(),
value: z.string()
})
.array()
.optional()
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const auditLogStream = await server.services.auditLogStream.getById({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
id: req.params.id
});
return { auditLogStream };
}
});
server.route({
method: "GET",
url: "/",
config: {
rateLimit: readLimit
},
schema: {
description: "List Audit Log Streams.",
security: [
{
bearerAuth: []
}
],
response: {
200: z.object({
auditLogStreams: SanitizedAuditLogStreamSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const auditLogStreams = await server.services.auditLogStream.list({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod
});
return { auditLogStreams };
}
});
};
@@ -0,0 +1,142 @@
import { z } from "zod";
import { LogProvider } from "@app/ee/services/audit-log-stream/audit-log-stream-enums";
import { TAuditLogStream } from "@app/ee/services/audit-log-stream/audit-log-stream-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 registerAuditLogStreamEndpoints = <T extends TAuditLogStream>({
server,
provider,
createSchema,
updateSchema,
sanitizedResponseSchema
}: {
server: FastifyZodProvider;
provider: LogProvider;
createSchema: z.ZodType<{
credentials: T["credentials"];
}>;
updateSchema: z.ZodType<{
credentials: T["credentials"];
}>;
sanitizedResponseSchema: z.ZodTypeAny;
}) => {
server.route({
method: "GET",
url: "/:logStreamId",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
logStreamId: z.string().uuid()
}),
response: {
200: z.object({
auditLogStream: sanitizedResponseSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { logStreamId } = req.params;
const auditLogStream = await server.services.auditLogStream.getById(logStreamId, provider, req.permission);
return { auditLogStream };
}
});
server.route({
method: "POST",
url: "/",
config: {
rateLimit: writeLimit
},
schema: {
body: createSchema,
response: {
200: z.object({
auditLogStream: sanitizedResponseSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { credentials } = req.body;
const auditLogStream = await server.services.auditLogStream.create(
{
provider,
credentials
},
req.permission
);
return { auditLogStream };
}
});
server.route({
method: "PATCH",
url: "/:logStreamId",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
logStreamId: z.string().uuid()
}),
body: updateSchema,
response: {
200: z.object({
auditLogStream: sanitizedResponseSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { logStreamId } = req.params;
const { credentials } = req.body;
const auditLogStream = await server.services.auditLogStream.updateById(
{
logStreamId,
provider,
credentials
},
req.permission
);
return { auditLogStream };
}
});
server.route({
method: "DELETE",
url: "/:logStreamId",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
logStreamId: z.string().uuid()
}),
response: {
200: z.object({
auditLogStream: sanitizedResponseSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { logStreamId } = req.params;
const auditLogStream = await server.services.auditLogStream.deleteById(logStreamId, provider, req.permission);
return { auditLogStream };
}
});
};
@@ -0,0 +1,73 @@
import { z } from "zod";
import {
CustomProviderListItemSchema,
SanitizedCustomProviderSchema
} from "@app/ee/services/audit-log-stream/custom/custom-provider-schemas";
import {
DatadogProviderListItemSchema,
SanitizedDatadogProviderSchema
} from "@app/ee/services/audit-log-stream/datadog/datadog-provider-schemas";
import {
SanitizedSplunkProviderSchema,
SplunkProviderListItemSchema
} from "@app/ee/services/audit-log-stream/splunk/splunk-provider-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";
const SanitizedAuditLogStreamSchema = z.union([
SanitizedCustomProviderSchema,
SanitizedDatadogProviderSchema,
SanitizedSplunkProviderSchema
]);
const ProviderOptionsSchema = z.discriminatedUnion("provider", [
CustomProviderListItemSchema,
DatadogProviderListItemSchema,
SplunkProviderListItemSchema
]);
export const registerAuditLogStreamRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/options",
config: {
rateLimit: readLimit
},
schema: {
response: {
200: z.object({
providerOptions: ProviderOptionsSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: () => {
const providerOptions = server.services.auditLogStream.listProviderOptions();
return { providerOptions };
}
});
server.route({
method: "GET",
url: "/",
config: {
rateLimit: readLimit
},
schema: {
response: {
200: z.object({
auditLogStreams: SanitizedAuditLogStreamSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const auditLogStreams = await server.services.auditLogStream.list(req.permission);
return { auditLogStreams };
}
});
};
@@ -0,0 +1,51 @@
import { LogProvider } from "@app/ee/services/audit-log-stream/audit-log-stream-enums";
import {
CreateCustomProviderLogStreamSchema,
SanitizedCustomProviderSchema,
UpdateCustomProviderLogStreamSchema
} from "@app/ee/services/audit-log-stream/custom/custom-provider-schemas";
import {
CreateDatadogProviderLogStreamSchema,
SanitizedDatadogProviderSchema,
UpdateDatadogProviderLogStreamSchema
} from "@app/ee/services/audit-log-stream/datadog/datadog-provider-schemas";
import {
CreateSplunkProviderLogStreamSchema,
SanitizedSplunkProviderSchema,
UpdateSplunkProviderLogStreamSchema
} from "@app/ee/services/audit-log-stream/splunk/splunk-provider-schemas";
import { registerAuditLogStreamEndpoints } from "./audit-log-stream-endpoints";
export * from "./audit-log-stream-router";
export const AUDIT_LOG_STREAM_REGISTER_ROUTER_MAP: Record<LogProvider, (server: FastifyZodProvider) => Promise<void>> =
{
[LogProvider.Custom]: async (server: FastifyZodProvider) => {
registerAuditLogStreamEndpoints({
server,
provider: LogProvider.Custom,
sanitizedResponseSchema: SanitizedCustomProviderSchema,
createSchema: CreateCustomProviderLogStreamSchema,
updateSchema: UpdateCustomProviderLogStreamSchema
});
},
[LogProvider.Datadog]: async (server: FastifyZodProvider) => {
registerAuditLogStreamEndpoints({
server,
provider: LogProvider.Datadog,
sanitizedResponseSchema: SanitizedDatadogProviderSchema,
createSchema: CreateDatadogProviderLogStreamSchema,
updateSchema: UpdateDatadogProviderLogStreamSchema
});
},
[LogProvider.Splunk]: async (server: FastifyZodProvider) => {
registerAuditLogStreamEndpoints({
server,
provider: LogProvider.Splunk,
sanitizedResponseSchema: SanitizedSplunkProviderSchema,
createSchema: CreateSplunkProviderLogStreamSchema,
updateSchema: UpdateSplunkProviderLogStreamSchema
});
}
};
+16 -2
View File
@@ -3,7 +3,7 @@ import { registerProjectTemplateRouter } from "@app/ee/routes/v1/project-templat
import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router";
import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; import { registerAccessApprovalRequestRouter } from "./access-approval-request-router";
import { registerAssumePrivilegeRouter } from "./assume-privilege-router"; import { registerAssumePrivilegeRouter } from "./assume-privilege-router";
import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; import { AUDIT_LOG_STREAM_REGISTER_ROUTER_MAP, registerAuditLogStreamRouter } from "./audit-log-stream-routers";
import { registerCaCrlRouter } from "./certificate-authority-crl-router"; import { registerCaCrlRouter } from "./certificate-authority-crl-router";
import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router";
import { registerKubernetesDynamicSecretLeaseRouter } from "./dynamic-secret-lease-routers/kubernetes-lease-router"; import { registerKubernetesDynamicSecretLeaseRouter } from "./dynamic-secret-lease-routers/kubernetes-lease-router";
@@ -114,7 +114,21 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
await server.register(registerSecretRouter, { prefix: "/secrets" }); await server.register(registerSecretRouter, { prefix: "/secrets" });
await server.register(registerSecretVersionRouter, { prefix: "/secret" }); await server.register(registerSecretVersionRouter, { prefix: "/secret" });
await server.register(registerGroupRouter, { prefix: "/groups" }); await server.register(registerGroupRouter, { prefix: "/groups" });
await server.register(registerAuditLogStreamRouter, { prefix: "/audit-log-streams" });
await server.register(
async (auditLogStreamRouter) => {
await auditLogStreamRouter.register(registerAuditLogStreamRouter);
// Provider-specific endpoints
await Promise.all(
Object.entries(AUDIT_LOG_STREAM_REGISTER_ROUTER_MAP).map(([provider, router]) =>
auditLogStreamRouter.register(router, { prefix: `/${provider}` })
)
);
},
{ prefix: "/audit-log-streams" }
);
await server.register(registerUserAdditionalPrivilegeRouter, { prefix: "/user-project-additional-privilege" }); await server.register(registerUserAdditionalPrivilegeRouter, { prefix: "/user-project-additional-privilege" });
await server.register( await server.register(
async (privilegeRouter) => { async (privilegeRouter) => {
@@ -20,6 +20,8 @@ import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserDALFactory } from "@app/services/user/user-dal";
import { TNotificationServiceFactory } from "../../../services/notification/notification-service";
import { NotificationType } from "../../../services/notification/notification-types";
import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-policy/access-approval-policy-approver-dal"; import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-policy/access-approval-policy-approver-dal";
import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal"; import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal";
import { TGroupDALFactory } from "../group/group-dal"; import { TGroupDALFactory } from "../group/group-dal";
@@ -67,6 +69,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
projectSlackConfigDAL: Pick<TProjectSlackConfigDALFactory, "getIntegrationDetailsByProject">; projectSlackConfigDAL: Pick<TProjectSlackConfigDALFactory, "getIntegrationDetailsByProject">;
microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "sendNotification">; microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "sendNotification">;
projectMicrosoftTeamsConfigDAL: Pick<TProjectMicrosoftTeamsConfigDALFactory, "getIntegrationDetailsByProject">; projectMicrosoftTeamsConfigDAL: Pick<TProjectMicrosoftTeamsConfigDALFactory, "getIntegrationDetailsByProject">;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
}; };
export const accessApprovalRequestServiceFactory = ({ export const accessApprovalRequestServiceFactory = ({
@@ -84,7 +87,8 @@ export const accessApprovalRequestServiceFactory = ({
kmsService, kmsService,
microsoftTeamsService, microsoftTeamsService,
projectMicrosoftTeamsConfigDAL, projectMicrosoftTeamsConfigDAL,
projectSlackConfigDAL projectSlackConfigDAL,
notificationService
}: TSecretApprovalRequestServiceFactoryDep): TAccessApprovalRequestServiceFactory => { }: TSecretApprovalRequestServiceFactoryDep): TAccessApprovalRequestServiceFactory => {
const $getEnvironmentFromPermissions = (permissions: unknown): string | null => { const $getEnvironmentFromPermissions = (permissions: unknown): string | null => {
if (!Array.isArray(permissions) || permissions.length === 0) { if (!Array.isArray(permissions) || permissions.length === 0) {
@@ -245,7 +249,8 @@ export const accessApprovalRequestServiceFactory = ({
); );
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
const approvalUrl = `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval`; const approvalPath = `/projects/secret-management/${project.id}/approval`;
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
await triggerWorkflowIntegrationNotification({ await triggerWorkflowIntegrationNotification({
input: { input: {
@@ -274,6 +279,17 @@ export const accessApprovalRequestServiceFactory = ({
} }
}); });
await notificationService.createUserNotifications(
approverUsers.map((approver) => ({
userId: approver.id,
orgId: actorOrgId,
type: NotificationType.ACCESS_APPROVAL_REQUEST,
title: "Access Approval Request",
body: `**${requesterFullName}** (${requestedByUser.email}) has requested ${isTemporary ? "temporary" : "permanent"} access to **${secretPath}** in the **${envSlug}** environment for project **${project.name}**.`,
link: approvalPath
}))
);
await smtpService.sendMail({ await smtpService.sendMail({
recipients: approverUsers.filter((approver) => approver.email).map((approver) => approver.email!), recipients: approverUsers.filter((approver) => approver.email).map((approver) => approver.email!),
subjectLine: "Access Approval Request", subjectLine: "Access Approval Request",
@@ -391,7 +407,8 @@ export const accessApprovalRequestServiceFactory = ({
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
const editorFullName = `${editedByUser.firstName} ${editedByUser.lastName}`; const editorFullName = `${editedByUser.firstName} ${editedByUser.lastName}`;
const approvalUrl = `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval`; const approvalPath = `/projects/secret-management/${project.id}/approval`;
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
await triggerWorkflowIntegrationNotification({ await triggerWorkflowIntegrationNotification({
input: { input: {
@@ -422,27 +439,44 @@ export const accessApprovalRequestServiceFactory = ({
} }
}); });
await smtpService.sendMail({ await notificationService.createUserNotifications(
recipients: policy.approvers policy.approvers
.filter((approver) => Boolean(approver.email) && approver.userId !== editedByUser.id) .filter((approver) => Boolean(approver.userId) && approver.userId !== editedByUser.id)
.map((approver) => approver.email!), .map((approver) => ({
subjectLine: "Access Approval Request Updated", userId: approver.userId!,
substitutions: { orgId: actorOrgId,
projectName: project.name, type: NotificationType.ACCESS_APPROVAL_REQUEST_UPDATED,
requesterFullName, title: "Access Approval Request Updated",
requesterEmail: requestedByUser.email, body: `**${editorFullName}** (${editedByUser.email}) has updated the access request submitted by **${requesterFullName}** (${requestedByUser.email}) for **${secretPath}** in the **${envSlug}** environment for project **${project.name}**.`,
isTemporary: true, link: approvalPath
expiresIn: msFn(ms(temporaryRange || ""), { long: true }), }))
secretPath, );
environment: envSlug,
permissions: accessTypes, const recipients = policy.approvers
approvalUrl, .filter((approver) => Boolean(approver.email) && approver.userId !== editedByUser.id)
editNote, .map((approver) => approver.email!);
editorFullName,
editorEmail: editedByUser.email if (recipients.length > 0) {
}, await smtpService.sendMail({
template: SmtpTemplates.AccessApprovalRequestUpdated recipients,
}); subjectLine: "Access Approval Request Updated",
substitutions: {
projectName: project.name,
requesterFullName,
requesterEmail: requestedByUser.email,
isTemporary: true,
expiresIn: msFn(ms(temporaryRange || ""), { long: true }),
secretPath,
environment: envSlug,
permissions: accessTypes,
approvalUrl,
editNote,
editorFullName,
editorEmail: editedByUser.email
},
template: SmtpTemplates.AccessApprovalRequestUpdated
});
}
return approvalRequest; return approvalRequest;
}); });
@@ -0,0 +1,5 @@
export enum LogProvider {
Datadog = "datadog",
Splunk = "splunk",
Custom = "custom"
}
@@ -0,0 +1,13 @@
import { LogProvider } from "./audit-log-stream-enums";
import { TAuditLogStreamCredentials, TLogStreamFactory } from "./audit-log-stream-types";
import { CustomProviderFactory } from "./custom/custom-provider-factory";
import { DatadogProviderFactory } from "./datadog/datadog-provider-factory";
import { SplunkProviderFactory } from "./splunk/splunk-provider-factory";
type TLogStreamFactoryImplementation = TLogStreamFactory<TAuditLogStreamCredentials>;
export const LOG_STREAM_FACTORY_MAP: Record<LogProvider, TLogStreamFactoryImplementation> = {
[LogProvider.Datadog]: DatadogProviderFactory as TLogStreamFactoryImplementation,
[LogProvider.Splunk]: SplunkProviderFactory as TLogStreamFactoryImplementation,
[LogProvider.Custom]: CustomProviderFactory as TLogStreamFactoryImplementation
};
@@ -1,21 +1,70 @@
export function providerSpecificPayload(url: string) { import { TAuditLogStreams } from "@app/db/schemas";
const { hostname } = new URL(url); import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
const payload: Record<string, string> = {}; import { TAuditLogStream, TAuditLogStreamCredentials } from "./audit-log-stream-types";
import { getCustomProviderListItem } from "./custom/custom-provider-fns";
import { getDatadogProviderListItem } from "./datadog/datadog-provider-fns";
import { getSplunkProviderListItem } from "./splunk/splunk-provider-fns";
switch (hostname) { export const listProviderOptions = () => {
case "http-intake.logs.datadoghq.com": return [getDatadogProviderListItem(), getSplunkProviderListItem(), getCustomProviderListItem()].sort((a, b) =>
case "http-intake.logs.us3.datadoghq.com": a.name.localeCompare(b.name)
case "http-intake.logs.us5.datadoghq.com": );
case "http-intake.logs.datadoghq.eu": };
case "http-intake.logs.ap1.datadoghq.com":
case "http-intake.logs.ddog-gov.com":
payload.ddsource = "infisical";
payload.service = "audit-logs";
break;
default:
break;
}
return payload; export const encryptLogStreamCredentials = async ({
} orgId,
credentials,
kmsService
}: {
orgId: string;
credentials: TAuditLogStreamCredentials;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId
});
const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({
plainText: Buffer.from(JSON.stringify(credentials))
});
return encryptedCredentialsBlob;
};
export const decryptLogStreamCredentials = async ({
orgId,
encryptedCredentials,
kmsService
}: {
orgId: string;
encryptedCredentials: Buffer;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => {
const { decryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId
});
const decryptedPlainTextBlob = decryptor({
cipherTextBlob: encryptedCredentials
});
return JSON.parse(decryptedPlainTextBlob.toString()) as TAuditLogStreamCredentials;
};
export const decryptLogStream = async (
logStream: TAuditLogStreams,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
return {
...logStream,
credentials: await decryptLogStreamCredentials({
encryptedCredentials: logStream.encryptedCredentials,
orgId: logStream.orgId,
kmsService
})
} as TAuditLogStream;
};
@@ -0,0 +1,14 @@
import { AuditLogStreamsSchema } from "@app/db/schemas";
export const BaseProviderSchema = AuditLogStreamsSchema.omit({
encryptedCredentials: true,
provider: true,
// Old "archived" values
encryptedHeadersAlgorithm: true,
encryptedHeadersCiphertext: true,
encryptedHeadersIV: true,
encryptedHeadersKeyEncoding: true,
encryptedHeadersTag: true,
url: true
});
@@ -1,242 +1,252 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { RawAxiosRequestHeaders } from "axios"; import { AxiosError } from "axios";
import { SecretKeyEncoding } from "@app/db/schemas"; import { TAuditLogs } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env"; import {
import { request } from "@app/lib/config/request"; decryptLogStream,
import { crypto } from "@app/lib/crypto/cryptography"; decryptLogStreamCredentials,
import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; encryptLogStreamCredentials,
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; listProviderOptions
} from "@app/ee/services/audit-log-stream/audit-log-stream-fns";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { AUDIT_LOG_STREAM_TIMEOUT } from "../audit-log/audit-log-queue";
import { TLicenseServiceFactory } from "../license/license-service"; import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { TPermissionServiceFactory } from "../permission/permission-service-types";
import { TAuditLogStreamDALFactory } from "./audit-log-stream-dal"; import { TAuditLogStreamDALFactory } from "./audit-log-stream-dal";
import { providerSpecificPayload } from "./audit-log-stream-fns"; import { LogProvider } from "./audit-log-stream-enums";
import { LogStreamHeaders, TAuditLogStreamServiceFactory } from "./audit-log-stream-types"; import { LOG_STREAM_FACTORY_MAP } from "./audit-log-stream-factory";
import { TAuditLogStream, TCreateAuditLogStreamDTO, TUpdateAuditLogStreamDTO } from "./audit-log-stream-types";
import { TCustomProviderCredentials } from "./custom/custom-provider-types";
type TAuditLogStreamServiceFactoryDep = { export type TAuditLogStreamServiceFactoryDep = {
auditLogStreamDAL: TAuditLogStreamDALFactory; auditLogStreamDAL: TAuditLogStreamDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">; permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}; };
export type TAuditLogStreamServiceFactory = ReturnType<typeof auditLogStreamServiceFactory>;
export const auditLogStreamServiceFactory = ({ export const auditLogStreamServiceFactory = ({
auditLogStreamDAL, auditLogStreamDAL,
permissionService, permissionService,
licenseService licenseService,
}: TAuditLogStreamServiceFactoryDep): TAuditLogStreamServiceFactory => { kmsService
const create: TAuditLogStreamServiceFactory["create"] = async ({ }: TAuditLogStreamServiceFactoryDep) => {
url, const create = async ({ provider, credentials }: TCreateAuditLogStreamDTO, actor: OrgServiceActor) => {
actor, const plan = await licenseService.getPlan(actor.orgId);
headers = [],
actorId,
actorOrgId,
actorAuthMethod
}) => {
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID attached to authentication token" });
const plan = await licenseService.getPlan(actorOrgId);
if (!plan.auditLogStreams) { if (!plan.auditLogStreams) {
throw new BadRequestError({ throw new BadRequestError({
message: "Failed to create audit log streams due to plan restriction. Upgrade plan to create group." message: "Failed to create Audit Log Stream: Plan restriction. Upgrade plan to continue."
}); });
} }
const { permission } = await permissionService.getOrgPermission( const { permission } = await permissionService.getOrgPermission(
actor, actor.type,
actorId, actor.id,
actorOrgId, actor.orgId,
actorAuthMethod, actor.authMethod,
actorOrgId actor.orgId
); );
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Settings);
const appCfg = getConfig(); const totalStreams = await auditLogStreamDAL.find({ orgId: actor.orgId });
if (appCfg.isCloud) await blockLocalAndPrivateIpAddresses(url);
const totalStreams = await auditLogStreamDAL.find({ orgId: actorOrgId });
if (totalStreams.length >= plan.auditLogStreamLimit) { if (totalStreams.length >= plan.auditLogStreamLimit) {
throw new BadRequestError({ throw new BadRequestError({
message: message: "Failed to create Audit Log Stream: Plan limit reached. Contact Infisical to increase quota."
"Failed to create audit log streams due to plan limit reached. Kindly contact Infisical to add more streams."
}); });
} }
// testing connection first const factory = LOG_STREAM_FACTORY_MAP[provider]();
const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" }; const validatedCredentials = await factory.validateCredentials({ credentials });
if (headers.length)
headers.forEach(({ key, value }) => {
streamHeaders[key] = value;
});
await request const encryptedCredentials = await encryptLogStreamCredentials({
.post( credentials: validatedCredentials,
url, orgId: actor.orgId,
{ ...providerSpecificPayload(url), ping: "ok" }, kmsService
{ });
headers: streamHeaders,
// request timeout
timeout: AUDIT_LOG_STREAM_TIMEOUT,
// connection timeout
signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT)
}
)
.catch((err) => {
throw new BadRequestError({ message: `Failed to connect with upstream source: ${(err as Error)?.message}` });
});
const encryptedHeaders = headers
? crypto.encryption().symmetric().encryptWithRootEncryptionKey(JSON.stringify(headers))
: undefined;
const logStream = await auditLogStreamDAL.create({ const logStream = await auditLogStreamDAL.create({
orgId: actorOrgId, orgId: actor.orgId,
url, provider,
...(encryptedHeaders encryptedCredentials
? {
encryptedHeadersCiphertext: encryptedHeaders.ciphertext,
encryptedHeadersIV: encryptedHeaders.iv,
encryptedHeadersTag: encryptedHeaders.tag,
encryptedHeadersAlgorithm: encryptedHeaders.algorithm,
encryptedHeadersKeyEncoding: encryptedHeaders.encoding
}
: {})
}); });
return logStream;
return { ...logStream, credentials: validatedCredentials } as TAuditLogStream;
}; };
const updateById: TAuditLogStreamServiceFactory["updateById"] = async ({ const updateById = async (
id, { logStreamId, provider, credentials }: TUpdateAuditLogStreamDTO,
url, actor: OrgServiceActor
actor, ) => {
headers = [], const plan = await licenseService.getPlan(actor.orgId);
actorId, if (!plan.auditLogStreams) {
actorOrgId,
actorAuthMethod
}) => {
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID attached to authentication token" });
const plan = await licenseService.getPlan(actorOrgId);
if (!plan.auditLogStreams)
throw new BadRequestError({ throw new BadRequestError({
message: "Failed to update audit log streams due to plan restriction. Upgrade plan to create group." message: "Failed to update Audit Log Stream: Plan restriction. Upgrade plan to continue."
}); });
}
const logStream = await auditLogStreamDAL.findById(id); const logStream = await auditLogStreamDAL.findById(logStreamId);
if (!logStream) throw new NotFoundError({ message: `Audit log stream with ID '${id}' not found` }); if (!logStream) throw new NotFoundError({ message: `Audit Log Stream with ID '${logStreamId}' not found` });
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
logStream.orgId
);
const { orgId } = logStream;
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings);
const appCfg = getConfig();
if (url && appCfg.isCloud) await blockLocalAndPrivateIpAddresses(url);
// testing connection first const finalCredentials = { ...credentials };
const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" };
if (headers.length)
headers.forEach(({ key, value }) => {
streamHeaders[key] = value;
});
await request // For the "Custom" provider, we must handle masked header values ('******').
.post( // These are placeholders from the frontend for secrets that haven't been changed.
url || logStream.url, // We need to replace them with the original, unmasked values from the database.
{ ...providerSpecificPayload(url || logStream.url), ping: "ok" }, if (
{ provider === LogProvider.Custom &&
headers: streamHeaders, "headers" in finalCredentials &&
// request timeout Array.isArray(finalCredentials.headers) &&
timeout: AUDIT_LOG_STREAM_TIMEOUT, finalCredentials.headers.some((header) => header.value === "******")
// connection timeout ) {
signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT) const decryptedOldCredentials = (await decryptLogStreamCredentials({
} encryptedCredentials: logStream.encryptedCredentials,
) orgId: logStream.orgId,
.catch((err) => { kmsService
throw new Error(`Failed to connect with the source ${(err as Error)?.message}`); })) as TCustomProviderCredentials;
});
const encryptedHeaders = headers const oldHeadersMap = decryptedOldCredentials.headers.reduce<Record<string, string>>((acc, header) => {
? crypto.encryption().symmetric().encryptWithRootEncryptionKey(JSON.stringify(headers)) acc[header.key] = header.value;
: undefined; return acc;
const updatedLogStream = await auditLogStreamDAL.updateById(id, { }, {});
url,
...(encryptedHeaders const finalHeaders: { key: string; value: string }[] = [];
? { for (const header of finalCredentials.headers) {
encryptedHeadersCiphertext: encryptedHeaders.ciphertext, if (header.value === "******") {
encryptedHeadersIV: encryptedHeaders.iv, const oldValue = oldHeadersMap[header.key];
encryptedHeadersTag: encryptedHeaders.tag, if (oldValue) {
encryptedHeadersAlgorithm: encryptedHeaders.algorithm, finalHeaders.push({ key: header.key, value: oldValue });
encryptedHeadersKeyEncoding: encryptedHeaders.encoding
} }
: {}) } else {
finalHeaders.push(header);
}
}
finalCredentials.headers = finalHeaders;
}
const factory = LOG_STREAM_FACTORY_MAP[provider]();
const validatedCredentials = await factory.validateCredentials({ credentials: finalCredentials });
const encryptedCredentials = await encryptLogStreamCredentials({
credentials: validatedCredentials,
orgId: actor.orgId,
kmsService
}); });
return updatedLogStream;
const updatedLogStream = await auditLogStreamDAL.updateById(logStreamId, {
encryptedCredentials
});
return { ...updatedLogStream, credentials: validatedCredentials } as TAuditLogStream;
}; };
const deleteById: TAuditLogStreamServiceFactory["deleteById"] = async ({ const deleteById = async (logStreamId: string, provider: LogProvider, actor: OrgServiceActor) => {
id, const logStream = await auditLogStreamDAL.findById(logStreamId);
actor, if (!logStream) throw new NotFoundError({ message: `Audit Log Stream with ID '${logStreamId}' not found` });
actorId,
actorOrgId,
actorAuthMethod
}) => {
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID attached to authentication token" });
const logStream = await auditLogStreamDAL.findById(id); const { permission } = await permissionService.getOrgPermission(
if (!logStream) throw new NotFoundError({ message: `Audit log stream with ID '${id}' not found` }); actor.type,
actor.id,
actor.orgId,
actor.authMethod,
logStream.orgId
);
const { orgId } = logStream;
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Settings);
const deletedLogStream = await auditLogStreamDAL.deleteById(id); if (logStream.provider !== provider) {
return deletedLogStream; throw new BadRequestError({
message: `Audit Log Stream with ID '${logStreamId}' is not for provider '${provider}'`
});
}
const deletedLogStream = await auditLogStreamDAL.deleteById(logStreamId);
return decryptLogStream(deletedLogStream, kmsService);
}; };
const getById: TAuditLogStreamServiceFactory["getById"] = async ({ const getById = async (logStreamId: string, provider: LogProvider, actor: OrgServiceActor) => {
id, const logStream = await auditLogStreamDAL.findById(logStreamId);
actor,
actorId,
actorOrgId,
actorAuthMethod
}) => {
const logStream = await auditLogStreamDAL.findById(id);
if (!logStream) throw new NotFoundError({ message: `Audit log stream with ID '${id}' not found` });
const { orgId } = logStream; if (!logStream) throw new NotFoundError({ message: `Audit log stream with ID '${logStreamId}' not found` });
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings);
const headers =
logStream?.encryptedHeadersCiphertext && logStream?.encryptedHeadersIV && logStream?.encryptedHeadersTag
? (JSON.parse(
crypto
.encryption()
.symmetric()
.decryptWithRootEncryptionKey({
tag: logStream.encryptedHeadersTag,
iv: logStream.encryptedHeadersIV,
ciphertext: logStream.encryptedHeadersCiphertext,
keyEncoding: logStream.encryptedHeadersKeyEncoding as SecretKeyEncoding
})
) as LogStreamHeaders[])
: undefined;
return { ...logStream, headers };
};
const list: TAuditLogStreamServiceFactory["list"] = async ({ actor, actorId, actorOrgId, actorAuthMethod }) => {
const { permission } = await permissionService.getOrgPermission( const { permission } = await permissionService.getOrgPermission(
actor, actor.type,
actorId, actor.id,
actorOrgId, logStream.orgId,
actorAuthMethod, actor.authMethod,
actorOrgId actor.orgId
); );
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings);
const logStreams = await auditLogStreamDAL.find({ orgId: actorOrgId }); if (logStream.provider !== provider) {
return logStreams; throw new BadRequestError({
message: `Audit Log Stream with ID '${logStreamId}' is not for provider '${provider}'`
});
}
return decryptLogStream(logStream, kmsService);
};
const list = async (actor: OrgServiceActor) => {
const { permission } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
actor.orgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Settings);
const logStreams = await auditLogStreamDAL.find({ orgId: actor.orgId });
return Promise.all(logStreams.map((stream) => decryptLogStream(stream, kmsService)));
};
const streamLog = async (orgId: string, auditLog: TAuditLogs) => {
const logStreams = await auditLogStreamDAL.find({ orgId });
await Promise.allSettled(
logStreams.map(async ({ provider, encryptedCredentials }) => {
const credentials = await decryptLogStreamCredentials({
encryptedCredentials,
orgId,
kmsService
});
const factory = LOG_STREAM_FACTORY_MAP[provider as LogProvider]();
try {
await factory.streamLog({
credentials,
auditLog
});
} catch (error) {
logger.error(
error,
`Failed to stream audit log [auditLogId=${auditLog.id}] [provider=${provider}] [orgId=${orgId}]${error instanceof AxiosError ? `: ${error.message}` : ""}`
);
throw error;
}
})
);
}; };
return { return {
@@ -244,6 +254,8 @@ export const auditLogStreamServiceFactory = ({
updateById, updateById,
deleteById, deleteById,
getById, getById,
list list,
listProviderOptions,
streamLog
}; };
}; };
@@ -1,48 +1,38 @@
import { TAuditLogStreams } from "@app/db/schemas"; import { TAuditLogs } from "@app/db/schemas";
import { TOrgPermission } from "@app/lib/types";
export type LogStreamHeaders = { import { LogProvider } from "./audit-log-stream-enums";
key: string; import { TCustomProvider, TCustomProviderCredentials } from "./custom/custom-provider-types";
value: string; import { TDatadogProvider, TDatadogProviderCredentials } from "./datadog/datadog-provider-types";
import { TSplunkProvider, TSplunkProviderCredentials } from "./splunk/splunk-provider-types";
export type TAuditLogStream = TDatadogProvider | TSplunkProvider | TCustomProvider;
export type TAuditLogStreamCredentials =
| TDatadogProviderCredentials
| TSplunkProviderCredentials
| TCustomProviderCredentials;
export type TCreateAuditLogStreamDTO = {
provider: LogProvider;
credentials: TAuditLogStreamCredentials;
}; };
export type TCreateAuditLogStreamDTO = Omit<TOrgPermission, "orgId"> & { export type TUpdateAuditLogStreamDTO = {
url: string; logStreamId: string;
headers?: LogStreamHeaders[]; provider: LogProvider;
credentials: TAuditLogStreamCredentials;
}; };
export type TUpdateAuditLogStreamDTO = Omit<TOrgPermission, "orgId"> & { export type TLogStreamFactoryValidateCredentials<C extends TAuditLogStreamCredentials> = (input: {
id: string; credentials: C;
url?: string; }) => Promise<C>;
headers?: LogStreamHeaders[];
};
export type TDeleteAuditLogStreamDTO = Omit<TOrgPermission, "orgId"> & { export type TLogStreamFactoryStreamLog<C extends TAuditLogStreamCredentials> = (input: {
id: string; credentials: C;
}; auditLog: TAuditLogs;
}) => Promise<void>;
export type TListAuditLogStreamDTO = Omit<TOrgPermission, "orgId">; export type TLogStreamFactory<C extends TAuditLogStreamCredentials> = () => {
validateCredentials: TLogStreamFactoryValidateCredentials<C>;
export type TGetDetailsAuditLogStreamDTO = Omit<TOrgPermission, "orgId"> & { streamLog: TLogStreamFactoryStreamLog<C>;
id: string;
};
export type TAuditLogStreamServiceFactory = {
create: (arg: TCreateAuditLogStreamDTO) => Promise<TAuditLogStreams>;
updateById: (arg: TUpdateAuditLogStreamDTO) => Promise<TAuditLogStreams>;
deleteById: (arg: TDeleteAuditLogStreamDTO) => Promise<TAuditLogStreams>;
getById: (arg: TGetDetailsAuditLogStreamDTO) => Promise<{
headers: LogStreamHeaders[] | undefined;
orgId: string;
url: string;
id: string;
createdAt: Date;
updatedAt: Date;
encryptedHeadersCiphertext?: string | null | undefined;
encryptedHeadersIV?: string | null | undefined;
encryptedHeadersTag?: string | null | undefined;
encryptedHeadersAlgorithm?: string | null | undefined;
encryptedHeadersKeyEncoding?: string | null | undefined;
}>;
list: (arg: TListAuditLogStreamDTO) => Promise<TAuditLogStreams[]>;
}; };
@@ -0,0 +1,67 @@
import { RawAxiosRequestHeaders } from "axios";
import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { AUDIT_LOG_STREAM_TIMEOUT } from "../../audit-log/audit-log-queue";
import { TLogStreamFactoryStreamLog, TLogStreamFactoryValidateCredentials } from "../audit-log-stream-types";
import { TCustomProviderCredentials } from "./custom-provider-types";
export const CustomProviderFactory = () => {
const validateCredentials: TLogStreamFactoryValidateCredentials<TCustomProviderCredentials> = async ({
credentials
}) => {
const { url, headers } = credentials;
await blockLocalAndPrivateIpAddresses(url);
const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" };
if (headers.length) {
headers.forEach(({ key, value }) => {
streamHeaders[key] = value;
});
}
await request
.post(
url,
{ ping: "ok" },
{
headers: streamHeaders,
timeout: AUDIT_LOG_STREAM_TIMEOUT,
signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT)
}
)
.catch((err) => {
throw new BadRequestError({ message: `Failed to connect with upstream source: ${(err as Error)?.message}` });
});
return credentials;
};
const streamLog: TLogStreamFactoryStreamLog<TCustomProviderCredentials> = async ({ credentials, auditLog }) => {
const { url, headers } = credentials;
await blockLocalAndPrivateIpAddresses(url);
const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json" };
if (headers.length) {
headers.forEach(({ key, value }) => {
streamHeaders[key] = value;
});
}
await request.post(url, auditLog, {
headers: streamHeaders,
timeout: AUDIT_LOG_STREAM_TIMEOUT,
signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT)
});
};
return {
validateCredentials,
streamLog
};
};
@@ -0,0 +1,8 @@
import { LogProvider } from "../audit-log-stream-enums";
export const getCustomProviderListItem = () => {
return {
name: "Custom" as const,
provider: LogProvider.Custom as const
};
};
@@ -0,0 +1,50 @@
import RE2 from "re2";
import { z } from "zod";
import { LogProvider } from "../audit-log-stream-enums";
import { BaseProviderSchema } from "../audit-log-stream-schemas";
export const CustomProviderCredentialsSchema = z.object({
url: z.string().url().trim().min(1).max(255),
headers: z
.object({
key: z
.string()
.min(1)
.refine((val) => new RE2(/^[^\n\r]+$/).test(val), "Header keys cannot contain newlines or carriage returns"),
value: z
.string()
.min(1)
.refine((val) => new RE2(/^[^\n\r]+$/).test(val), "Header values cannot contain newlines or carriage returns")
})
.array()
});
const BaseCustomProviderSchema = BaseProviderSchema.extend({ provider: z.literal(LogProvider.Custom) });
export const CustomProviderSchema = BaseCustomProviderSchema.extend({
credentials: CustomProviderCredentialsSchema
});
export const SanitizedCustomProviderSchema = BaseCustomProviderSchema.extend({
credentials: z.object({
url: CustomProviderCredentialsSchema.shape.url,
// Return header keys and a redacted value
headers: CustomProviderCredentialsSchema.shape.headers.transform((headers) =>
headers.map((header) => ({ ...header, value: "******" }))
)
})
});
export const CustomProviderListItemSchema = z.object({
name: z.literal("Custom"),
provider: z.literal(LogProvider.Custom)
});
export const CreateCustomProviderLogStreamSchema = z.object({
credentials: CustomProviderCredentialsSchema
});
export const UpdateCustomProviderLogStreamSchema = z.object({
credentials: CustomProviderCredentialsSchema
});
@@ -0,0 +1,7 @@
import { z } from "zod";
import { CustomProviderCredentialsSchema, CustomProviderSchema } from "./custom-provider-schemas";
export type TCustomProvider = z.infer<typeof CustomProviderSchema>;
export type TCustomProviderCredentials = z.infer<typeof CustomProviderCredentialsSchema>;
@@ -0,0 +1,67 @@
import { RawAxiosRequestHeaders } from "axios";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { AUDIT_LOG_STREAM_TIMEOUT } from "../../audit-log/audit-log-queue";
import { TLogStreamFactoryStreamLog, TLogStreamFactoryValidateCredentials } from "../audit-log-stream-types";
import { TDatadogProviderCredentials } from "./datadog-provider-types";
function createPayload(event: Record<string, unknown>) {
const appCfg = getConfig();
const ddtags = [`env:${appCfg.NODE_ENV || "unknown"}`].join(",");
return {
...event,
hostname: new URL(appCfg.SITE_URL || "http://infisical").hostname,
ddsource: "infisical",
service: "infisical",
ddtags
};
}
export const DatadogProviderFactory = () => {
const validateCredentials: TLogStreamFactoryValidateCredentials<TDatadogProviderCredentials> = async ({
credentials
}) => {
const { url, token } = credentials;
await blockLocalAndPrivateIpAddresses(url);
const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json", "DD-API-KEY": token };
await request
.post(url, createPayload({ ping: "ok" }), {
headers: streamHeaders,
timeout: AUDIT_LOG_STREAM_TIMEOUT,
signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT)
})
.catch((err) => {
throw new BadRequestError({ message: `Failed to connect with Datadog: ${(err as Error)?.message}` });
});
return credentials;
};
const streamLog: TLogStreamFactoryStreamLog<TDatadogProviderCredentials> = async ({ credentials, auditLog }) => {
const { url, token } = credentials;
await blockLocalAndPrivateIpAddresses(url);
const streamHeaders: RawAxiosRequestHeaders = { "Content-Type": "application/json", "DD-API-KEY": token };
await request.post(url, createPayload(auditLog), {
headers: streamHeaders,
timeout: AUDIT_LOG_STREAM_TIMEOUT,
signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT)
});
};
return {
validateCredentials,
streamLog
};
};
@@ -0,0 +1,8 @@
import { LogProvider } from "../audit-log-stream-enums";
export const getDatadogProviderListItem = () => {
return {
name: "Datadog" as const,
provider: LogProvider.Datadog as const
};
};
@@ -0,0 +1,38 @@
import RE2 from "re2";
import { z } from "zod";
import { LogProvider } from "../audit-log-stream-enums";
import { BaseProviderSchema } from "../audit-log-stream-schemas";
export const DatadogProviderCredentialsSchema = z.object({
url: z.string().url().trim().min(1).max(255),
token: z
.string()
.trim()
.refine((val) => new RE2(/^[a-fA-F0-9]{32}$/).test(val), "Invalid Datadog API key format")
});
const BaseDatadogProviderSchema = BaseProviderSchema.extend({ provider: z.literal(LogProvider.Datadog) });
export const DatadogProviderSchema = BaseDatadogProviderSchema.extend({
credentials: DatadogProviderCredentialsSchema
});
export const SanitizedDatadogProviderSchema = BaseDatadogProviderSchema.extend({
credentials: DatadogProviderCredentialsSchema.pick({
url: true
})
});
export const DatadogProviderListItemSchema = z.object({
name: z.literal("Datadog"),
provider: z.literal(LogProvider.Datadog)
});
export const CreateDatadogProviderLogStreamSchema = z.object({
credentials: DatadogProviderCredentialsSchema
});
export const UpdateDatadogProviderLogStreamSchema = z.object({
credentials: DatadogProviderCredentialsSchema
});
@@ -0,0 +1,7 @@
import { z } from "zod";
import { DatadogProviderCredentialsSchema, DatadogProviderSchema } from "./datadog-provider-schemas";
export type TDatadogProvider = z.infer<typeof DatadogProviderSchema>;
export type TDatadogProviderCredentials = z.infer<typeof DatadogProviderCredentialsSchema>;
@@ -0,0 +1,84 @@
import { RawAxiosRequestHeaders } from "axios";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { AUDIT_LOG_STREAM_TIMEOUT } from "../../audit-log/audit-log-queue";
import { TLogStreamFactoryStreamLog, TLogStreamFactoryValidateCredentials } from "../audit-log-stream-types";
import { TSplunkProviderCredentials } from "./splunk-provider-types";
function createPayload(event: Record<string, unknown>) {
const appCfg = getConfig();
return {
time: Math.floor(Date.now() / 1000),
...(appCfg.SITE_URL && { host: new URL(appCfg.SITE_URL).host }),
source: "infisical",
sourcetype: "_json",
event
};
}
async function createSplunkUrl(hostname: string) {
let parsedHostname: string;
try {
parsedHostname = new URL(`https://${hostname}`).hostname;
} catch (error) {
throw new BadRequestError({ message: `Invalid Splunk hostname provided: ${(error as Error).message}` });
}
await blockLocalAndPrivateIpAddresses(`https://${parsedHostname}`);
return `https://${parsedHostname}:8088/services/collector/event`;
}
export const SplunkProviderFactory = () => {
const validateCredentials: TLogStreamFactoryValidateCredentials<TSplunkProviderCredentials> = async ({
credentials
}) => {
const { hostname, token } = credentials;
const url = await createSplunkUrl(hostname);
const streamHeaders: RawAxiosRequestHeaders = {
"Content-Type": "application/json",
Authorization: `Splunk ${token}`
};
await request
.post(url, createPayload({ ping: "ok" }), {
headers: streamHeaders,
timeout: AUDIT_LOG_STREAM_TIMEOUT,
signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT)
})
.catch((err) => {
throw new BadRequestError({ message: `Failed to connect with Splunk: ${(err as Error)?.message}` });
});
return credentials;
};
const streamLog: TLogStreamFactoryStreamLog<TSplunkProviderCredentials> = async ({ credentials, auditLog }) => {
const { hostname, token } = credentials;
const url = await createSplunkUrl(hostname);
const streamHeaders: RawAxiosRequestHeaders = {
"Content-Type": "application/json",
Authorization: `Splunk ${token}`
};
await request.post(url, createPayload(auditLog), {
headers: streamHeaders,
timeout: AUDIT_LOG_STREAM_TIMEOUT,
signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT)
});
};
return {
validateCredentials,
streamLog
};
};
@@ -0,0 +1,8 @@
import { LogProvider } from "../audit-log-stream-enums";
export const getSplunkProviderListItem = () => {
return {
name: "Splunk" as const,
provider: LogProvider.Splunk as const
};
};
@@ -0,0 +1,59 @@
import { z } from "zod";
import { LogProvider } from "../audit-log-stream-enums";
import { BaseProviderSchema } from "../audit-log-stream-schemas";
export const SplunkProviderCredentialsSchema = z.object({
hostname: z
.string()
.trim()
.min(1)
.max(255)
.superRefine((val, ctx) => {
if (val.includes("://")) {
ctx.addIssue({
code: "custom",
message: "Hostname should not include protocol"
});
return;
}
try {
const url = new URL(`https://${val}`);
if (url.hostname !== val) {
ctx.addIssue({
code: "custom",
message: "Must be a valid hostname without port or path"
});
}
} catch {
ctx.addIssue({ code: "custom", message: "Invalid hostname" });
}
}),
token: z.string().uuid().trim().min(1)
});
const BaseSplunkProviderSchema = BaseProviderSchema.extend({ provider: z.literal(LogProvider.Splunk) });
export const SplunkProviderSchema = BaseSplunkProviderSchema.extend({
credentials: SplunkProviderCredentialsSchema
});
export const SanitizedSplunkProviderSchema = BaseSplunkProviderSchema.extend({
credentials: SplunkProviderCredentialsSchema.pick({
hostname: true
})
});
export const SplunkProviderListItemSchema = z.object({
name: z.literal("Splunk"),
provider: z.literal(LogProvider.Splunk)
});
export const CreateSplunkProviderLogStreamSchema = z.object({
credentials: SplunkProviderCredentialsSchema
});
export const UpdateSplunkProviderLogStreamSchema = z.object({
credentials: SplunkProviderCredentialsSchema
});
@@ -0,0 +1,7 @@
import { z } from "zod";
import { SplunkProviderCredentialsSchema, SplunkProviderSchema } from "./splunk-provider-schemas";
export type TSplunkProvider = z.infer<typeof SplunkProviderSchema>;
export type TSplunkProviderCredentials = z.infer<typeof SplunkProviderCredentialsSchema>;
@@ -1,22 +1,14 @@
import { AxiosError, RawAxiosRequestHeaders } from "axios"; import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service";
import { SecretKeyEncoding } from "@app/db/schemas";
import { request } from "@app/lib/config/request";
import { crypto } from "@app/lib/crypto/cryptography";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TAuditLogStreamDALFactory } from "../audit-log-stream/audit-log-stream-dal";
import { providerSpecificPayload } from "../audit-log-stream/audit-log-stream-fns";
import { LogStreamHeaders } from "../audit-log-stream/audit-log-stream-types";
import { TLicenseServiceFactory } from "../license/license-service"; import { TLicenseServiceFactory } from "../license/license-service";
import { TAuditLogDALFactory } from "./audit-log-dal"; import { TAuditLogDALFactory } from "./audit-log-dal";
import { TCreateAuditLogDTO } from "./audit-log-types"; import { TCreateAuditLogDTO } from "./audit-log-types";
type TAuditLogQueueServiceFactoryDep = { type TAuditLogQueueServiceFactoryDep = {
auditLogDAL: TAuditLogDALFactory; auditLogDAL: TAuditLogDALFactory;
auditLogStreamDAL: Pick<TAuditLogStreamDALFactory, "find">; auditLogStreamService: Pick<TAuditLogStreamServiceFactory, "streamLog">;
queueService: TQueueServiceFactory; queueService: TQueueServiceFactory;
projectDAL: Pick<TProjectDALFactory, "findById">; projectDAL: Pick<TProjectDALFactory, "findById">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
@@ -35,7 +27,7 @@ export const auditLogQueueServiceFactory = async ({
queueService, queueService,
projectDAL, projectDAL,
licenseService, licenseService,
auditLogStreamDAL auditLogStreamService
}: TAuditLogQueueServiceFactoryDep): Promise<TAuditLogQueueServiceFactory> => { }: TAuditLogQueueServiceFactoryDep): Promise<TAuditLogQueueServiceFactory> => {
const pushToLog = async (data: TCreateAuditLogDTO) => { const pushToLog = async (data: TCreateAuditLogDTO) => {
await queueService.queue<QueueName.AuditLog>(QueueName.AuditLog, QueueJobs.AuditLog, data, { await queueService.queue<QueueName.AuditLog>(QueueName.AuditLog, QueueJobs.AuditLog, data, {
@@ -86,60 +78,7 @@ export const auditLogQueueServiceFactory = async ({
userAgentType userAgentType
}); });
const logStreams = orgId ? await auditLogStreamDAL.find({ orgId }) : []; await auditLogStreamService.streamLog(orgId, auditLog);
await Promise.allSettled(
logStreams.map(
async ({
url,
encryptedHeadersTag,
encryptedHeadersIV,
encryptedHeadersKeyEncoding,
encryptedHeadersCiphertext
}) => {
const streamHeaders =
encryptedHeadersIV && encryptedHeadersCiphertext && encryptedHeadersTag
? (JSON.parse(
crypto
.encryption()
.symmetric()
.decryptWithRootEncryptionKey({
keyEncoding: encryptedHeadersKeyEncoding as SecretKeyEncoding,
iv: encryptedHeadersIV,
tag: encryptedHeadersTag,
ciphertext: encryptedHeadersCiphertext
})
) as LogStreamHeaders[])
: [];
const headers: RawAxiosRequestHeaders = { "Content-Type": "application/json" };
if (streamHeaders.length)
streamHeaders.forEach(({ key, value }) => {
headers[key] = value;
});
try {
const response = await request.post(
url,
{ ...providerSpecificPayload(url), ...auditLog },
{
headers,
// request timeout
timeout: AUDIT_LOG_STREAM_TIMEOUT,
// connection timeout
signal: AbortSignal.timeout(AUDIT_LOG_STREAM_TIMEOUT)
}
);
return response;
} catch (error) {
logger.error(
`Failed to stream audit log [url=${url}] for org [orgId=${orgId}] [error=${(error as AxiosError).message}]`
);
return error;
}
}
)
);
} }
}); });
@@ -30,7 +30,7 @@ const generateUsername = (usernameTemplate?: string | null, identity?: { name: s
export const CassandraProvider = (): TDynamicProviderFns => { export const CassandraProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretCassandraSchema.parseAsync(inputs); const providerInputs = await DynamicSecretCassandraSchema.parseAsync(inputs);
const hostIps = await Promise.all( await Promise.all(
providerInputs.host providerInputs.host
.split(",") .split(",")
.filter(Boolean) .filter(Boolean)
@@ -48,10 +48,10 @@ export const CassandraProvider = (): TDynamicProviderFns => {
allowedExpressions: (val) => ["username"].includes(val) allowedExpressions: (val) => ["username"].includes(val)
}); });
return { ...providerInputs, hostIps }; return { ...providerInputs };
}; };
const $getClient = async (providerInputs: z.infer<typeof DynamicSecretCassandraSchema> & { hostIps: string[] }) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretCassandraSchema>) => {
const sslOptions = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined; const sslOptions = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined;
const client = new cassandra.Client({ const client = new cassandra.Client({
sslOptions, sslOptions,
@@ -64,7 +64,7 @@ export const CassandraProvider = (): TDynamicProviderFns => {
}, },
keyspace: providerInputs.keyspace, keyspace: providerInputs.keyspace,
localDataCenter: providerInputs?.localDataCenter, localDataCenter: providerInputs?.localDataCenter,
contactPoints: providerInputs.hostIps contactPoints: providerInputs.host.split(",")
}); });
return client; return client;
}; };
@@ -28,14 +28,14 @@ const generateUsername = (usernameTemplate?: string | null, identity?: { name: s
export const ElasticSearchProvider = (): TDynamicProviderFns => { export const ElasticSearchProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretElasticSearchSchema.parseAsync(inputs); const providerInputs = await DynamicSecretElasticSearchSchema.parseAsync(inputs);
const [hostIp] = await verifyHostInputValidity(providerInputs.host); await verifyHostInputValidity(providerInputs.host);
return { ...providerInputs, hostIp }; return { ...providerInputs };
}; };
const $getClient = async (providerInputs: z.infer<typeof DynamicSecretElasticSearchSchema> & { hostIp: string }) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretElasticSearchSchema>) => {
const connection = new ElasticSearchClient({ const connection = new ElasticSearchClient({
node: { node: {
url: new URL(`${providerInputs.hostIp}:${providerInputs.port}`), url: new URL(`${providerInputs.host}:${providerInputs.port}`),
...(providerInputs.ca && { ...(providerInputs.ca && {
ssl: { ssl: {
rejectUnauthorized: false, rejectUnauthorized: false,
@@ -28,15 +28,15 @@ const generateUsername = (usernameTemplate?: string | null, identity?: { name: s
export const MongoDBProvider = (): TDynamicProviderFns => { export const MongoDBProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretMongoDBSchema.parseAsync(inputs); const providerInputs = await DynamicSecretMongoDBSchema.parseAsync(inputs);
const [hostIp] = await verifyHostInputValidity(providerInputs.host); await verifyHostInputValidity(providerInputs.host);
return { ...providerInputs, hostIp }; return { ...providerInputs };
}; };
const $getClient = async (providerInputs: z.infer<typeof DynamicSecretMongoDBSchema> & { hostIp: string }) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretMongoDBSchema>) => {
const isSrv = !providerInputs.port; const isSrv = !providerInputs.port;
const uri = isSrv const uri = isSrv
? `mongodb+srv://${providerInputs.hostIp}` ? `mongodb+srv://${providerInputs.host}`
: `mongodb://${providerInputs.hostIp}:${providerInputs.port}`; : `mongodb://${providerInputs.host}:${providerInputs.port}`;
const client = new MongoClient(uri, { const client = new MongoClient(uri, {
auth: { auth: {
@@ -87,13 +87,13 @@ async function deleteRabbitMqUser({ axiosInstance, usernameToDelete }: TDeleteRa
export const RabbitMqProvider = (): TDynamicProviderFns => { export const RabbitMqProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretRabbitMqSchema.parseAsync(inputs); const providerInputs = await DynamicSecretRabbitMqSchema.parseAsync(inputs);
const [hostIp] = await verifyHostInputValidity(providerInputs.host); await verifyHostInputValidity(providerInputs.host);
return { ...providerInputs, hostIp }; return { ...providerInputs };
}; };
const $getClient = async (providerInputs: z.infer<typeof DynamicSecretRabbitMqSchema> & { hostIp: string }) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretRabbitMqSchema>) => {
const axiosInstance = axios.create({ const axiosInstance = axios.create({
baseURL: `${providerInputs.hostIp}:${providerInputs.port}/api`, baseURL: `${providerInputs.host}:${providerInputs.port}/api`,
auth: { auth: {
username: providerInputs.username, username: providerInputs.username,
password: providerInputs.password password: providerInputs.password
@@ -36,7 +36,7 @@ export const SapAseProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretSapAseSchema.parseAsync(inputs); const providerInputs = await DynamicSecretSapAseSchema.parseAsync(inputs);
const [hostIp] = await verifyHostInputValidity(providerInputs.host); await verifyHostInputValidity(providerInputs.host);
validateHandlebarTemplate("SAP ASE creation", providerInputs.creationStatement, { validateHandlebarTemplate("SAP ASE creation", providerInputs.creationStatement, {
allowedExpressions: (val) => ["username", "password"].includes(val) allowedExpressions: (val) => ["username", "password"].includes(val)
}); });
@@ -45,16 +45,13 @@ export const SapAseProvider = (): TDynamicProviderFns => {
allowedExpressions: (val) => ["username"].includes(val) allowedExpressions: (val) => ["username"].includes(val)
}); });
} }
return { ...providerInputs, hostIp }; return { ...providerInputs };
}; };
const $getClient = async ( const $getClient = async (providerInputs: z.infer<typeof DynamicSecretSapAseSchema>, useMaster?: boolean) => {
providerInputs: z.infer<typeof DynamicSecretSapAseSchema> & { hostIp: string },
useMaster?: boolean
) => {
const connectionString = const connectionString =
`DRIVER={FreeTDS};` + `DRIVER={FreeTDS};` +
`SERVER=${providerInputs.hostIp};` + `SERVER=${providerInputs.host};` +
`PORT=${providerInputs.port};` + `PORT=${providerInputs.port};` +
`DATABASE=${useMaster ? "master" : providerInputs.database};` + `DATABASE=${useMaster ? "master" : providerInputs.database};` +
`UID=${providerInputs.username};` + `UID=${providerInputs.username};` +
@@ -37,7 +37,7 @@ export const SapHanaProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretSapHanaSchema.parseAsync(inputs); const providerInputs = await DynamicSecretSapHanaSchema.parseAsync(inputs);
const [hostIp] = await verifyHostInputValidity(providerInputs.host); await verifyHostInputValidity(providerInputs.host);
validateHandlebarTemplate("SAP Hana creation", providerInputs.creationStatement, { validateHandlebarTemplate("SAP Hana creation", providerInputs.creationStatement, {
allowedExpressions: (val) => ["username", "password", "expiration"].includes(val) allowedExpressions: (val) => ["username", "password", "expiration"].includes(val)
}); });
@@ -49,12 +49,12 @@ export const SapHanaProvider = (): TDynamicProviderFns => {
validateHandlebarTemplate("SAP Hana revoke", providerInputs.revocationStatement, { validateHandlebarTemplate("SAP Hana revoke", providerInputs.revocationStatement, {
allowedExpressions: (val) => ["username"].includes(val) allowedExpressions: (val) => ["username"].includes(val)
}); });
return { ...providerInputs, hostIp }; return { ...providerInputs };
}; };
const $getClient = async (providerInputs: z.infer<typeof DynamicSecretSapHanaSchema> & { hostIp: string }) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretSapHanaSchema>) => {
const client = hdb.createClient({ const client = hdb.createClient({
host: providerInputs.hostIp, host: providerInputs.host,
port: providerInputs.port, port: providerInputs.port,
user: providerInputs.username, user: providerInputs.username,
password: providerInputs.password, password: providerInputs.password,
@@ -150,8 +150,10 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO)
return { ...providerInputs, hostIp }; return { ...providerInputs, hostIp };
}; };
const $getClient = async (providerInputs: z.infer<typeof DynamicSecretSqlDBSchema>) => { const $getClient = async (providerInputs: z.infer<typeof DynamicSecretSqlDBSchema> & { hostIp: string }) => {
const ssl = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined; const ssl = providerInputs.ca
? { rejectUnauthorized: false, ca: providerInputs.ca, servername: providerInputs.host }
: undefined;
const isMsSQLClient = providerInputs.client === SqlProviders.MsSQL; const isMsSQLClient = providerInputs.client === SqlProviders.MsSQL;
const db = knex({ const db = knex({
@@ -159,7 +161,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO)
connection: { connection: {
database: providerInputs.database, database: providerInputs.database,
port: providerInputs.port, port: providerInputs.port,
host: providerInputs.host, host: providerInputs.client === SqlProviders.Postgres ? providerInputs.hostIp : providerInputs.host,
user: providerInputs.username, user: providerInputs.username,
password: providerInputs.password, password: providerInputs.password,
ssl, ssl,
@@ -209,8 +211,8 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO)
const validateConnection = async (inputs: unknown) => { const validateConnection = async (inputs: unknown) => {
const providerInputs = await validateProviderInputs(inputs); const providerInputs = await validateProviderInputs(inputs);
let isConnected = false; let isConnected = false;
const gatewayCallback = async (host = providerInputs.hostIp, port = providerInputs.port) => { const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => {
const db = await $getClient({ ...providerInputs, port, host }); const db = await $getClient({ ...providerInputs, port, host, hostIp: providerInputs.hostIp });
// oracle needs from keyword // oracle needs from keyword
const testStatement = providerInputs.client === SqlProviders.Oracle ? "SELECT 1 FROM DUAL" : "SELECT 1"; const testStatement = providerInputs.client === SqlProviders.Oracle ? "SELECT 1 FROM DUAL" : "SELECT 1";
@@ -1,11 +1,11 @@
import Redis from "ioredis"; import { Cluster, Redis } from "ioredis";
import { z } from "zod"; import { z } from "zod";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { BusEventSchema, TopicName } from "./types"; import { BusEventSchema, TopicName } from "./types";
export const eventBusFactory = (redis: Redis) => { export const eventBusFactory = (redis: Redis | Cluster) => {
const publisher = redis.duplicate(); const publisher = redis.duplicate();
// Duplicate the publisher to create a subscriber. // Duplicate the publisher to create a subscriber.
// This is necessary because Redis does not allow a single connection to both publish and subscribe. // This is necessary because Redis does not allow a single connection to both publish and subscribe.
@@ -1,6 +1,6 @@
/* eslint-disable no-continue */ /* eslint-disable no-continue */
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import Redis from "ioredis"; import { Cluster, Redis } from "ioredis";
import { KeyStorePrefixes } from "@app/keystore/keystore"; import { KeyStorePrefixes } from "@app/keystore/keystore";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
@@ -12,7 +12,7 @@ import { BusEvent, RegisteredEvent } from "./types";
const AUTH_REFRESH_INTERVAL = 60 * 1000; const AUTH_REFRESH_INTERVAL = 60 * 1000;
const HEART_BEAT_INTERVAL = 15 * 1000; const HEART_BEAT_INTERVAL = 15 * 1000;
export const sseServiceFactory = (bus: TEventBusService, redis: Redis) => { export const sseServiceFactory = (bus: TEventBusService, redis: Redis | Cluster) => {
const clients = new Set<EventStreamClient>(); const clients = new Set<EventStreamClient>();
const heartbeatInterval = setInterval(() => { const heartbeatInterval = setInterval(() => {
@@ -3,7 +3,7 @@ import { Readable } from "node:stream";
import { MongoAbility, PureAbility } from "@casl/ability"; import { MongoAbility, PureAbility } from "@casl/ability";
import { MongoQuery } from "@ucast/mongo2js"; import { MongoQuery } from "@ucast/mongo2js";
import Redis from "ioredis"; import { Cluster, Redis } from "ioredis";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { ProjectType } from "@app/db/schemas"; import { ProjectType } from "@app/db/schemas";
@@ -65,7 +65,7 @@ export type EventStreamClient = {
matcher: PureAbility; matcher: PureAbility;
}; };
export function createEventStreamClient(redis: Redis, options: IEventStreamClientOpts): EventStreamClient { export function createEventStreamClient(redis: Redis | Cluster, options: IEventStreamClientOpts): EventStreamClient {
const rules = options.registered.map((r) => { const rules = options.registered.map((r) => {
const secretPath = r.conditions?.secretPath; const secretPath = r.conditions?.secretPath;
const hasConditions = r.conditions?.environmentSlug || r.conditions?.secretPath; const hasConditions = r.conditions?.environmentSlug || r.conditions?.secretPath;
@@ -323,6 +323,8 @@ export const licenseServiceFactory = ({
}); });
} }
await updateSubscriptionOrgMemberCount(orgId);
const { const {
data: { url } data: { url }
} = await licenseServerCloudApi.request.post( } = await licenseServerCloudApi.request.post(
@@ -722,6 +724,16 @@ export const licenseServiceFactory = ({
await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId));
}; };
const getCustomerId = () => {
if (!selfHostedLicense) return "unknown";
return selfHostedLicense?.customerId;
};
const getLicenseId = () => {
if (!selfHostedLicense) return "unknown";
return selfHostedLicense?.licenseId;
};
return { return {
generateOrgCustomerId, generateOrgCustomerId,
removeOrgCustomer, removeOrgCustomer,
@@ -736,6 +748,8 @@ export const licenseServiceFactory = ({
return onPremFeatures; return onPremFeatures;
}, },
getPlan, getPlan,
getCustomerId,
getLicenseId,
invalidateGetPlan, invalidateGetPlan,
updateSubscriptionOrgMemberCount, updateSubscriptionOrgMemberCount,
refreshPlan, refreshPlan,
@@ -59,7 +59,7 @@ type TSecretReplicationServiceFactoryDep = {
TSecretVersionV2DALFactory, TSecretVersionV2DALFactory,
"find" | "insertMany" | "update" | "findLatestVersionMany" "find" | "insertMany" | "update" | "findLatestVersionMany"
>; >;
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "updateById" | "findByFolderIds">; secretImportDAL: Pick<TSecretImportDALFactory, "find" | "updateById" | "findByFolderIds" | "findByIds">;
folderDAL: Pick< folderDAL: Pick<
TSecretFolderDALFactory, TSecretFolderDALFactory,
"findSecretPathByFolderIds" | "findBySecretPath" | "create" | "findOne" | "findByManySecretPath" "findSecretPathByFolderIds" | "findBySecretPath" | "create" | "findOne" | "findByManySecretPath"
+49 -14
View File
@@ -3,6 +3,7 @@ import { pgAdvisoryLockHashText } from "@app/lib/crypto/hashtext";
import { applyJitter } from "@app/lib/dates"; import { applyJitter } from "@app/lib/dates";
import { delay as delayMs } from "@app/lib/delay"; import { delay as delayMs } from "@app/lib/delay";
import { ExecutionResult, Redlock, Settings } from "@app/lib/red-lock"; import { ExecutionResult, Redlock, Settings } from "@app/lib/red-lock";
import { Redis, Cluster } from "ioredis";
export const PgSqlLock = { export const PgSqlLock = {
BootUpMigration: 2023, BootUpMigration: 2023,
@@ -38,6 +39,7 @@ export const KeyStorePrefixes = {
SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) => SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) =>
`sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const, `sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const,
SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const, SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const,
AppConnectionConcurrentJobs: (connectionId: string) => `app-connection-concurrency-${connectionId}` as const,
SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const, SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const,
SecretScanningLock: (dataSourceId: string, resourceExternalId: string) => SecretScanningLock: (dataSourceId: string, resourceExternalId: string) =>
`secret-scanning-v2-mutex-${dataSourceId}-${resourceExternalId}` as const, `secret-scanning-v2-mutex-${dataSourceId}-${resourceExternalId}` as const,
@@ -101,30 +103,57 @@ export type TKeyStoreFactory = {
getKeysByPattern: (pattern: string, limit?: number) => Promise<string[]>; getKeysByPattern: (pattern: string, limit?: number) => Promise<string[]>;
}; };
export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFactory => { const pickPrimaryOrSecondaryRedis = (primary: Redis | Cluster, secondaries?: Array<Redis | Cluster>) => {
const redis = buildRedisFromConfig(redisConfigKeys); if (!secondaries || !secondaries.length) return primary;
const redisLock = new Redlock([redis], { retryCount: 2, retryDelay: 200 }); const selectedReplica = secondaries[Math.floor(Math.random() * secondaries.length)];
return selectedReplica;
};
interface TKeyStoreFactoryDTO extends TRedisConfigKeys {
REDIS_READ_REPLICAS?: { host: string; port: number }[];
}
export const keyStoreFactory = (redisConfigKeys: TKeyStoreFactoryDTO): TKeyStoreFactory => {
const primaryRedis = buildRedisFromConfig(redisConfigKeys);
const redisReadReplicas = redisConfigKeys.REDIS_READ_REPLICAS?.map((el) => {
if (redisConfigKeys.REDIS_URL) {
const primaryNode = new URL(redisConfigKeys?.REDIS_URL);
primaryNode.hostname = el.host;
primaryNode.port = String(el.port);
return buildRedisFromConfig({ ...redisConfigKeys, REDIS_URL: primaryNode.toString() });
}
if (redisConfigKeys.REDIS_SENTINEL_HOSTS) {
return buildRedisFromConfig({ ...redisConfigKeys, REDIS_SENTINEL_HOSTS: [el] });
}
return buildRedisFromConfig({ ...redisConfigKeys, REDIS_CLUSTER_HOSTS: [el] });
});
const redisLock = new Redlock([primaryRedis], { retryCount: 2, retryDelay: 200 });
const setItem = async (key: string, value: string | number | Buffer, prefix?: string) => const setItem = async (key: string, value: string | number | Buffer, prefix?: string) =>
redis.set(prefix ? `${prefix}:${key}` : key, value); primaryRedis.set(prefix ? `${prefix}:${key}` : key, value);
const getItem = async (key: string, prefix?: string) => redis.get(prefix ? `${prefix}:${key}` : key); const getItem = async (key: string, prefix?: string) =>
pickPrimaryOrSecondaryRedis(primaryRedis, redisReadReplicas).get(prefix ? `${prefix}:${key}` : key);
const getItems = async (keys: string[], prefix?: string) => const getItems = async (keys: string[], prefix?: string) =>
redis.mget(keys.map((key) => (prefix ? `${prefix}:${key}` : key))); pickPrimaryOrSecondaryRedis(primaryRedis, redisReadReplicas).mget(
keys.map((key) => (prefix ? `${prefix}:${key}` : key))
);
const setItemWithExpiry = async ( const setItemWithExpiry = async (
key: string, key: string,
expiryInSeconds: number | string, expiryInSeconds: number | string,
value: string | number | Buffer, value: string | number | Buffer,
prefix?: string prefix?: string
) => redis.set(prefix ? `${prefix}:${key}` : key, value, "EX", expiryInSeconds); ) => primaryRedis.set(prefix ? `${prefix}:${key}` : key, value, "EX", expiryInSeconds);
const deleteItem = async (key: string) => redis.del(key); const deleteItem = async (key: string) => primaryRedis.del(key);
const deleteItemsByKeyIn = async (keys: string[]) => { const deleteItemsByKeyIn = async (keys: string[]) => {
if (keys.length === 0) return 0; if (keys.length === 0) return 0;
return redis.del(keys); return primaryRedis.del(keys);
}; };
const deleteItems = async ({ pattern, batchSize = 500, delay = 1500, jitter = 200 }: TDeleteItems) => { const deleteItems = async ({ pattern, batchSize = 500, delay = 1500, jitter = 200 }: TDeleteItems) => {
@@ -134,12 +163,12 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac
do { do {
// Await in loop is needed so that Redis is not overwhelmed // Await in loop is needed so that Redis is not overwhelmed
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const [nextCursor, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 1000); // Count should be 1000 - 5000 for prod loads const [nextCursor, keys] = await primaryRedis.scan(cursor, "MATCH", pattern, "COUNT", 1000); // Count should be 1000 - 5000 for prod loads
cursor = nextCursor; cursor = nextCursor;
for (let i = 0; i < keys.length; i += batchSize) { for (let i = 0; i < keys.length; i += batchSize) {
const batch = keys.slice(i, i + batchSize); const batch = keys.slice(i, i + batchSize);
const pipeline = redis.pipeline(); const pipeline = primaryRedis.pipeline();
for (const key of batch) { for (const key of batch) {
pipeline.unlink(key); pipeline.unlink(key);
} }
@@ -155,9 +184,9 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac
return totalDeleted; return totalDeleted;
}; };
const incrementBy = async (key: string, value: number) => redis.incrby(key, value); const incrementBy = async (key: string, value: number) => primaryRedis.incrby(key, value);
const setExpiry = async (key: string, expiryInSeconds: number) => redis.expire(key, expiryInSeconds); const setExpiry = async (key: string, expiryInSeconds: number) => primaryRedis.expire(key, expiryInSeconds);
const waitTillReady = async ({ const waitTillReady = async ({
key, key,
@@ -188,7 +217,13 @@ export const keyStoreFactory = (redisConfigKeys: TRedisConfigKeys): TKeyStoreFac
do { do {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const [nextCursor, keys] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 1000); const [nextCursor, keys] = await pickPrimaryOrSecondaryRedis(primaryRedis, redisReadReplicas).scan(
cursor,
"MATCH",
pattern,
"COUNT",
1000
);
cursor = nextCursor; cursor = nextCursor;
allKeys.push(...keys); allKeys.push(...keys);
+41 -3
View File
@@ -37,6 +37,8 @@ const envSchema = z
.default("false") .default("false")
.transform((el) => el === "true"), .transform((el) => el === "true"),
REDIS_URL: zpStr(z.string().optional()), REDIS_URL: zpStr(z.string().optional()),
REDIS_USERNAME: zpStr(z.string().optional()),
REDIS_PASSWORD: zpStr(z.string().optional()),
REDIS_SENTINEL_HOSTS: zpStr( REDIS_SENTINEL_HOSTS: zpStr(
z z
.string() .string()
@@ -49,6 +51,28 @@ const envSchema = z
REDIS_SENTINEL_ENABLE_TLS: zodStrBool.optional().describe("Whether to use TLS/SSL for Redis Sentinel connection"), REDIS_SENTINEL_ENABLE_TLS: zodStrBool.optional().describe("Whether to use TLS/SSL for Redis Sentinel connection"),
REDIS_SENTINEL_USERNAME: zpStr(z.string().optional().describe("Authentication username for Redis Sentinel")), REDIS_SENTINEL_USERNAME: zpStr(z.string().optional().describe("Authentication username for Redis Sentinel")),
REDIS_SENTINEL_PASSWORD: zpStr(z.string().optional().describe("Authentication password for Redis Sentinel")), REDIS_SENTINEL_PASSWORD: zpStr(z.string().optional().describe("Authentication password for Redis Sentinel")),
REDIS_CLUSTER_HOSTS: zpStr(
z
.string()
.optional()
.describe("Comma-separated list of Redis Cluster host:port pairs. Eg: 192.168.65.254:6379,192.168.65.254:6380")
),
REDIS_READ_REPLICAS: zpStr(
z
.string()
.optional()
.describe(
"Comma-separated list of Redis read replicas host:port pairs. Eg: 192.168.65.254:6379,192.168.65.254:6380"
)
),
REDIS_CLUSTER_ENABLE_TLS: z
.enum(["true", "false"])
.default("false")
.transform((el) => el === "true"),
REDIS_CLUSTER_AWS_ELASTICACHE_DNS_LOOKUP_MODE: z
.enum(["true", "false"])
.default("false")
.transform((el) => el === "true"),
HOST: zpStr(z.string().default("localhost")), HOST: zpStr(z.string().default("localhost")),
DB_CONNECTION_URI: zpStr(z.string().describe("Postgres database connection string")).default( DB_CONNECTION_URI: zpStr(z.string().describe("Postgres database connection string")).default(
`postgresql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}` `postgresql://${process.env.DB_USER}:${process.env.DB_PASSWORD}@${process.env.DB_HOST}:${process.env.DB_PORT}/${process.env.DB_NAME}`
@@ -218,6 +242,8 @@ const envSchema = z
), ),
PARAMS_FOLDER_SECRET_DETECTION_ENTROPY: z.coerce.number().optional().default(3.7), PARAMS_FOLDER_SECRET_DETECTION_ENTROPY: z.coerce.number().optional().default(3.7),
INFISICAL_PRIMARY_INSTANCE_URL: zpStr(z.string().optional()),
// HSM // HSM
HSM_LIB_PATH: zpStr(z.string().optional()), HSM_LIB_PATH: zpStr(z.string().optional()),
HSM_PIN: zpStr(z.string().optional()), HSM_PIN: zpStr(z.string().optional()),
@@ -335,8 +361,8 @@ const envSchema = z
"Either ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY must be defined." "Either ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY must be defined."
) )
.refine( .refine(
(data) => Boolean(data.REDIS_URL) || Boolean(data.REDIS_SENTINEL_HOSTS), (data) => Boolean(data.REDIS_URL) || Boolean(data.REDIS_SENTINEL_HOSTS) || Boolean(data.REDIS_CLUSTER_HOSTS),
"Either REDIS_URL or REDIS_SENTINEL_HOSTS must be defined." "Either REDIS_URL, REDIS_SENTINEL_HOSTS or REDIS_CLUSTER_HOSTS must be defined."
) )
.transform((data) => ({ .transform((data) => ({
...data, ...data,
@@ -346,7 +372,7 @@ const envSchema = z
: undefined, : undefined,
isCloud: Boolean(data.LICENSE_SERVER_KEY), isCloud: Boolean(data.LICENSE_SERVER_KEY),
isSmtpConfigured: Boolean(data.SMTP_HOST), isSmtpConfigured: Boolean(data.SMTP_HOST),
isRedisConfigured: Boolean(data.REDIS_URL || data.REDIS_SENTINEL_HOSTS), isRedisConfigured: Boolean(data.REDIS_URL || data.REDIS_SENTINEL_HOSTS || data.REDIS_CLUSTER_HOSTS),
isDevelopmentMode: data.NODE_ENV === "development", isDevelopmentMode: data.NODE_ENV === "development",
isTestMode: data.NODE_ENV === "test", isTestMode: data.NODE_ENV === "test",
isRotationDevelopmentMode: isRotationDevelopmentMode:
@@ -361,6 +387,18 @@ const envSchema = z
const [host, port] = el.trim().split(":"); const [host, port] = el.trim().split(":");
return { host: host.trim(), port: Number(port.trim()) }; return { host: host.trim(), port: Number(port.trim()) };
}), }),
REDIS_CLUSTER_HOSTS: data.REDIS_CLUSTER_HOSTS?.trim()
?.split(",")
.map((el) => {
const [host, port] = el.trim().split(":");
return { host: host.trim(), port: Number(port.trim()) };
}),
REDIS_READ_REPLICAS: data.REDIS_READ_REPLICAS?.trim()
?.split(",")
.map((el) => {
const [host, port] = el.trim().split(":");
return { host: host.trim(), port: Number(port.trim()) };
}),
isSecretScanningConfigured: isSecretScanningConfigured:
Boolean(data.SECRET_SCANNING_GIT_APP_ID) && Boolean(data.SECRET_SCANNING_GIT_APP_ID) &&
Boolean(data.SECRET_SCANNING_PRIVATE_KEY) && Boolean(data.SECRET_SCANNING_PRIVATE_KEY) &&
+24 -1
View File
@@ -2,6 +2,14 @@ import { Redis } from "ioredis";
export type TRedisConfigKeys = Partial<{ export type TRedisConfigKeys = Partial<{
REDIS_URL: string; REDIS_URL: string;
REDIS_USERNAME: string;
REDIS_PASSWORD: string;
REDIS_CLUSTER_HOSTS: { host: string; port: number }[];
REDIS_CLUSTER_ENABLE_TLS: boolean;
// ref: https://github.com/redis/ioredis?tab=readme-ov-file#special-note-aws-elasticache-clusters-with-tls
REDIS_CLUSTER_AWS_ELASTICACHE_DNS_LOOKUP_MODE: boolean;
REDIS_SENTINEL_HOSTS: { host: string; port: number }[]; REDIS_SENTINEL_HOSTS: { host: string; port: number }[];
REDIS_SENTINEL_MASTER_NAME: string; REDIS_SENTINEL_MASTER_NAME: string;
REDIS_SENTINEL_ENABLE_TLS: boolean; REDIS_SENTINEL_ENABLE_TLS: boolean;
@@ -12,6 +20,19 @@ export type TRedisConfigKeys = Partial<{
export const buildRedisFromConfig = (cfg: TRedisConfigKeys) => { export const buildRedisFromConfig = (cfg: TRedisConfigKeys) => {
if (cfg.REDIS_URL) return new Redis(cfg.REDIS_URL, { maxRetriesPerRequest: null }); if (cfg.REDIS_URL) return new Redis(cfg.REDIS_URL, { maxRetriesPerRequest: null });
if (cfg.REDIS_CLUSTER_HOSTS) {
return new Redis.Cluster(cfg.REDIS_CLUSTER_HOSTS, {
dnsLookup: cfg.REDIS_CLUSTER_AWS_ELASTICACHE_DNS_LOOKUP_MODE
? (address, callback) => callback(null, address)
: undefined,
redisOptions: {
username: cfg.REDIS_USERNAME,
password: cfg.REDIS_PASSWORD,
tls: cfg?.REDIS_CLUSTER_ENABLE_TLS ? {} : undefined
}
});
}
return new Redis({ return new Redis({
// refine at tope will catch this case // refine at tope will catch this case
sentinels: cfg.REDIS_SENTINEL_HOSTS!, sentinels: cfg.REDIS_SENTINEL_HOSTS!,
@@ -19,6 +40,8 @@ export const buildRedisFromConfig = (cfg: TRedisConfigKeys) => {
maxRetriesPerRequest: null, maxRetriesPerRequest: null,
sentinelUsername: cfg.REDIS_SENTINEL_USERNAME, sentinelUsername: cfg.REDIS_SENTINEL_USERNAME,
sentinelPassword: cfg.REDIS_SENTINEL_PASSWORD, sentinelPassword: cfg.REDIS_SENTINEL_PASSWORD,
enableTLSForSentinelMode: cfg.REDIS_SENTINEL_ENABLE_TLS enableTLSForSentinelMode: cfg.REDIS_SENTINEL_ENABLE_TLS,
username: cfg.REDIS_USERNAME,
password: cfg.REDIS_PASSWORD
}); });
}; };
@@ -250,8 +250,11 @@ const cryptographyFactory = () => {
}; };
}; };
const encryptWithRootEncryptionKey = (data: string) => { const encryptWithRootEncryptionKey = (
const appCfg = getConfig(); data: string,
appCfgOverride?: Pick<TEnvConfig, "ROOT_ENCRYPTION_KEY" | "ENCRYPTION_KEY">
) => {
const appCfg = appCfgOverride || getConfig();
const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY; const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY;
const encryptionKey = appCfg.ENCRYPTION_KEY; const encryptionKey = appCfg.ENCRYPTION_KEY;
+13 -2
View File
@@ -23,6 +23,7 @@ import { logger } from "@app/lib/logger";
import { QueueWorkerProfile } from "@app/lib/types"; import { QueueWorkerProfile } from "@app/lib/types";
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
import { ExternalPlatforms } from "@app/services/external-migration/external-migration-types"; import { ExternalPlatforms } from "@app/services/external-migration/external-migration-types";
import { TCreateUserNotificationDTO } from "@app/services/notification/notification-types";
import { import {
TFailedIntegrationSyncEmailsPayload, TFailedIntegrationSyncEmailsPayload,
TIntegrationSyncPayload, TIntegrationSyncPayload,
@@ -67,7 +68,8 @@ export enum QueueName {
SecretScanningV2 = "secret-scanning-v2", SecretScanningV2 = "secret-scanning-v2",
TelemetryAggregatedEvents = "telemetry-aggregated-events", TelemetryAggregatedEvents = "telemetry-aggregated-events",
DailyReminders = "daily-reminders", DailyReminders = "daily-reminders",
SecretReminderMigration = "secret-reminder-migration" SecretReminderMigration = "secret-reminder-migration",
UserNotification = "user-notification"
} }
export enum QueueJobs { export enum QueueJobs {
@@ -109,7 +111,8 @@ export enum QueueJobs {
PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal", PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal",
TelemetryAggregatedEvents = "telemetry-aggregated-events", TelemetryAggregatedEvents = "telemetry-aggregated-events",
DailyReminders = "daily-reminders", DailyReminders = "daily-reminders",
SecretReminderMigration = "secret-reminder-migration" SecretReminderMigration = "secret-reminder-migration",
UserNotification = "user-notification-job"
} }
export type TQueueJobTypes = { export type TQueueJobTypes = {
@@ -313,6 +316,10 @@ export type TQueueJobTypes = {
name: QueueJobs.TelemetryAggregatedEvents; name: QueueJobs.TelemetryAggregatedEvents;
payload: undefined; payload: undefined;
}; };
[QueueName.UserNotification]: {
name: QueueJobs.UserNotification;
payload: { notifications: TCreateUserNotificationDTO[] };
};
}; };
const SECRET_SCANNING_JOBS = [ const SECRET_SCANNING_JOBS = [
@@ -415,6 +422,7 @@ export const queueServiceFactory = (
redisCfg: TRedisConfigKeys, redisCfg: TRedisConfigKeys,
{ dbConnectionUrl, dbRootCert }: { dbConnectionUrl: string; dbRootCert?: string } { dbConnectionUrl, dbRootCert }: { dbConnectionUrl: string; dbRootCert?: string }
): TQueueServiceFactory => { ): TQueueServiceFactory => {
const isClusterMode = Boolean(redisCfg?.REDIS_CLUSTER_HOSTS);
const connection = buildRedisFromConfig(redisCfg); const connection = buildRedisFromConfig(redisCfg);
const queueContainer = {} as Record< const queueContainer = {} as Record<
QueueName, QueueName,
@@ -457,6 +465,8 @@ export const queueServiceFactory = (
} }
queueContainer[name] = new Queue(name as string, { queueContainer[name] = new Queue(name as string, {
// ref: docs.bullmq.io/bull/patterns/redis-cluster
prefix: isClusterMode ? `{${name}}` : undefined,
...queueSettings, ...queueSettings,
...(crypto.isFipsModeEnabled() ...(crypto.isFipsModeEnabled()
? { ? {
@@ -472,6 +482,7 @@ export const queueServiceFactory = (
const appCfg = getConfig(); const appCfg = getConfig();
if (appCfg.QUEUE_WORKERS_ENABLED && isQueueEnabled(name)) { if (appCfg.QUEUE_WORKERS_ENABLED && isQueueEnabled(name)) {
workerContainer[name] = new Worker(name, jobFn, { workerContainer[name] = new Worker(name, jobFn, {
prefix: isClusterMode ? `{${name}}` : undefined,
...queueSettings, ...queueSettings,
...(crypto.isFipsModeEnabled() ...(crypto.isFipsModeEnabled()
? { ? {
+3 -2
View File
@@ -12,7 +12,7 @@ import type { FastifyRateLimitOptions } from "@fastify/rate-limit";
import ratelimiter from "@fastify/rate-limit"; import ratelimiter from "@fastify/rate-limit";
import { fastifyRequestContext } from "@fastify/request-context"; import { fastifyRequestContext } from "@fastify/request-context";
import fastify from "fastify"; import fastify from "fastify";
import { Redis } from "ioredis"; import { Cluster, Redis } from "ioredis";
import { Knex } from "knex"; import { Knex } from "knex";
import { HsmModule } from "@app/ee/services/hsm/hsm-types"; import { HsmModule } from "@app/ee/services/hsm/hsm-types";
@@ -43,7 +43,7 @@ type TMain = {
queue: TQueueServiceFactory; queue: TQueueServiceFactory;
keyStore: TKeyStoreFactory; keyStore: TKeyStoreFactory;
hsmModule: HsmModule; hsmModule: HsmModule;
redis: Redis; redis: Redis | Cluster;
envConfig: TEnvConfig; envConfig: TEnvConfig;
superAdminDAL: TSuperAdminDALFactory; superAdminDAL: TSuperAdminDALFactory;
}; };
@@ -76,6 +76,7 @@ export const main = async ({
server.setValidatorCompiler(validatorCompiler); server.setValidatorCompiler(validatorCompiler);
server.setSerializerCompiler(serializerCompiler); server.setSerializerCompiler(serializerCompiler);
// @ts-expect-error akhilmhdh: even on setting it fastify as Redis | Cluster it's throwing error
server.decorate("redis", redis); server.decorate("redis", redis);
server.addContentTypeParser("application/scim+json", { parseAs: "string" }, (_, body, done) => { server.addContentTypeParser("application/scim+json", { parseAs: "string" }, (_, body, done) => {
try { try {
+107 -100
View File
@@ -107,110 +107,117 @@ export const extractAuth = async (req: FastifyRequest, jwtSecret: string) => {
}; };
// ! Important: You can only 100% count on the `req.permission.orgId` field being present when the auth method is Identity Access Token (Machine Identity). // ! Important: You can only 100% count on the `req.permission.orgId` field being present when the auth method is Identity Access Token (Machine Identity).
export const injectIdentity = fp(async (server: FastifyZodProvider) => { export const injectIdentity = fp(
server.decorateRequest("auth", null); async (server: FastifyZodProvider, opt: { shouldForwardWritesToPrimaryInstance?: boolean }) => {
server.addHook("onRequest", async (req) => { server.decorateRequest("auth", null);
const appCfg = getConfig(); server.decorateRequest("shouldForwardWritesToPrimaryInstance", Boolean(opt.shouldForwardWritesToPrimaryInstance));
server.addHook("onRequest", async (req) => {
const appCfg = getConfig();
if (req.url.includes(".well-known/est") || req.url.includes("/api/v3/auth/")) { if (opt.shouldForwardWritesToPrimaryInstance && req.method !== "GET") {
return; return;
}
// Authentication is handled on a route-level here.
if (req.url.includes("/api/v1/workflow-integrations/microsoft-teams/message-endpoint")) {
return;
}
const { authMode, token, actor } = await extractAuth(req, appCfg.AUTH_SECRET);
if (!authMode) return;
switch (authMode) {
case AuthMode.JWT: {
const { user, tokenVersionId, orgId } = await server.services.authToken.fnValidateJwtIdentity(token);
requestContext.set("orgId", orgId);
req.auth = {
authMode: AuthMode.JWT,
user,
userId: user.id,
tokenVersionId,
actor,
orgId: orgId as string,
authMethod: token.authMethod,
isMfaVerified: token.isMfaVerified,
token
};
break;
} }
case AuthMode.IDENTITY_ACCESS_TOKEN: {
const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(token, req.realIp); if (req.url.includes(".well-known/est") || req.url.includes("/api/v3/auth/")) {
const serverCfg = await getServerCfg(); return;
requestContext.set("orgId", identity.orgId); }
req.auth = {
authMode: AuthMode.IDENTITY_ACCESS_TOKEN, // Authentication is handled on a route-level here.
actor, if (req.url.includes("/api/v1/workflow-integrations/microsoft-teams/message-endpoint")) {
orgId: identity.orgId, return;
identityId: identity.identityId, }
identityName: identity.name,
authMethod: null, const { authMode, token, actor } = await extractAuth(req, appCfg.AUTH_SECRET);
isInstanceAdmin: serverCfg?.adminIdentityIds?.includes(identity.identityId),
token if (!authMode) return;
};
if (token?.identityAuth?.oidc) { switch (authMode) {
requestContext.set("identityAuthInfo", { case AuthMode.JWT: {
identityId: identity.identityId, const { user, tokenVersionId, orgId } = await server.services.authToken.fnValidateJwtIdentity(token);
oidc: token?.identityAuth?.oidc requestContext.set("orgId", orgId);
}); req.auth = {
authMode: AuthMode.JWT,
user,
userId: user.id,
tokenVersionId,
actor,
orgId: orgId as string,
authMethod: token.authMethod,
isMfaVerified: token.isMfaVerified,
token
};
break;
} }
if (token?.identityAuth?.kubernetes) { case AuthMode.IDENTITY_ACCESS_TOKEN: {
requestContext.set("identityAuthInfo", { const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(token, req.realIp);
const serverCfg = await getServerCfg();
requestContext.set("orgId", identity.orgId);
req.auth = {
authMode: AuthMode.IDENTITY_ACCESS_TOKEN,
actor,
orgId: identity.orgId,
identityId: identity.identityId, identityId: identity.identityId,
kubernetes: token?.identityAuth?.kubernetes identityName: identity.name,
}); authMethod: null,
isInstanceAdmin: serverCfg?.adminIdentityIds?.includes(identity.identityId),
token
};
if (token?.identityAuth?.oidc) {
requestContext.set("identityAuthInfo", {
identityId: identity.identityId,
oidc: token?.identityAuth?.oidc
});
}
if (token?.identityAuth?.kubernetes) {
requestContext.set("identityAuthInfo", {
identityId: identity.identityId,
kubernetes: token?.identityAuth?.kubernetes
});
}
if (token?.identityAuth?.aws) {
requestContext.set("identityAuthInfo", {
identityId: identity.identityId,
aws: token?.identityAuth?.aws
});
}
break;
} }
if (token?.identityAuth?.aws) { case AuthMode.SERVICE_TOKEN: {
requestContext.set("identityAuthInfo", { const serviceToken = await server.services.serviceToken.fnValidateServiceToken(token);
identityId: identity.identityId, requestContext.set("orgId", serviceToken.orgId);
aws: token?.identityAuth?.aws req.auth = {
}); orgId: serviceToken.orgId,
authMode: AuthMode.SERVICE_TOKEN as const,
serviceToken,
serviceTokenId: serviceToken.id,
actor,
authMethod: null,
token
};
break;
} }
break; case AuthMode.API_KEY: {
const user = await server.services.apiKey.fnValidateApiKey(token as string);
req.auth = {
authMode: AuthMode.API_KEY as const,
userId: user.id,
actor,
user,
orgId: "API_KEY", // We set the orgId to an arbitrary value, since we can't link an API key to a specific org. We have to deprecate API keys soon!
authMethod: null,
token: token as string
};
break;
}
case AuthMode.SCIM_TOKEN: {
const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token);
requestContext.set("orgId", orgId);
req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId, authMethod: null };
break;
}
default:
throw new BadRequestError({ message: "Invalid token strategy provided" });
} }
case AuthMode.SERVICE_TOKEN: { });
const serviceToken = await server.services.serviceToken.fnValidateServiceToken(token); }
requestContext.set("orgId", serviceToken.orgId); );
req.auth = {
orgId: serviceToken.orgId,
authMode: AuthMode.SERVICE_TOKEN as const,
serviceToken,
serviceTokenId: serviceToken.id,
actor,
authMethod: null,
token
};
break;
}
case AuthMode.API_KEY: {
const user = await server.services.apiKey.fnValidateApiKey(token as string);
req.auth = {
authMode: AuthMode.API_KEY as const,
userId: user.id,
actor,
user,
orgId: "API_KEY", // We set the orgId to an arbitrary value, since we can't link an API key to a specific org. We have to deprecate API keys soon!
authMethod: null,
token: token as string
};
break;
}
case AuthMode.SCIM_TOKEN: {
const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token);
requestContext.set("orgId", orgId);
req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, scimTokenId, orgId, authMethod: null };
break;
}
default:
throw new BadRequestError({ message: "Invalid token strategy provided" });
}
});
});
@@ -10,6 +10,10 @@ interface TAuthOptions {
export const verifyAuth = export const verifyAuth =
<T extends FastifyRequest>(authStrategies: AuthMode[], options: TAuthOptions = { requireOrg: true }) => <T extends FastifyRequest>(authStrategies: AuthMode[], options: TAuthOptions = { requireOrg: true }) =>
(req: T, _res: FastifyReply, done: HookHandlerDoneFunction) => { (req: T, _res: FastifyReply, done: HookHandlerDoneFunction) => {
if (req.shouldForwardWritesToPrimaryInstance && req.method !== "GET") {
return done();
}
if (!Array.isArray(authStrategies)) throw new Error("Auth strategy must be array"); if (!Array.isArray(authStrategies)) throw new Error("Auth strategy must be array");
if (!req.auth) throw new UnauthorizedError({ message: "Token missing" }); if (!req.auth) throw new UnauthorizedError({ message: "Token missing" });
@@ -0,0 +1,14 @@
import replyFrom from "@fastify/reply-from";
import fp from "fastify-plugin";
export const forwardWritesToPrimary = fp(async (server, opt: { primaryUrl: string }) => {
await server.register(replyFrom, {
base: opt.primaryUrl
});
server.addHook("preValidation", async (request, reply) => {
if (request.url.startsWith("/api") && ["POST", "PUT", "DELETE", "PATCH"].includes(request.method)) {
return reply.from(request.url);
}
});
});
+45 -11
View File
@@ -218,6 +218,11 @@ import { kmsServiceFactory } from "@app/services/kms/kms-service";
import { microsoftTeamsIntegrationDALFactory } from "@app/services/microsoft-teams/microsoft-teams-integration-dal"; import { microsoftTeamsIntegrationDALFactory } from "@app/services/microsoft-teams/microsoft-teams-integration-dal";
import { microsoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; import { microsoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service";
import { projectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; import { projectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal";
import { notificationQueueServiceFactory } from "@app/services/notification/notification-queue";
import { notificationServiceFactory } from "@app/services/notification/notification-service";
import { userNotificationDALFactory } from "@app/services/notification/user-notification-dal";
import { offlineUsageReportDALFactory } from "@app/services/offline-usage-report/offline-usage-report-dal";
import { offlineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service";
import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal";
import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; import { orgBotDALFactory } from "@app/services/org/org-bot-dal";
import { orgDALFactory } from "@app/services/org/org-dal"; import { orgDALFactory } from "@app/services/org/org-dal";
@@ -310,6 +315,7 @@ import { injectAssumePrivilege } from "../plugins/auth/inject-assume-privilege";
import { injectIdentity } from "../plugins/auth/inject-identity"; import { injectIdentity } from "../plugins/auth/inject-identity";
import { injectPermission } from "../plugins/auth/inject-permission"; import { injectPermission } from "../plugins/auth/inject-permission";
import { injectRateLimits } from "../plugins/inject-rate-limits"; import { injectRateLimits } from "../plugins/inject-rate-limits";
import { forwardWritesToPrimary } from "../plugins/primary-forwarding-mode";
import { registerV1Routes } from "./v1"; import { registerV1Routes } from "./v1";
import { initializeOauthConfigSync } from "./v1/sso-router"; import { initializeOauthConfigSync } from "./v1/sso-router";
import { registerV2Routes } from "./v2"; import { registerV2Routes } from "./v2";
@@ -385,6 +391,7 @@ export const registerRoutes = async (
const reminderRecipientDAL = reminderRecipientDALFactory(db); const reminderRecipientDAL = reminderRecipientDALFactory(db);
const integrationDAL = integrationDALFactory(db); const integrationDAL = integrationDALFactory(db);
const offlineUsageReportDAL = offlineUsageReportDALFactory(db);
const integrationAuthDAL = integrationAuthDALFactory(db); const integrationAuthDAL = integrationAuthDALFactory(db);
const webhookDAL = webhookDALFactory(db); const webhookDAL = webhookDALFactory(db);
const serviceTokenDAL = serviceTokenDALFactory(db); const serviceTokenDAL = serviceTokenDALFactory(db);
@@ -418,6 +425,7 @@ export const registerRoutes = async (
const telemetryDAL = telemetryDALFactory(db); const telemetryDAL = telemetryDALFactory(db);
const appConnectionDAL = appConnectionDALFactory(db); const appConnectionDAL = appConnectionDALFactory(db);
const secretSyncDAL = secretSyncDALFactory(db, folderDAL); const secretSyncDAL = secretSyncDALFactory(db, folderDAL);
const userNotificationDAL = userNotificationDALFactory(db);
// ee db layer ops // ee db layer ops
const permissionDAL = permissionDALFactory(db); const permissionDAL = permissionDALFactory(db);
@@ -555,20 +563,29 @@ export const registerRoutes = async (
permissionService permissionService
}); });
const auditLogStreamService = auditLogStreamServiceFactory({
licenseService,
permissionService,
auditLogStreamDAL,
kmsService
});
const auditLogQueue = await auditLogQueueServiceFactory({ const auditLogQueue = await auditLogQueueServiceFactory({
auditLogDAL, auditLogDAL,
queueService, queueService,
projectDAL, projectDAL,
licenseService, licenseService,
auditLogStreamDAL auditLogStreamService
}); });
const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue }); const notificationQueue = await notificationQueueServiceFactory({
const auditLogStreamService = auditLogStreamServiceFactory({ userNotificationDAL,
licenseService, queueService
permissionService,
auditLogStreamDAL
}); });
const notificationService = notificationServiceFactory({ notificationQueue, userNotificationDAL });
const auditLogService = auditLogServiceFactory({ auditLogDAL, permissionService, auditLogQueue });
const secretApprovalPolicyService = secretApprovalPolicyServiceFactory({ const secretApprovalPolicyService = secretApprovalPolicyServiceFactory({
projectEnvDAL, projectEnvDAL,
secretApprovalPolicyApproverDAL: sapApproverDAL, secretApprovalPolicyApproverDAL: sapApproverDAL,
@@ -842,7 +859,14 @@ export const registerRoutes = async (
licenseService, licenseService,
kmsService, kmsService,
microsoftTeamsService, microsoftTeamsService,
invalidateCacheQueue invalidateCacheQueue,
smtpService,
tokenService
});
const offlineUsageReportService = offlineUsageReportServiceFactory({
offlineUsageReportDAL,
licenseService
}); });
const orgAdminService = orgAdminServiceFactory({ const orgAdminService = orgAdminServiceFactory({
@@ -1369,7 +1393,8 @@ export const registerRoutes = async (
kmsService, kmsService,
groupDAL, groupDAL,
microsoftTeamsService, microsoftTeamsService,
projectMicrosoftTeamsConfigDAL projectMicrosoftTeamsConfigDAL,
notificationService
}); });
const secretReplicationService = secretReplicationServiceFactory({ const secretReplicationService = secretReplicationServiceFactory({
@@ -1668,7 +1693,8 @@ export const registerRoutes = async (
secretVersionV2DAL: secretVersionV2BridgeDAL, secretVersionV2DAL: secretVersionV2BridgeDAL,
identityUniversalAuthClientSecretDAL: identityUaClientSecretDAL, identityUniversalAuthClientSecretDAL: identityUaClientSecretDAL,
serviceTokenService, serviceTokenService,
orgService orgService,
userNotificationDAL
}); });
const dailyReminderQueueService = dailyReminderQueueServiceFactory({ const dailyReminderQueueService = dailyReminderQueueServiceFactory({
@@ -2004,6 +2030,7 @@ export const registerRoutes = async (
apiKey: apiKeyService, apiKey: apiKeyService,
authToken: tokenService, authToken: tokenService,
superAdmin: superAdminService, superAdmin: superAdminService,
offlineUsageReport: offlineUsageReportService,
project: projectService, project: projectService,
projectMembership: projectMembershipService, projectMembership: projectMembershipService,
projectKey: projectKeyService, projectKey: projectKeyService,
@@ -2097,7 +2124,8 @@ export const registerRoutes = async (
secretScanningV2: secretScanningV2Service, secretScanningV2: secretScanningV2Service,
reminder: reminderService, reminder: reminderService,
bus: eventBusService, bus: eventBusService,
sse: sseService sse: sseService,
notification: notificationService
}); });
const cronJobs: CronJob[] = []; const cronJobs: CronJob[] = [];
@@ -2136,8 +2164,14 @@ export const registerRoutes = async (
user: userDAL, user: userDAL,
kmipClient: kmipClientDAL kmipClient: kmipClientDAL
}); });
const shouldForwardWritesToPrimaryInstance = Boolean(envConfig.INFISICAL_PRIMARY_INSTANCE_URL);
if (shouldForwardWritesToPrimaryInstance) {
logger.info(`Infisical primary instance is configured: ${envConfig.INFISICAL_PRIMARY_INSTANCE_URL}`);
await server.register(injectIdentity, { userDAL, serviceTokenDAL }); await server.register(forwardWritesToPrimary, { primaryUrl: envConfig.INFISICAL_PRIMARY_INSTANCE_URL as string });
}
await server.register(injectIdentity, { shouldForwardWritesToPrimaryInstance });
await server.register(injectAssumePrivilege); await server.register(injectAssumePrivilege);
await server.register(injectPermission); await server.register(injectPermission);
await server.register(injectRateLimits); await server.register(injectRateLimits);
@@ -246,13 +246,6 @@ export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({
metadata: ResourceMetadataSchema.optional() metadata: ResourceMetadataSchema.optional()
}); });
export const SanitizedAuditLogStreamSchema = z.object({
id: z.string(),
url: z.string(),
createdAt: z.date(),
updatedAt: z.date()
});
export const SanitizedProjectSchema = ProjectsSchema.pick({ export const SanitizedProjectSchema = ProjectsSchema.pick({
id: true, id: true,
name: true, name: true,
+124 -3
View File
@@ -13,6 +13,7 @@ import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { addAuthOriginDomainCookie } from "@app/server/lib/cookie"; import { addAuthOriginDomainCookie } from "@app/server/lib/cookie";
import { GenericResourceNameSchema } from "@app/server/lib/schemas";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin"; import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -53,7 +54,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
defaultAuthOrgAuthMethod: z.string().nullish(), defaultAuthOrgAuthMethod: z.string().nullish(),
isSecretScanningDisabled: z.boolean(), isSecretScanningDisabled: z.boolean(),
kubernetesAutoFetchServiceAccountToken: z.boolean(), kubernetesAutoFetchServiceAccountToken: z.boolean(),
paramsFolderSecretDetectionEnabled: z.boolean() paramsFolderSecretDetectionEnabled: z.boolean(),
isOfflineUsageReportsEnabled: z.boolean()
}) })
}) })
} }
@@ -69,7 +71,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
isMigrationModeOn: serverEnvs.MAINTENANCE_MODE, isMigrationModeOn: serverEnvs.MAINTENANCE_MODE,
isSecretScanningDisabled: serverEnvs.DISABLE_SECRET_SCANNING, isSecretScanningDisabled: serverEnvs.DISABLE_SECRET_SCANNING,
kubernetesAutoFetchServiceAccountToken: serverEnvs.KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN, kubernetesAutoFetchServiceAccountToken: serverEnvs.KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN,
paramsFolderSecretDetectionEnabled: serverEnvs.PARAMS_FOLDER_SECRET_DETECTION_ENABLED paramsFolderSecretDetectionEnabled: serverEnvs.PARAMS_FOLDER_SECRET_DETECTION_ENABLED,
isOfflineUsageReportsEnabled: !!serverEnvs.LICENSE_KEY_OFFLINE
} }
}; };
} }
@@ -215,7 +218,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
}), }),
membershipId: z.string(), membershipId: z.string(),
role: z.string(), role: z.string(),
roleId: z.string().nullish() roleId: z.string().nullish(),
status: z.string().nullish()
}) })
.array(), .array(),
projects: z projects: z
@@ -838,4 +842,121 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
}; };
} }
}); });
server.route({
method: "POST",
url: "/organization-management/organizations",
config: {
rateLimit: writeLimit
},
schema: {
body: z.object({
name: GenericResourceNameSchema,
inviteAdminEmails: z.string().email().array().min(1)
}),
response: {
200: z.object({
organization: OrganizationsSchema
})
}
},
onRequest: (req, res, done) => {
verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => {
verifySuperAdmin(req, res, done);
});
},
handler: async (req) => {
const organization = await server.services.superAdmin.createOrganization(req.body, req.permission);
return { organization };
}
});
server.route({
method: "POST",
url: "/organization-management/organizations/:organizationId/memberships/:membershipId/resend-invite",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
organizationId: z.string(),
membershipId: z.string()
}),
response: {
200: z.object({
organizationMembership: OrgMembershipsSchema
})
}
},
onRequest: (req, res, done) => {
verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => {
verifySuperAdmin(req, res, done);
});
},
handler: async (req) => {
const organizationMembership = await server.services.superAdmin.resendOrgInvite(req.params, req.permission);
return { organizationMembership };
}
});
server.route({
method: "POST",
url: "/organization-management/organizations/:organizationId/access",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
organizationId: z.string()
}),
response: {
200: z.object({
organizationMembership: OrgMembershipsSchema
})
}
},
onRequest: (req, res, done) => {
verifyAuth([AuthMode.JWT])(req, res, () => {
verifySuperAdmin(req, res, done);
});
},
handler: async (req) => {
const organizationMembership = await server.services.superAdmin.joinOrganization(
req.params.organizationId,
req.permission
);
return { organizationMembership };
}
});
server.route({
method: "POST",
url: "/usage-report/generate",
config: {
rateLimit: writeLimit
},
schema: {
response: {
200: z.object({
csvContent: z.string(),
signature: z.string(),
filename: z.string()
})
}
},
onRequest: (req, res, done) => {
verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => {
verifySuperAdmin(req, res, done);
});
},
handler: async () => {
const result = await server.services.offlineUsageReport.generateUsageReportCSV();
return {
csvContent: result.csvContent,
signature: result.signature,
filename: result.filename
};
}
});
}; };
+2
View File
@@ -33,6 +33,7 @@ import { registerIntegrationAuthRouter } from "./integration-auth-router";
import { registerIntegrationRouter } from "./integration-router"; import { registerIntegrationRouter } from "./integration-router";
import { registerInviteOrgRouter } from "./invite-org-router"; import { registerInviteOrgRouter } from "./invite-org-router";
import { registerMicrosoftTeamsRouter } from "./microsoft-teams-router"; import { registerMicrosoftTeamsRouter } from "./microsoft-teams-router";
import { registerNotificationRouter } from "./notification-router";
import { registerOrgAdminRouter } from "./org-admin-router"; import { registerOrgAdminRouter } from "./org-admin-router";
import { registerOrgRouter } from "./organization-router"; import { registerOrgRouter } from "./organization-router";
import { registerPasswordRouter } from "./password-router"; import { registerPasswordRouter } from "./password-router";
@@ -83,6 +84,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
await server.register(registerAdminRouter, { prefix: "/admin" }); await server.register(registerAdminRouter, { prefix: "/admin" });
await server.register(registerOrgAdminRouter, { prefix: "/organization-admin" }); await server.register(registerOrgAdminRouter, { prefix: "/organization-admin" });
await server.register(registerUserRouter, { prefix: "/user" }); await server.register(registerUserRouter, { prefix: "/user" });
await server.register(registerNotificationRouter, { prefix: "/notifications" });
await server.register(registerInviteOrgRouter, { prefix: "/invite-org" }); await server.register(registerInviteOrgRouter, { prefix: "/invite-org" });
await server.register(registerUserActionRouter, { prefix: "/user-action" }); await server.register(registerUserActionRouter, { prefix: "/user-action" });
await server.register(registerSecretImportRouter, { prefix: "/secret-imports" }); await server.register(registerSecretImportRouter, { prefix: "/secret-imports" });
@@ -0,0 +1,123 @@
import { z } from "zod";
import { UserNotificationsSchema } from "@app/db/schemas/user-notifications";
import { UnauthorizedError } from "@app/lib/errors";
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 registerNotificationRouter = async (server: FastifyZodProvider) => {
server.route({
url: "/user",
config: {
rateLimit: readLimit
},
method: "GET",
schema: {
response: {
200: z.object({
notifications: UserNotificationsSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
if (req.auth.authMode !== AuthMode.JWT) {
throw new UnauthorizedError({ message: "This endpoint can only be accessed by users" });
}
const notifications = await server.services.notification.listUserNotifications({
userId: req.auth.userId,
orgId: req.auth.orgId
});
return { notifications };
}
});
server.route({
url: "/user/:notificationId",
config: {
rateLimit: writeLimit
},
method: "DELETE",
schema: {
params: z.object({
notificationId: z.string()
}),
response: {
200: z.object({
notification: UserNotificationsSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
if (req.auth.authMode !== AuthMode.JWT) {
throw new UnauthorizedError({ message: "This endpoint can only be accessed by users" });
}
const notification = await server.services.notification.deleteUserNotification({
notificationId: req.params.notificationId,
userId: req.auth.userId
});
return { notification };
}
});
server.route({
url: "/user/:notificationId",
config: {
rateLimit: writeLimit
},
method: "PATCH",
schema: {
params: z.object({
notificationId: z.string()
}),
body: z.object({
isRead: z.boolean()
}),
response: {
200: z.object({
notification: UserNotificationsSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
if (req.auth.authMode !== AuthMode.JWT) {
throw new UnauthorizedError({ message: "This endpoint can only be accessed by users" });
}
const notification = await server.services.notification.updateUserNotification({
notificationId: req.params.notificationId,
userId: req.auth.userId,
...req.body
});
return { notification };
}
});
// Mark all user notifications as read
server.route({
url: "/user/mark-as-read",
config: {
rateLimit: writeLimit
},
method: "POST",
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
if (req.auth.authMode !== AuthMode.JWT) {
throw new UnauthorizedError({ message: "This endpoint can only be accessed by users" });
}
await server.services.notification.markUserNotificationsAsRead({
userId: req.auth.userId,
orgId: req.auth.orgId
});
}
});
};
@@ -2,10 +2,13 @@ import fastifyMultipart from "@fastify/multipart";
import { z } from "zod"; import { z } from "zod";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { VaultMappingType } from "@app/services/external-migration/external-migration-types"; import {
ExternalMigrationProviders,
VaultMappingType
} from "@app/services/external-migration/external-migration-types";
const MB25_IN_BYTES = 26214400; const MB25_IN_BYTES = 26214400;
@@ -81,4 +84,33 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
}); });
} }
}); });
server.route({
method: "GET",
url: "/custom-migration-enabled/:provider",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
provider: z.nativeEnum(ExternalMigrationProviders)
}),
response: {
200: z.object({
enabled: z.boolean()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const enabled = await server.services.migration.hasCustomVaultMigration({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
provider: req.params.provider
});
return { enabled };
}
});
}; };
@@ -56,7 +56,7 @@ const getConnectionConfig = ({
? { ? {
rejectUnauthorized: sslRejectUnauthorized, rejectUnauthorized: sslRejectUnauthorized,
ca: sslCertificate, ca: sslCertificate,
servername: host serverName: host
} }
: false : false
}; };
@@ -90,7 +90,7 @@ export const getSqlConnectionClient = async (appConnection: Pick<TSqlConnection,
connection: { connection: {
database, database,
port, port,
host, host: app === AppConnection.Postgres ? host : baseHost,
user: username, user: username,
password, password,
connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT,
@@ -135,7 +135,7 @@ export const executeWithPotentialGateway = async <T>(
}, },
{ {
protocol: GatewayProxyProtocol.Tcp, protocol: GatewayProxyProtocol.Tcp,
targetHost, targetHost: app === AppConnection.Postgres ? targetHost : credentials.host,
targetPort: credentials.port, targetPort: credentials.port,
relayHost, relayHost,
relayPort: Number(relayPort), relayPort: Number(relayPort),
@@ -453,23 +453,24 @@ export const authLoginServiceFactory = ({
const selectedOrg = await orgDAL.findById(organizationId); const selectedOrg = await orgDAL.findById(organizationId);
if (!selectedOrgMembership) {
throw new ForbiddenRequestError({
message: `User does not have access to the organization named ${selectedOrg?.name}`
});
}
// Check if authEnforced is true and the current auth method is not an enforced method // Check if authEnforced is true and the current auth method is not an enforced method
if ( if (
selectedOrg.authEnforced && selectedOrg.authEnforced &&
!isAuthMethodSaml(decodedToken.authMethod) && !isAuthMethodSaml(decodedToken.authMethod) &&
decodedToken.authMethod !== AuthMethod.OIDC decodedToken.authMethod !== AuthMethod.OIDC &&
!(selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin)
) { ) {
throw new BadRequestError({ throw new BadRequestError({
message: "Login with the auth method required by your organization." message: "Login with the auth method required by your organization."
}); });
} }
if (!selectedOrgMembership) {
throw new ForbiddenRequestError({
message: `User does not have access to the organization named ${selectedOrg?.name}`
});
}
if (selectedOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) { if (selectedOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) {
const canBypass = selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin; const canBypass = selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin;
@@ -1190,7 +1190,9 @@ export const internalCertificateAuthorityServiceFactory = ({
}); });
} }
collectionId = certificateTemplate.pkiCollectionId as string; if (!collectionId) {
collectionId = certificateTemplate.pkiCollectionId as string;
}
ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certificateTemplate.caId); ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certificateTemplate.caId);
} }
@@ -408,19 +408,123 @@ export const transformToInfisicalFormatNamespaceToProjects = (
}; };
}; };
export const transformToInfisicalFormatKeyVaultToProjectsCustomC1 = (vaultData: VaultData[]): InfisicalImportData => {
const projects: Array<{ name: string; id: string }> = [];
const environments: Array<{ name: string; id: string; projectId: string; envParentId?: string }> = [];
const folders: Array<{ id: string; name: string; environmentId: string; parentFolderId?: string }> = [];
const secrets: Array<{ id: string; name: string; environmentId: string; value: string; folderId?: string }> = [];
// track created entities to avoid duplicates
const projectMap = new Map<string, string>(); // team name -> projectId
const environmentMap = new Map<string, string>(); // team-name:envName -> environmentId
const folderMap = new Map<string, string>(); // team-name:envName:folderPath -> folderId
for (const data of vaultData) {
const { path, secretData } = data;
const pathParts = path.split("/").filter(Boolean);
if (pathParts.length < 2) {
// eslint-disable-next-line no-continue
continue;
}
// first level: environment (dev, prod, staging, etc.)
const environmentName = pathParts[0];
// second level: team name (team1, team2, etc.)
const teamName = pathParts[1];
// remaining parts: folder structure
const folderParts = pathParts.slice(2);
// create project (team) if if doesn't exist
if (!projectMap.has(teamName)) {
const projectId = uuidv4();
projectMap.set(teamName, projectId);
projects.push({
name: teamName,
id: projectId
});
}
const projectId = projectMap.get(teamName)!;
// create environment (dev, prod, etc.) for team
const envKey = `${teamName}:${environmentName}`;
if (!environmentMap.has(envKey)) {
const environmentId = uuidv4();
environmentMap.set(envKey, environmentId);
environments.push({
name: environmentName,
id: environmentId,
projectId
});
}
const environmentId = environmentMap.get(envKey)!;
// create folder structure for path segments
let currentFolderId: string | undefined;
let currentPath = "";
for (const folderName of folderParts) {
currentPath = currentPath ? `${currentPath}/${folderName}` : folderName;
const folderKey = `${teamName}:${environmentName}:${currentPath}`;
if (!folderMap.has(folderKey)) {
const folderId = uuidv4();
folderMap.set(folderKey, folderId);
folders.push({
id: folderId,
name: folderName,
environmentId,
parentFolderId: currentFolderId || environmentId
});
currentFolderId = folderId;
} else {
currentFolderId = folderMap.get(folderKey)!;
}
}
for (const [key, value] of Object.entries(secretData)) {
secrets.push({
id: uuidv4(),
name: key,
environmentId,
value: String(value),
folderId: currentFolderId
});
}
}
return {
projects,
environments,
folders,
secrets
};
};
// refer to internal doc for more details on which ID's belong to which orgs.
// when its a custom migration, then it doesn't matter which mapping type is used (as of now).
export const vaultMigrationTransformMappings: Record<
string,
(vaultData: VaultData[], mappingType: VaultMappingType) => InfisicalImportData
> = {
"68c57ab3-cea5-41fc-ae38-e156b10c14d2": transformToInfisicalFormatKeyVaultToProjectsCustomC1
} as const;
export const importVaultDataFn = async ( export const importVaultDataFn = async (
{ {
vaultAccessToken, vaultAccessToken,
vaultNamespace, vaultNamespace,
vaultUrl, vaultUrl,
mappingType, mappingType,
gatewayId gatewayId,
orgId
}: { }: {
vaultAccessToken: string; vaultAccessToken: string;
vaultNamespace?: string; vaultNamespace?: string;
vaultUrl: string; vaultUrl: string;
mappingType: VaultMappingType; mappingType: VaultMappingType;
gatewayId?: string; gatewayId?: string;
orgId: string;
}, },
{ gatewayService }: { gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId"> } { gatewayService }: { gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId"> }
) => { ) => {
@@ -432,6 +536,25 @@ export const importVaultDataFn = async (
}); });
} }
let transformFn: (vaultData: VaultData[], mappingType: VaultMappingType) => InfisicalImportData;
if (mappingType === VaultMappingType.Custom) {
transformFn = vaultMigrationTransformMappings[orgId];
if (!transformFn) {
throw new BadRequestError({
message: "Please contact our sales team to enable custom vault migrations."
});
}
} else {
transformFn = transformToInfisicalFormatNamespaceToProjects;
}
logger.info(
{ orgId, mappingType },
`[importVaultDataFn]: Running ${orgId in vaultMigrationTransformMappings ? "custom" : "default"} transform`
);
const vaultApi = vaultFactory(gatewayService); const vaultApi = vaultFactory(gatewayService);
const vaultData = await vaultApi.collectVaultData({ const vaultData = await vaultApi.collectVaultData({
@@ -441,7 +564,5 @@ export const importVaultDataFn = async (
gatewayId gatewayId
}); });
const infisicalData = transformToInfisicalFormatNamespaceToProjects(vaultData, mappingType); return transformFn(vaultData, mappingType);
return infisicalData;
}; };
@@ -5,9 +5,20 @@ import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
import { TUserDALFactory } from "../user/user-dal"; import { TUserDALFactory } from "../user/user-dal";
import { decryptEnvKeyDataFn, importVaultDataFn, parseEnvKeyDataFn } from "./external-migration-fns"; import {
decryptEnvKeyDataFn,
importVaultDataFn,
parseEnvKeyDataFn,
vaultMigrationTransformMappings
} from "./external-migration-fns";
import { TExternalMigrationQueueFactory } from "./external-migration-queue"; import { TExternalMigrationQueueFactory } from "./external-migration-queue";
import { ExternalPlatforms, TImportEnvKeyDataDTO, TImportVaultDataDTO } from "./external-migration-types"; import {
ExternalMigrationProviders,
ExternalPlatforms,
THasCustomVaultMigrationDTO,
TImportEnvKeyDataDTO,
TImportVaultDataDTO
} from "./external-migration-types";
type TExternalMigrationServiceFactoryDep = { type TExternalMigrationServiceFactoryDep = {
permissionService: TPermissionServiceFactory; permissionService: TPermissionServiceFactory;
@@ -101,7 +112,8 @@ export const externalMigrationServiceFactory = ({
vaultNamespace, vaultNamespace,
vaultUrl, vaultUrl,
mappingType, mappingType,
gatewayId gatewayId,
orgId: actorOrgId
}, },
{ {
gatewayService gatewayService
@@ -127,8 +139,37 @@ export const externalMigrationServiceFactory = ({
}); });
}; };
const hasCustomVaultMigration = async ({
actor,
actorId,
actorOrgId,
actorAuthMethod,
provider
}: THasCustomVaultMigrationDTO) => {
const { membership } = await permissionService.getOrgPermission(
actor,
actorId,
actorOrgId,
actorAuthMethod,
actorOrgId
);
if (membership.role !== OrgMembershipRole.Admin) {
throw new ForbiddenRequestError({ message: "Only admins can check custom migration status" });
}
if (provider !== ExternalMigrationProviders.Vault) {
throw new BadRequestError({
message: "Invalid provider. Vault is the only supported provider for custom migrations."
});
}
return actorOrgId in vaultMigrationTransformMappings;
};
return { return {
importEnvKeyData, importEnvKeyData,
importVaultData importVaultData,
hasCustomVaultMigration
}; };
}; };
@@ -4,7 +4,8 @@ import { ActorAuthMethod, ActorType } from "../auth/auth-type";
export enum VaultMappingType { export enum VaultMappingType {
Namespace = "namespace", Namespace = "namespace",
KeyVault = "key-vault" KeyVault = "key-vault",
Custom = "custom"
} }
export type InfisicalImportData = { export type InfisicalImportData = {
@@ -26,6 +27,10 @@ export type TImportEnvKeyDataDTO = {
encryptedJson: { nonce: string; data: string }; encryptedJson: { nonce: string; data: string };
} & Omit<TOrgPermission, "orgId">; } & Omit<TOrgPermission, "orgId">;
export type THasCustomVaultMigrationDTO = {
provider: ExternalMigrationProviders;
} & Omit<TOrgPermission, "orgId">;
export type TImportVaultDataDTO = { export type TImportVaultDataDTO = {
vaultAccessToken: string; vaultAccessToken: string;
vaultNamespace?: string; vaultNamespace?: string;
@@ -111,3 +116,8 @@ export enum ExternalPlatforms {
EnvKey = "EnvKey", EnvKey = "EnvKey",
Vault = "Vault" Vault = "Vault"
} }
export enum ExternalMigrationProviders {
Vault = "vault",
EnvKey = "env-key"
}
@@ -84,18 +84,20 @@ export const identityUaServiceFactory = ({
const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`; const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`;
let lock: Awaited<ReturnType<typeof keyStore.acquireLock>>; let lock: Awaited<ReturnType<typeof keyStore.acquireLock>> | undefined;
try { if (identityUa.lockoutEnabled) {
lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 500, { try {
retryCount: 3, lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 500, {
retryDelay: 300, retryCount: 3,
retryJitter: 100 retryDelay: 300,
}); retryJitter: 100
} catch (e) { });
logger.info( } catch (e) {
`identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]` logger.info(
); `identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]`
throw new RateLimitError({ message: "Rate limit exceeded" }); );
throw new RateLimitError({ message: "Failed to acquire lock: rate limit exceeded" });
}
} }
try { try {
@@ -257,7 +259,7 @@ export const identityUaServiceFactory = ({
...accessTokenTTLParams ...accessTokenTTLParams
}; };
} finally { } finally {
await lock.release(); if (lock) await lock.release();
} }
}; };
@@ -39,7 +39,7 @@ const getIntegrationSecretsV2 = async (
}, },
secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "find" | "findByFolderId">, secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "find" | "findByFolderId">,
folderDAL: Pick<TSecretFolderDALFactory, "findByManySecretPath">, folderDAL: Pick<TSecretFolderDALFactory, "findByManySecretPath">,
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds"> secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds" | "findByIds">
) => { ) => {
const content: Record<string, boolean> = {}; const content: Record<string, boolean> = {};
if (dto.depth > MAX_SYNC_SECRET_DEPTH) { if (dto.depth > MAX_SYNC_SECRET_DEPTH) {
@@ -300,7 +300,7 @@ export const deleteIntegrationSecrets = async ({
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">; projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "find" | "findByFolderId">; secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "find" | "findByFolderId">;
folderDAL: Pick<TSecretFolderDALFactory, "findByManySecretPath" | "findBySecretPath">; folderDAL: Pick<TSecretFolderDALFactory, "findByManySecretPath" | "findBySecretPath">;
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">; secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds" | "findByIds">;
secretDAL: Pick<TSecretDALFactory, "findByFolderId">; secretDAL: Pick<TSecretDALFactory, "findByFolderId">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}) => { }) => {
@@ -40,7 +40,7 @@ type TIntegrationServiceFactoryDep = {
projectBotService: TProjectBotServiceFactory; projectBotService: TProjectBotServiceFactory;
secretQueueService: Pick<TSecretQueueFactory, "syncIntegrations">; secretQueueService: Pick<TSecretQueueFactory, "syncIntegrations">;
secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "find" | "findByFolderId">; secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "find" | "findByFolderId">;
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">; secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds" | "findByIds">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
secretDAL: Pick<TSecretDALFactory, "findByFolderId">; secretDAL: Pick<TSecretDALFactory, "findByFolderId">;
}; };
@@ -0,0 +1,39 @@
import { QueueJobs, TQueueServiceFactory } from "@app/queue";
import { TCreateUserNotificationDTO } from "./notification-types";
import { TUserNotificationDALFactory } from "./user-notification-dal";
type TNotificationQueueServiceFactoryDep = {
userNotificationDAL: Pick<TUserNotificationDALFactory, "batchInsert">;
queueService: TQueueServiceFactory;
};
export type TNotificationQueueServiceFactory = {
pushUserNotifications: (data: TCreateUserNotificationDTO[]) => Promise<void>;
};
export const notificationQueueServiceFactory = async ({
userNotificationDAL,
queueService
}: TNotificationQueueServiceFactoryDep): Promise<TNotificationQueueServiceFactory> => {
const pushUserNotifications = async (data: TCreateUserNotificationDTO[]) => {
await queueService.queuePg(QueueJobs.UserNotification, { notifications: data });
};
await queueService.startPg(
QueueJobs.UserNotification,
async ([job]) => {
const { notifications } = job.data as { notifications: TCreateUserNotificationDTO[] };
await userNotificationDAL.batchInsert(notifications);
},
{
batchSize: 1,
workerCount: 2,
pollingIntervalSeconds: 1
}
);
return {
pushUserNotifications
};
};
@@ -0,0 +1,81 @@
import { NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { TNotificationQueueServiceFactory } from "./notification-queue";
import { TCreateUserNotificationDTO } from "./notification-types";
import { TUserNotificationDALFactory } from "./user-notification-dal";
type TNotificationServiceFactoryDep = {
notificationQueue: TNotificationQueueServiceFactory;
userNotificationDAL: TUserNotificationDALFactory;
};
export type TNotificationServiceFactory = ReturnType<typeof notificationServiceFactory>;
export const notificationServiceFactory = ({
notificationQueue,
userNotificationDAL
}: TNotificationServiceFactoryDep) => {
const listUserNotifications = async ({ userId, orgId }: { userId: string; orgId: string }) => {
const now = new Date();
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
const notifications = await userNotificationDAL.find({
userId,
orgId,
startDate: threeMonthsAgo.toISOString(),
endDate: now.toISOString()
});
return notifications;
};
const createUserNotifications = async (data: TCreateUserNotificationDTO[]) => {
return notificationQueue.pushUserNotifications(data);
};
const deleteUserNotification = async ({ userId, notificationId }: { userId: string; notificationId: string }) => {
if (!userId) throw new UnauthorizedError({ message: "Invalid userId" });
const deletedNotifications = await userNotificationDAL.delete({ id: notificationId, userId });
if (deletedNotifications.length <= 0) throw new NotFoundError({ message: "Notification not found" });
return deletedNotifications[0];
};
const markUserNotificationsAsRead = async ({ userId, orgId }: { userId: string; orgId: string }) => {
await userNotificationDAL.markAllNotificationsAsRead(userId, orgId);
};
const updateUserNotification = async ({
userId,
notificationId,
isRead
}: {
userId: string;
notificationId: string;
isRead: boolean;
}) => {
const [updatedNotification] = await userNotificationDAL.update(
{
id: notificationId,
userId
},
{
isRead
}
);
if (!updatedNotification) throw new NotFoundError({ message: "Notification not found" });
return updatedNotification;
};
return {
listUserNotifications,
createUserNotifications,
deleteUserNotification,
markUserNotificationsAsRead,
updateUserNotification
};
};
@@ -0,0 +1,15 @@
export enum NotificationType {
ACCESS_APPROVAL_REQUEST = "access-approval-request",
ACCESS_APPROVAL_REQUEST_UPDATED = "access-approval-request-updated"
}
export interface TCreateUserNotificationDTO {
userId: string;
// Adding an orgId will make the notification only show up when a user is in a certain org. Otherwise, it shows up in all orgs.
// Keep in mind that org-scoped links for a notification will break if orgId is missing and the user is in the wrong org
orgId?: string;
type: NotificationType;
title: string;
body?: string;
link?: string;
}
@@ -0,0 +1,130 @@
import knex from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError, GatewayTimeoutError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
import { logger } from "@app/lib/logger";
import { QueueName } from "@app/queue";
export type TUserNotificationDALFactory = ReturnType<typeof userNotificationDALFactory>;
const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
const PRUNE_BATCH_SIZE = 10000;
const MAX_RETRY_ON_FAILURE = 3;
export const userNotificationDALFactory = (db: TDbClient) => {
const notificationOrm = ormify(db, TableName.UserNotifications);
const find = async (
{
userId,
orgId,
startDate,
endDate,
limit = 1000,
offset = 0
}: {
userId: string;
orgId: string;
startDate: string;
endDate: string;
limit?: number;
offset?: number;
},
tx?: knex.Knex
) => {
try {
const docs = await (tx || db.replicaNode())(TableName.UserNotifications)
.where(`${TableName.UserNotifications}.userId`, userId)
.andWhere((qb) => {
void qb
.where(`${TableName.UserNotifications}.orgId`, orgId)
.orWhereNull(`${TableName.UserNotifications}.orgId`);
})
.whereRaw(`"${TableName.UserNotifications}"."createdAt" >= ?::timestamptz`, [startDate])
.andWhereRaw(`"${TableName.UserNotifications}"."createdAt" < ?::timestamptz`, [endDate])
.select(selectAllTableCols(TableName.UserNotifications))
.limit(limit)
.offset(offset)
.orderBy(`${TableName.UserNotifications}.createdAt`, "desc")
.timeout(1000 * 120); // 2 minutes timeout
return docs;
} catch (error) {
if (error instanceof knex.KnexTimeoutError) {
throw new GatewayTimeoutError({
error,
message: "Failed to fetch notifications due to timeout."
});
}
throw new DatabaseError({ error });
}
};
// delete all notifications older than 3 months
const pruneNotifications = async () => {
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
let deletedNotificationIds: { id: string }[] = [];
let numberOfRetryOnFailure = 0;
logger.info(`${QueueName.DailyResourceCleanUp}: prune notifications started`);
do {
try {
// eslint-disable-next-line no-await-in-loop
deletedNotificationIds = await db.transaction(async (trx) => {
await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`);
const findExpiredNotificationSubQuery = trx(TableName.UserNotifications)
.where("createdAt", "<", threeMonthsAgo)
.orderBy(`${TableName.UserNotifications}.createdAt`, "desc")
.select("id")
.limit(PRUNE_BATCH_SIZE);
// eslint-disable-next-line no-await-in-loop
const results = await trx(TableName.UserNotifications)
.whereIn("id", findExpiredNotificationSubQuery)
.del()
.returning("id");
return results;
});
numberOfRetryOnFailure = 0;
} catch (error) {
numberOfRetryOnFailure += 1;
deletedNotificationIds = [];
logger.error(error, "Failed to delete notification on pruning. Retrying...");
} finally {
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
}
} while (
deletedNotificationIds.length > 0 ||
(numberOfRetryOnFailure > 0 && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE)
);
if (numberOfRetryOnFailure >= MAX_RETRY_ON_FAILURE) {
logger.error(
`${QueueName.DailyResourceCleanUp}: prune notifications completed with persistent errors after ${MAX_RETRY_ON_FAILURE} retries. Some notifications might not have been pruned.`
);
} else {
logger.info(`${QueueName.DailyResourceCleanUp}: prune notifications completed`);
}
};
const markAllNotificationsAsRead = async (userId: string, orgId: string) => {
await db(TableName.UserNotifications)
.where({ userId })
.andWhere((qb) => {
void qb.where({ orgId }).orWhereNull("orgId");
})
.update({ isRead: true });
};
return { ...notificationOrm, pruneNotifications, find, markAllNotificationsAsRead };
};
@@ -0,0 +1,208 @@
import { TDbClient } from "@app/db";
import { ProjectType, TableName } from "@app/db/schemas";
export type TOfflineUsageReportDALFactory = ReturnType<typeof offlineUsageReportDALFactory>;
export const offlineUsageReportDALFactory = (db: TDbClient) => {
const getUserMetrics = async () => {
// Get total users and admin users
const userMetrics = (await db
.from(TableName.Users)
.select(
db.raw(
`
COUNT(*) as total_users,
COUNT(CASE WHEN "superAdmin" = true THEN 1 END) as admin_users
`
)
)
.where({ isGhost: false })
.first()) as { total_users: string; admin_users: string } | undefined;
// Get users by auth method
const authMethodStats = (await db
.from(TableName.Users)
.select(
db.raw(`
unnest("authMethods") as auth_method,
COUNT(*) as count
`)
)
.where({ isGhost: false })
.whereNotNull("authMethods")
.groupBy(db.raw('unnest("authMethods")'))) as Array<{ auth_method: string; count: string }>;
const usersByAuthMethod = authMethodStats.reduce(
(acc: Record<string, number>, row: { auth_method: string; count: string }) => {
acc[row.auth_method] = parseInt(row.count, 10);
return acc;
},
{} as Record<string, number>
);
return {
totalUsers: parseInt(userMetrics?.total_users || "0", 10),
adminUsers: parseInt(userMetrics?.admin_users || "0", 10),
usersByAuthMethod
};
};
const getMachineIdentityMetrics = async () => {
// Get total machine identities
const identityMetrics = (await db
.from(TableName.Identity)
.select(
db.raw(
`
COUNT(*) as total_identities
`
)
)
.first()) as { total_identities: string } | undefined;
// Get identities by auth method
const authMethodStats = (await db
.from(TableName.Identity)
.select("authMethod")
.count("* as count")
.whereNotNull("authMethod")
.groupBy("authMethod")) as Array<{ authMethod: string; count: string }>;
const machineIdentitiesByAuthMethod = authMethodStats.reduce(
(acc: Record<string, number>, row: { authMethod: string; count: string }) => {
acc[row.authMethod] = parseInt(row.count, 10);
return acc;
},
{} as Record<string, number>
);
return {
totalMachineIdentities: parseInt(identityMetrics?.total_identities || "0", 10),
machineIdentitiesByAuthMethod
};
};
const getProjectMetrics = async () => {
// Get total projects and projects by type
const projectMetrics = (await db
.from(TableName.Project)
.select("type")
.count("* as count")
.groupBy("type")) as Array<{ type: string; count: string }>;
const totalProjects = projectMetrics.reduce(
(sum, row: { type: string; count: string }) => sum + parseInt(row.count, 10),
0
);
const projectsByType = projectMetrics.reduce(
(acc: Record<string, number>, row: { type: string; count: string }) => {
acc[row.type] = parseInt(row.count, 10);
return acc;
},
{} as Record<string, number>
);
// Calculate average secrets per project
const secretsPerProject = (await db
.from(`${TableName.SecretV2} as s`)
.select("p.id as projectId")
.count("s.id as count")
.leftJoin(`${TableName.SecretFolder} as sf`, "s.folderId", "sf.id")
.leftJoin(`${TableName.Environment} as e`, "sf.envId", "e.id")
.leftJoin(`${TableName.Project} as p`, "e.projectId", "p.id")
.where("p.type", ProjectType.SecretManager)
.groupBy("p.id")
.whereNotNull("p.id")) as Array<{ projectId: string; count: string }>;
const averageSecretsPerProject =
secretsPerProject.length > 0
? secretsPerProject.reduce(
(sum, row: { projectId: string; count: string }) => sum + parseInt(row.count, 10),
0
) / secretsPerProject.length
: 0;
return {
totalProjects,
projectsByType,
averageSecretsPerProject: Math.round(averageSecretsPerProject * 100) / 100
};
};
const getSecretMetrics = async () => {
// Get total secrets
const totalSecretsResult = (await db.from(TableName.SecretV2).count("* as count").first()) as
| { count: string }
| undefined;
const totalSecrets = parseInt(totalSecretsResult?.count || "0", 10);
// Get secrets by project
const secretsByProject = (await db
.from(`${TableName.SecretV2} as s`)
.select("p.id as projectId", "p.name as projectName")
.count("s.id as secretCount")
.leftJoin(`${TableName.SecretFolder} as sf`, "s.folderId", "sf.id")
.leftJoin(`${TableName.Environment} as e`, "sf.envId", "e.id")
.leftJoin(`${TableName.Project} as p`, "e.projectId", "p.id")
.where("p.type", ProjectType.SecretManager)
.groupBy("p.id", "p.name")
.whereNotNull("p.id")) as Array<{ projectId: string; projectName: string; secretCount: string }>;
return {
totalSecrets,
secretsByProject: secretsByProject.map(
(row: { projectId: string; projectName: string; secretCount: string }) => ({
projectId: row.projectId,
projectName: row.projectName,
secretCount: parseInt(row.secretCount, 10)
})
)
};
};
const getSecretSyncMetrics = async () => {
const totalSecretSyncsResult = (await db.from(TableName.SecretSync).count("* as count").first()) as
| { count: string }
| undefined;
return {
totalSecretSyncs: parseInt(totalSecretSyncsResult?.count || "0", 10)
};
};
const getDynamicSecretMetrics = async () => {
const totalDynamicSecretsResult = (await db.from(TableName.DynamicSecret).count("* as count").first()) as
| { count: string }
| undefined;
return {
totalDynamicSecrets: parseInt(totalDynamicSecretsResult?.count || "0", 10)
};
};
const getSecretRotationMetrics = async () => {
// Check both v1 and v2 secret rotation tables
const [v1RotationsResult, v2RotationsResult] = await Promise.all([
db.from(TableName.SecretRotation).count("* as count").first() as Promise<{ count: string } | undefined>,
db.from(TableName.SecretRotationV2).count("* as count").first() as Promise<{ count: string } | undefined>
]);
const totalV1Rotations = parseInt(v1RotationsResult?.count || "0", 10);
const totalV2Rotations = parseInt(v2RotationsResult?.count || "0", 10);
return {
totalSecretRotations: totalV1Rotations + totalV2Rotations
};
};
return {
getUserMetrics,
getMachineIdentityMetrics,
getProjectMetrics,
getSecretMetrics,
getSecretSyncMetrics,
getDynamicSecretMetrics,
getSecretRotationMetrics
};
};
@@ -0,0 +1,133 @@
import crypto from "crypto";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors";
import { TOfflineUsageReportDALFactory } from "./offline-usage-report-dal";
type TOfflineUsageReportServiceFactoryDep = {
offlineUsageReportDAL: TOfflineUsageReportDALFactory;
licenseService: Pick<TLicenseServiceFactory, "getCustomerId" | "getLicenseId">;
};
export type TOfflineUsageReportServiceFactory = ReturnType<typeof offlineUsageReportServiceFactory>;
export const offlineUsageReportServiceFactory = ({
offlineUsageReportDAL,
licenseService
}: TOfflineUsageReportServiceFactoryDep) => {
const signReportContent = (content: string, licenseId: string): string => {
const contentHash = crypto.createHash("sha256").update(content).digest("hex");
const hmac = crypto.createHmac("sha256", licenseId);
hmac.update(contentHash);
return hmac.digest("hex");
};
const verifyReportContent = (content: string, signature: string, licenseId: string): boolean => {
const expectedSignature = signReportContent(content, licenseId);
return signature === expectedSignature;
};
const generateUsageReportCSV = async () => {
const cfg = getConfig();
if (!cfg.LICENSE_KEY_OFFLINE) {
throw new BadRequestError({
message: "Offline usage reports are not enabled. LICENSE_KEY_OFFLINE must be configured."
});
}
const customerId = licenseService.getCustomerId() as string;
const licenseId = licenseService.getLicenseId();
const [
userMetrics,
machineIdentityMetrics,
projectMetrics,
secretMetrics,
secretSyncMetrics,
dynamicSecretMetrics,
secretRotationMetrics
] = await Promise.all([
offlineUsageReportDAL.getUserMetrics(),
offlineUsageReportDAL.getMachineIdentityMetrics(),
offlineUsageReportDAL.getProjectMetrics(),
offlineUsageReportDAL.getSecretMetrics(),
offlineUsageReportDAL.getSecretSyncMetrics(),
offlineUsageReportDAL.getDynamicSecretMetrics(),
offlineUsageReportDAL.getSecretRotationMetrics()
]);
const headers = [
"Total Users",
"Admin Users",
"Total Identities",
"Total Projects",
"Total Secrets",
"Total Secret Syncs",
"Total Dynamic Secrets",
"Total Secret Rotations",
"Avg Secrets Per Project"
];
const allUserAuthMethods = Object.keys(userMetrics.usersByAuthMethod);
allUserAuthMethods.forEach((method) => {
headers.push(`Users Auth ${method}`);
});
const allIdentityAuthMethods = Object.keys(machineIdentityMetrics.machineIdentitiesByAuthMethod);
allIdentityAuthMethods.forEach((method) => {
headers.push(`Identities Auth ${method}`);
});
const allProjectTypes = Object.keys(projectMetrics.projectsByType);
allProjectTypes.forEach((type) => {
headers.push(`Projects ${type}`);
});
headers.push("Signature");
const dataRow: (string | number)[] = [
userMetrics.totalUsers,
userMetrics.adminUsers,
machineIdentityMetrics.totalMachineIdentities,
projectMetrics.totalProjects,
secretMetrics.totalSecrets,
secretSyncMetrics.totalSecretSyncs,
dynamicSecretMetrics.totalDynamicSecrets,
secretRotationMetrics.totalSecretRotations,
projectMetrics.averageSecretsPerProject
];
allUserAuthMethods.forEach((method) => {
dataRow.push(userMetrics.usersByAuthMethod[method] || 0);
});
allIdentityAuthMethods.forEach((method) => {
dataRow.push(machineIdentityMetrics.machineIdentitiesByAuthMethod[method] || 0);
});
allProjectTypes.forEach((type) => {
dataRow.push(projectMetrics.projectsByType[type] || 0);
});
const headersWithoutSignature = headers.slice(0, -1);
const contentWithoutSignature = [headersWithoutSignature.join(","), dataRow.join(",")].join("\n");
const signature = signReportContent(contentWithoutSignature, licenseId);
dataRow.push(signature);
const csvContent = [headers.join(","), dataRow.join(",")].join("\n");
return {
csvContent,
signature,
filename: `infisical-usage-report-${customerId}-${new Date().toISOString().split("T")[0]}.csv`
};
};
return {
generateUsageReportCSV,
verifyReportSignature: (csvContent: string, signature: string, licenseId: string) =>
verifyReportContent(csvContent, signature, licenseId)
};
};
@@ -0,0 +1,42 @@
export interface TUsageMetrics {
// User metrics
totalUsers: number;
usersByAuthMethod: Record<string, number>;
adminUsers: number;
// Machine identity metrics
totalMachineIdentities: number;
machineIdentitiesByAuthMethod: Record<string, number>;
// Project metrics
totalProjects: number;
projectsByType: Record<string, number>;
averageSecretsPerProject: number;
// Secret metrics
totalSecrets: number;
totalSecretSyncs: number;
totalDynamicSecrets: number;
totalSecretRotations: number;
}
export interface TUsageReportMetadata {
generatedAt: string;
instanceId: string;
reportVersion: string;
}
export interface TUsageReport {
metadata: TUsageReportMetadata;
metrics: TUsageMetrics;
signature?: string;
}
export interface TGenerateUsageReportDTO {
includeSignature?: boolean;
}
export interface TVerifyUsageReportDTO {
reportData: string;
signature: string;
}
+14 -1
View File
@@ -83,6 +83,7 @@ export const orgDALFactory = (db: TDbClient) => {
.select(db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId")) .select(db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId"))
.select(db.ref("role").withSchema(TableName.OrgMembership).as("orgMembershipRole")) .select(db.ref("role").withSchema(TableName.OrgMembership).as("orgMembershipRole"))
.select(db.ref("roleId").withSchema(TableName.OrgMembership).as("orgMembershipRoleId")) .select(db.ref("roleId").withSchema(TableName.OrgMembership).as("orgMembershipRoleId"))
.select(db.ref("status").withSchema(TableName.OrgMembership).as("orgMembershipStatus"))
.select(db.ref("name").withSchema(TableName.OrgRoles).as("orgMembershipRoleName")); .select(db.ref("name").withSchema(TableName.OrgRoles).as("orgMembershipRoleName"));
const formattedDocs = sqlNestRelationships({ const formattedDocs = sqlNestRelationships({
@@ -112,7 +113,8 @@ export const orgDALFactory = (db: TDbClient) => {
orgMembershipId, orgMembershipId,
orgMembershipRole, orgMembershipRole,
orgMembershipRoleName, orgMembershipRoleName,
orgMembershipRoleId orgMembershipRoleId,
orgMembershipStatus
}) => ({ }) => ({
user: { user: {
id: userId, id: userId,
@@ -121,6 +123,7 @@ export const orgDALFactory = (db: TDbClient) => {
firstName, firstName,
lastName lastName
}, },
status: orgMembershipStatus,
membershipId: orgMembershipId, membershipId: orgMembershipId,
role: orgMembershipRoleName || orgMembershipRole, // custom role name or pre-defined role name role: orgMembershipRoleName || orgMembershipRole, // custom role name or pre-defined role name
roleId: orgMembershipRoleId roleId: orgMembershipRoleId
@@ -488,6 +491,15 @@ export const orgDALFactory = (db: TDbClient) => {
} }
}; };
const bulkCreateMemberships = async (data: TOrgMembershipsInsert[], tx?: Knex) => {
try {
const memberships = await (tx || db)(TableName.OrgMembership).insert(data).returning("*");
return memberships;
} catch (error) {
throw new DatabaseError({ error, name: "Create org memberships" });
}
};
const updateMembershipById = async (id: string, data: TOrgMembershipsUpdate, tx?: Knex) => { const updateMembershipById = async (id: string, data: TOrgMembershipsUpdate, tx?: Knex) => {
try { try {
const [membership] = await (tx || db)(TableName.OrgMembership).where({ id }).update(data).returning("*"); const [membership] = await (tx || db)(TableName.OrgMembership).where({ id }).update(data).returning("*");
@@ -668,6 +680,7 @@ export const orgDALFactory = (db: TDbClient) => {
findMembership, findMembership,
findMembershipWithScimFilter, findMembershipWithScimFilter,
createMembership, createMembership,
bulkCreateMemberships,
updateMembershipById, updateMembershipById,
deleteMembershipById, deleteMembershipById,
deleteMembershipsById, deleteMembershipsById,
+29 -21
View File
@@ -528,15 +528,18 @@ export const orgServiceFactory = ({
/* /*
* Create organization * Create organization
* */ * */
const createOrganization = async ({ const createOrganization = async (
userId, {
userEmail, userId,
orgName userEmail,
}: { orgName
userId: string; }: {
orgName: string; userId?: string;
userEmail?: string | null; orgName: string;
}) => { userEmail?: string | null;
},
trx?: Knex
) => {
const { privateKey, publicKey } = await crypto.encryption().asymmetric().generateKeyPair(); const { privateKey, publicKey } = await crypto.encryption().asymmetric().generateKeyPair();
const key = crypto.randomBytes(32).toString("base64"); const key = crypto.randomBytes(32).toString("base64");
const { const {
@@ -555,22 +558,25 @@ export const orgServiceFactory = ({
} = crypto.encryption().symmetric().encryptWithRootEncryptionKey(key); } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(key);
const customerId = await licenseService.generateOrgCustomerId(orgName, userEmail); const customerId = await licenseService.generateOrgCustomerId(orgName, userEmail);
const organization = await orgDAL.transaction(async (tx) => {
const createOrg = async (tx: Knex) => {
// akhilmhdh: for now this is auto created. in future we can input from user and for previous users just modifiy // akhilmhdh: for now this is auto created. in future we can input from user and for previous users just modifiy
const org = await orgDAL.create( const org = await orgDAL.create(
{ name: orgName, customerId, slug: slugify(`${orgName}-${alphaNumericNanoId(4)}`) }, { name: orgName, customerId, slug: slugify(`${orgName}-${alphaNumericNanoId(4)}`) },
tx tx
); );
await orgDAL.createMembership( if (userId) {
{ await orgDAL.createMembership(
userId, {
orgId: org.id, userId,
role: OrgMembershipRole.Admin, orgId: org.id,
status: OrgMembershipStatus.Accepted, role: OrgMembershipRole.Admin,
isActive: true status: OrgMembershipStatus.Accepted,
}, isActive: true
tx },
); tx
);
}
await orgBotDAL.create( await orgBotDAL.create(
{ {
name: org.name, name: org.name,
@@ -590,7 +596,9 @@ export const orgServiceFactory = ({
tx tx
); );
return org; return org;
}); };
const organization = await (trx ? createOrg(trx) : orgDAL.transaction(createOrg));
await licenseService.updateSubscriptionOrgMemberCount(organization.id); await licenseService.updateSubscriptionOrgMemberCount(organization.id);
return organization; return organization;
@@ -1,4 +1,5 @@
import { ForbiddenError, subject } from "@casl/ability"; import { createMongoAbility, ForbiddenError, MongoAbility, RawRuleOf, subject } from "@casl/ability";
import { PackRule, unpackRules } from "@casl/ability/extra";
import slugify from "@sindresorhus/slugify"; import slugify from "@sindresorhus/slugify";
import { import {
@@ -16,9 +17,11 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionCertificateActions, ProjectPermissionCertificateActions,
ProjectPermissionMemberActions,
ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiSubscriberActions,
ProjectPermissionPkiTemplateActions, ProjectPermissionPkiTemplateActions,
ProjectPermissionSecretActions, ProjectPermissionSecretActions,
ProjectPermissionSet,
ProjectPermissionSshHostActions, ProjectPermissionSshHostActions,
ProjectPermissionSub ProjectPermissionSub
} from "@app/ee/services/permission/project-permission"; } from "@app/ee/services/permission/project-permission";
@@ -1852,9 +1855,52 @@ export const projectServiceFactory = ({
if (projectMember) throw new BadRequestError({ message: "User already has access to the project" }); if (projectMember) throw new BadRequestError({ message: "User already has access to the project" });
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId);
const filteredProjectMembers = projectMembers
let filteredProjectMembers = projectMembers
.filter((member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin)) .filter((member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin))
.map((el) => el.user.email!); .map((el) => el.user.email!);
if (filteredProjectMembers.length === 0) {
const customRolesWithMemberCreate = await projectRoleDAL.find({ projectId });
const customRoleSlugsCanCreate = customRolesWithMemberCreate
.filter((role) => {
try {
const permissions = (
typeof role.permissions === "string"
? (JSON.parse(role.permissions) as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[])
: role.permissions
) as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[];
const ability = createMongoAbility<MongoAbility<ProjectPermissionSet>>(
unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>(permissions)
);
return ability.can(ProjectPermissionMemberActions.Create, ProjectPermissionSub.Member);
} catch {
return false;
}
})
.map((role) => role.slug);
if (customRoleSlugsCanCreate.length > 0) {
const usersWithCustomCreateMemberRole = projectMembers
.filter((member) =>
member.roles.some((role) => role.customRoleSlug && customRoleSlugsCanCreate.includes(role.customRoleSlug))
)
.map((el) => el.user.email!)
.filter(Boolean);
if (usersWithCustomCreateMemberRole.length > 0) {
filteredProjectMembers = usersWithCustomCreateMemberRole;
}
}
}
if (filteredProjectMembers.length === 0) {
throw new BadRequestError({
message:
"No users in this project have permission to grant you access. Please contact an organization administrator to assign the necessary permissions."
});
}
const org = await orgDAL.findOne({ id: permission.orgId }); const org = await orgDAL.findOne({ id: permission.orgId });
const project = await projectDAL.findById(projectId); const project = await projectDAL.findById(projectId);
const userDetails = await userDAL.findById(permission.id); const userDetails = await userDAL.findById(permission.id);
@@ -3,6 +3,7 @@ import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-d
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { TUserNotificationDALFactory } from "@app/services/notification/user-notification-dal";
import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal";
import { TIdentityUaClientSecretDALFactory } from "../identity-ua/identity-ua-client-secret-dal"; import { TIdentityUaClientSecretDALFactory } from "../identity-ua/identity-ua-client-secret-dal";
@@ -25,6 +26,7 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = {
serviceTokenService: Pick<TServiceTokenServiceFactory, "notifyExpiringTokens">; serviceTokenService: Pick<TServiceTokenServiceFactory, "notifyExpiringTokens">;
queueService: TQueueServiceFactory; queueService: TQueueServiceFactory;
orgService: TOrgServiceFactory; orgService: TOrgServiceFactory;
userNotificationDAL: Pick<TUserNotificationDALFactory, "pruneNotifications">;
}; };
export type TDailyResourceCleanUpQueueServiceFactory = ReturnType<typeof dailyResourceCleanUpQueueServiceFactory>; export type TDailyResourceCleanUpQueueServiceFactory = ReturnType<typeof dailyResourceCleanUpQueueServiceFactory>;
@@ -40,7 +42,8 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
secretVersionV2DAL, secretVersionV2DAL,
identityUniversalAuthClientSecretDAL, identityUniversalAuthClientSecretDAL,
serviceTokenService, serviceTokenService,
orgService orgService,
userNotificationDAL
}: TDailyResourceCleanUpQueueServiceFactoryDep) => { }: TDailyResourceCleanUpQueueServiceFactoryDep) => {
const appCfg = getConfig(); const appCfg = getConfig();
@@ -78,6 +81,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
await serviceTokenService.notifyExpiringTokens(); await serviceTokenService.notifyExpiringTokens();
await orgService.notifyInvitedUsers(); await orgService.notifyInvitedUsers();
await auditLogDAL.pruneAuditLog(); await auditLogDAL.pruneAuditLog();
await userNotificationDAL.pruneNotifications();
logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`); logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`);
} catch (error) { } catch (error) {
logger.error(error, `${QueueName.DailyResourceCleanUp}: resource cleanup failed`); logger.error(error, `${QueueName.DailyResourceCleanUp}: resource cleanup failed`);
@@ -127,6 +127,27 @@ export const secretImportDALFactory = (db: TDbClient) => {
} }
}; };
const findByIds = async (ids: string[], tx?: Knex) => {
try {
const docs = await (tx || db.replicaNode())(TableName.SecretImport)
.whereIn(`${TableName.SecretImport}.id`, ids)
.join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`)
.select(
db.ref("*").withSchema(TableName.SecretImport) as unknown as keyof TSecretImports,
db.ref("slug").withSchema(TableName.Environment),
db.ref("name").withSchema(TableName.Environment),
db.ref("id").withSchema(TableName.Environment).as("envId")
);
return docs.map(({ envId, slug, name, ...el }) => ({
...el,
importEnv: { id: envId, slug, name }
}));
} catch (error) {
throw new DatabaseError({ error, name: "Find secret imports by ids" });
}
};
const getProjectImportCount = async ( const getProjectImportCount = async (
{ search, ...filter }: Partial<TSecretImports & { projectId: string; search?: string }>, { search, ...filter }: Partial<TSecretImports & { projectId: string; search?: string }>,
tx?: Knex tx?: Knex
@@ -325,6 +346,7 @@ export const secretImportDALFactory = (db: TDbClient) => {
...secretImportOrm, ...secretImportOrm,
find, find,
findById, findById,
findByIds,
findByFolderIds, findByFolderIds,
findLastImportPosition, findLastImportPosition,
updateAllPosition, updateAllPosition,
@@ -1,3 +1,5 @@
import RE2 from "re2";
import { SecretType, TSecretImports, TSecrets, TSecretsV2 } from "@app/db/schemas"; import { SecretType, TSecretImports, TSecrets, TSecretsV2 } from "@app/db/schemas";
import { groupBy, unique } from "@app/lib/fn"; import { groupBy, unique } from "@app/lib/fn";
@@ -54,6 +56,74 @@ type TSecretImportSecretsV2 = {
const LEVEL_BREAK = 10; const LEVEL_BREAK = 10;
const getImportUniqKey = (envSlug: string, path: string) => `${envSlug}=${path}`; const getImportUniqKey = (envSlug: string, path: string) => `${envSlug}=${path}`;
const RESERVED_IMPORT_REGEX = new RE2("/__reserve_replication_([a-f0-9-]{36})");
/**
* Processes reserved imports by resolving them to their replication source.
*/
const processReservedImports = async <
T extends {
isReserved?: boolean | null;
importPath: string;
importEnv: { id: string; slug: string; name: string };
folderId: string;
}
>(
imports: T[],
secretImportDAL: Pick<TSecretImportDALFactory, "findByIds">
): Promise<T[]> => {
const reservedImportIds: string[] = [];
imports.forEach((secretImport) => {
if (secretImport.isReserved) {
const reservedMatch = RESERVED_IMPORT_REGEX.exec(secretImport.importPath);
if (reservedMatch) {
const referencedImportId = reservedMatch[1];
reservedImportIds.push(referencedImportId);
}
}
});
if (reservedImportIds.length === 0) {
return imports;
}
try {
const importDetailsMap = new Map<
string,
{ importPath: string; importEnv: { id: string; slug: string; name: string } }
>();
const referencedImports = await secretImportDAL.findByIds(reservedImportIds);
referencedImports.forEach((referencedImport) => {
importDetailsMap.set(referencedImport.id, {
importPath: referencedImport.importPath,
importEnv: referencedImport.importEnv
});
});
return imports.map((secretImport) => {
if (secretImport.isReserved) {
const reservedMatch = RESERVED_IMPORT_REGEX.exec(secretImport.importPath);
if (reservedMatch) {
const referencedImportId = reservedMatch[1];
const referencedDetails = importDetailsMap.get(referencedImportId);
if (referencedDetails) {
return {
...secretImport,
importPath: referencedDetails.importPath,
importEnv: referencedDetails.importEnv
};
}
}
}
return secretImport;
});
} catch (error) {
return imports;
}
};
export const fnSecretsFromImports = async ({ export const fnSecretsFromImports = async ({
allowedImports: possibleCyclicImports, allowedImports: possibleCyclicImports,
folderDAL, folderDAL,
@@ -167,7 +237,7 @@ export const fnSecretsV2FromImports = async ({
folderDAL: Pick<TSecretFolderDALFactory, "findByManySecretPath">; folderDAL: Pick<TSecretFolderDALFactory, "findByManySecretPath">;
viewSecretValue: boolean; viewSecretValue: boolean;
secretDAL: Pick<TSecretV2BridgeDALFactory, "find">; secretDAL: Pick<TSecretV2BridgeDALFactory, "find">;
secretImportDAL: Pick<TSecretImportDALFactory, "findByFolderIds">; secretImportDAL: Pick<TSecretImportDALFactory, "findByFolderIds" | "findByIds">;
decryptor: (value?: Buffer | null) => string; decryptor: (value?: Buffer | null) => string;
expandSecretReferences?: (inputSecret: { expandSecretReferences?: (inputSecret: {
value?: string; value?: string;
@@ -188,6 +258,10 @@ export const fnSecretsV2FromImports = async ({
})[]; })[];
}[] = [{ secretImports: rootSecretImports, depth: 0, parentImportedSecrets: [] }]; }[] = [{ secretImports: rootSecretImports, depth: 0, parentImportedSecrets: [] }];
const processedSecretImports = await processReservedImports(rootSecretImports, secretImportDAL);
stack[0] = { secretImports: processedSecretImports, depth: 0, parentImportedSecrets: [] };
const processedImports: TSecretImportSecretsV2[] = []; const processedImports: TSecretImportSecretsV2[] = [];
while (stack.length) { while (stack.length) {
@@ -12,7 +12,7 @@ type TAWSParameterStoreRecord = Record<string, AWS.SSM.Parameter>;
type TAWSParameterStoreMetadataRecord = Record<string, AWS.SSM.ParameterMetadata>; type TAWSParameterStoreMetadataRecord = Record<string, AWS.SSM.ParameterMetadata>;
type TAWSParameterStoreTagsRecord = Record<string, Record<string, string>>; type TAWSParameterStoreTagsRecord = Record<string, Record<string, string>>;
const MAX_RETRIES = 5; const MAX_RETRIES = 10;
const BATCH_SIZE = 10; const BATCH_SIZE = 10;
const getSSM = async (secretSync: TAwsParameterStoreSyncWithCredentials) => { const getSSM = async (secretSync: TAwsParameterStoreSyncWithCredentials) => {
@@ -38,7 +38,7 @@ type TAwsSecretsRecord = Record<string, SecretListEntry>;
type TAwsSecretValuesRecord = Record<string, SecretValueEntry>; type TAwsSecretValuesRecord = Record<string, SecretValueEntry>;
type TAwsSecretDescriptionsRecord = Record<string, DescribeSecretResponse>; type TAwsSecretDescriptionsRecord = Record<string, DescribeSecretResponse>;
const MAX_RETRIES = 5; const MAX_RETRIES = 10;
const BATCH_SIZE = 20; const BATCH_SIZE = 20;
const getSecretsManagerClient = async (secretSync: TAwsSecretsManagerSyncWithCredentials) => { const getSecretsManagerClient = async (secretSync: TAwsSecretsManagerSyncWithCredentials) => {
@@ -80,8 +80,8 @@ type TSecretSyncQueueFactoryDep = {
| "deleteMany" | "deleteMany"
| "invalidateSecretCacheByProjectId" | "invalidateSecretCacheByProjectId"
>; >;
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">; secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds" | "findByIds">;
secretSyncDAL: Pick<TSecretSyncDALFactory, "findById" | "find" | "updateById" | "deleteById">; secretSyncDAL: Pick<TSecretSyncDALFactory, "findById" | "find" | "updateById" | "deleteById" | "update">;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">; auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers">; projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers">;
projectDAL: TProjectDALFactory; projectDAL: TProjectDALFactory;
@@ -104,17 +104,15 @@ type SecretSyncActionJob = Job<
TQueueSecretSyncSyncSecretsByIdDTO | TQueueSecretSyncImportSecretsByIdDTO | TQueueSecretSyncRemoveSecretsByIdDTO TQueueSecretSyncSyncSecretsByIdDTO | TQueueSecretSyncImportSecretsByIdDTO | TQueueSecretSyncRemoveSecretsByIdDTO
>; >;
const JITTER_MS = 10 * 1000;
const REQUEUE_MS = 30 * 1000;
const REQUEUE_LIMIT = 30;
const CONNECTION_CONCURRENCY_LIMIT = 3;
const getRequeueDelay = (failureCount?: number) => { const getRequeueDelay = (failureCount?: number) => {
if (!failureCount) return 0; const jitter = Math.random() * JITTER_MS;
if (!failureCount) return jitter;
const baseDelay = 1000; return REQUEUE_MS + jitter;
const maxDelay = 30000;
const delay = Math.min(baseDelay * 2 ** failureCount, maxDelay);
const jitter = delay * (0.5 + Math.random() * 0.5);
return jitter;
}; };
export const secretSyncQueueFactory = ({ export const secretSyncQueueFactory = ({
@@ -193,6 +191,46 @@ export const secretSyncQueueFactory = ({
folderCommitService folderCommitService
}); });
const $isConnectionConcurrencyLimitReached = async (connectionId: string) => {
const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId));
if (!concurrencyCount) return false;
const count = Number.parseInt(concurrencyCount, 10);
if (Number.isNaN(count)) return false;
return count >= CONNECTION_CONCURRENCY_LIMIT;
};
const $incrementConnectionConcurrencyCount = async (connectionId: string) => {
const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId));
const currentCount = Number.parseInt(concurrencyCount || "0", 10);
const incrementedCount = Number.isNaN(currentCount) ? 1 : currentCount + 1;
await keyStore.setItemWithExpiry(
KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId),
(REQUEUE_MS * REQUEUE_LIMIT) / 1000, // in seconds
incrementedCount
);
};
const $decrementConnectionConcurrencyCount = async (connectionId: string) => {
const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId));
const currentCount = Number.parseInt(concurrencyCount || "0", 10);
const decrementedCount = Math.max(0, Number.isNaN(currentCount) ? 0 : currentCount - 1);
await keyStore.setItemWithExpiry(
KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId),
(REQUEUE_MS * REQUEUE_LIMIT) / 1000, // in seconds
decrementedCount
);
};
const $getInfisicalSecrets = async ( const $getInfisicalSecrets = async (
secretSync: TSecretSyncRaw | TSecretSyncWithCredentials, secretSync: TSecretSyncRaw | TSecretSyncWithCredentials,
includeImports = true includeImports = true
@@ -416,15 +454,11 @@ export const secretSyncQueueFactory = ({
return importedSecretMap; return importedSecretMap;
}; };
const $handleSyncSecretsJob = async (job: TSecretSyncSyncSecretsDTO) => { const $handleSyncSecretsJob = async (job: TSecretSyncSyncSecretsDTO, secretSync: TSecretSyncRaw) => {
const { const {
data: { syncId, auditLogInfo } data: { syncId, auditLogInfo }
} = job; } = job;
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
await enterpriseSyncCheck( await enterpriseSyncCheck(
licenseService, licenseService,
secretSync.destination as SecretSync, secretSync.destination as SecretSync,
@@ -566,15 +600,11 @@ export const secretSyncQueueFactory = ({
logger.info("SecretSync Sync Job with ID %s Completed", job.id); logger.info("SecretSync Sync Job with ID %s Completed", job.id);
}; };
const $handleImportSecretsJob = async (job: TSecretSyncImportSecretsDTO) => { const $handleImportSecretsJob = async (job: TSecretSyncImportSecretsDTO, secretSync: TSecretSyncRaw) => {
const { const {
data: { syncId, auditLogInfo, importBehavior } data: { syncId, auditLogInfo, importBehavior }
} = job; } = job;
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
await secretSyncDAL.updateById(syncId, { await secretSyncDAL.updateById(syncId, {
importStatus: SecretSyncStatus.Running importStatus: SecretSyncStatus.Running
}); });
@@ -683,15 +713,11 @@ export const secretSyncQueueFactory = ({
logger.info("SecretSync Import Job with ID %s Completed", job.id); logger.info("SecretSync Import Job with ID %s Completed", job.id);
}; };
const $handleRemoveSecretsJob = async (job: TSecretSyncRemoveSecretsDTO) => { const $handleRemoveSecretsJob = async (job: TSecretSyncRemoveSecretsDTO, secretSync: TSecretSyncRaw) => {
const { const {
data: { syncId, auditLogInfo, deleteSyncOnComplete } data: { syncId, auditLogInfo, deleteSyncOnComplete }
} = job; } = job;
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
await enterpriseSyncCheck( await enterpriseSyncCheck(
licenseService, licenseService,
secretSync.destination as SecretSync, secretSync.destination as SecretSync,
@@ -894,6 +920,17 @@ export const secretSyncQueueFactory = ({
const secretSyncs = await secretSyncDAL.find({ folderId: folder.id, isAutoSyncEnabled: true }); const secretSyncs = await secretSyncDAL.find({ folderId: folder.id, isAutoSyncEnabled: true });
await secretSyncDAL.update(
{
$in: {
id: secretSyncs.map((sync) => sync.id)
}
},
{
syncStatus: SecretSyncStatus.Pending
}
);
await Promise.all(secretSyncs.map((secretSync) => queueSecretSyncSyncSecretsById({ syncId: secretSync.id }))); await Promise.all(secretSyncs.map((secretSync) => queueSecretSyncSyncSecretsById({ syncId: secretSync.id })));
}; };
@@ -904,7 +941,7 @@ export const secretSyncQueueFactory = ({
case QueueJobs.SecretSyncSyncSecrets: { case QueueJobs.SecretSyncSyncSecrets: {
const { failedToAcquireLockCount = 0, ...rest } = job.data as TQueueSecretSyncSyncSecretsByIdDTO; const { failedToAcquireLockCount = 0, ...rest } = job.data as TQueueSecretSyncSyncSecretsByIdDTO;
if (failedToAcquireLockCount < 10) { if (failedToAcquireLockCount < REQUEUE_LIMIT) {
await queueSecretSyncSyncSecretsById({ ...rest, failedToAcquireLockCount: failedToAcquireLockCount + 1 }); await queueSecretSyncSyncSecretsById({ ...rest, failedToAcquireLockCount: failedToAcquireLockCount + 1 });
return; return;
} }
@@ -974,6 +1011,26 @@ export const secretSyncQueueFactory = ({
| TQueueSecretSyncImportSecretsByIdDTO | TQueueSecretSyncImportSecretsByIdDTO
| TQueueSecretSyncRemoveSecretsByIdDTO; | TQueueSecretSyncRemoveSecretsByIdDTO;
const secretSync = await secretSyncDAL.findById(syncId);
if (!secretSync) throw new Error(`Cannot find secret sync with ID ${syncId}`);
const { connectionId } = secretSync;
if (job.name === QueueJobs.SecretSyncSyncSecrets) {
const isConcurrentLimitReached = await $isConnectionConcurrencyLimitReached(connectionId);
if (isConcurrentLimitReached) {
logger.info(
`SecretSync Concurrency limit reached [syncId=${syncId}] [job=${job.name}] [connectionId=${connectionId}]`
);
await $handleAcquireLockFailure(job as SecretSyncActionJob);
return;
}
}
let lock: Awaited<ReturnType<typeof keyStore.acquireLock>>; let lock: Awaited<ReturnType<typeof keyStore.acquireLock>>;
try { try {
@@ -993,20 +1050,26 @@ export const secretSyncQueueFactory = ({
try { try {
switch (job.name) { switch (job.name) {
case QueueJobs.SecretSyncSyncSecrets: case QueueJobs.SecretSyncSyncSecrets: {
await $handleSyncSecretsJob(job as TSecretSyncSyncSecretsDTO); await $incrementConnectionConcurrencyCount(connectionId);
await $handleSyncSecretsJob(job as TSecretSyncSyncSecretsDTO, secretSync);
break; break;
}
case QueueJobs.SecretSyncImportSecrets: case QueueJobs.SecretSyncImportSecrets:
await $handleImportSecretsJob(job as TSecretSyncImportSecretsDTO); await $handleImportSecretsJob(job as TSecretSyncImportSecretsDTO, secretSync);
break; break;
case QueueJobs.SecretSyncRemoveSecrets: case QueueJobs.SecretSyncRemoveSecrets:
await $handleRemoveSecretsJob(job as TSecretSyncRemoveSecretsDTO); await $handleRemoveSecretsJob(job as TSecretSyncRemoveSecretsDTO, secretSync);
break; break;
default: default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unhandled Secret Sync Job ${job.name}`); throw new Error(`Unhandled Secret Sync Job ${job.name}`);
} }
} finally { } finally {
if (job.name === QueueJobs.SecretSyncSyncSecrets) {
await $decrementConnectionConcurrencyCount(connectionId);
}
await lock.release(); await lock.release();
} }
}); });
@@ -108,7 +108,7 @@ type TSecretV2BridgeServiceFactoryDep = {
| "findBySecretPathMultiEnv" | "findBySecretPathMultiEnv"
| "findSecretPathByFolderIds" | "findSecretPathByFolderIds"
>; >;
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">; secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds" | "findByIds">;
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "handleSecretReminder" | "removeSecretReminder">; secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "handleSecretReminder" | "removeSecretReminder">;
secretApprovalPolicyService: Pick<TSecretApprovalPolicyServiceFactory, "getSecretApprovalPolicy">; secretApprovalPolicyService: Pick<TSecretApprovalPolicyServiceFactory, "getSecretApprovalPolicy">;
secretApprovalRequestDAL: Pick<TSecretApprovalRequestDALFactory, "create" | "transaction">; secretApprovalRequestDAL: Pick<TSecretApprovalRequestDALFactory, "create" | "transaction">;
+1 -1
View File
@@ -86,7 +86,7 @@ type TSecretQueueFactoryDep = {
integrationAuthService: Pick<TIntegrationAuthServiceFactory, "getIntegrationAccessToken">; integrationAuthService: Pick<TIntegrationAuthServiceFactory, "getIntegrationAccessToken">;
folderDAL: TSecretFolderDALFactory; folderDAL: TSecretFolderDALFactory;
secretDAL: TSecretDALFactory; secretDAL: TSecretDALFactory;
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">; secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds" | "findByIds">;
webhookDAL: Pick<TWebhookDALFactory, "findAllWebhooks" | "transaction" | "update" | "bulkUpdate">; webhookDAL: Pick<TWebhookDALFactory, "findAllWebhooks" | "transaction" | "update" | "bulkUpdate">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "find">; projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "find">;
projectDAL: TProjectDALFactory; projectDAL: TProjectDALFactory;
@@ -0,0 +1,69 @@
import { Heading, Section, Text } from "@react-email/components";
import React from "react";
import { BaseButton } from "./BaseButton";
import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper";
import { BaseLink } from "./BaseLink";
interface OrganizationAssignmentTemplateProps extends Omit<BaseEmailWrapperProps, "preview" | "title"> {
inviterFirstName?: string;
inviterUsername?: string;
organizationName: string;
callback_url: string;
}
export const OrganizationAssignmentTemplate = ({
organizationName,
inviterFirstName,
inviterUsername,
callback_url,
siteUrl
}: OrganizationAssignmentTemplateProps) => {
return (
<BaseEmailWrapper
title="New Organization"
preview="You've been added to a new organization on Infisical."
siteUrl={siteUrl}
>
<Heading className="text-black text-[18px] leading-[28px] text-center font-normal p-0 mx-0">
You've been added to the organization
<br />
<strong>{organizationName}</strong> on <strong>Infisical</strong>
</Heading>
<Section className="px-[24px] mb-[28px] mt-[36px] pt-[12px] pb-[8px] border text-center border-solid border-gray-200 rounded-md bg-gray-50">
<Text className="text-black text-[14px] leading-[24px]">
{inviterFirstName && inviterUsername ? (
<>
<strong>{inviterFirstName}</strong> (
<BaseLink href={`mailto:${inviterUsername}`}>{inviterUsername}</BaseLink>) has added you as an
organization admin to <strong>{organizationName}</strong>.
</>
) : (
<>
An instance admin has added you as an organization admin to <strong>{organizationName}</strong>.
</>
)}
</Text>
</Section>
<Section className="text-center">
<BaseButton href={callback_url}>View Dashboard</BaseButton>
</Section>
<Section className="mt-[24px] bg-gray-50 pt-[2px] pb-[16px] border border-solid border-gray-200 px-[24px] rounded-md text-gray-800">
<Text className="mb-[0px]">
<strong>About Infisical:</strong> Infisical is an all-in-one platform to securely manage application secrets,
certificates, SSH keys, and configurations across your team and infrastructure.
</Text>
</Section>
</BaseEmailWrapper>
);
};
export default OrganizationAssignmentTemplate;
OrganizationAssignmentTemplate.PreviewProps = {
organizationName: "Example Organization",
inviterFirstName: "Jane",
inviterUsername: "[email protected]",
siteUrl: "https://infisical.com",
callback_url: "https://app.infisical.com"
} as OrganizationAssignmentTemplateProps;
@@ -9,6 +9,7 @@ export * from "./IntegrationSyncFailedTemplate";
export * from "./NewDeviceLoginTemplate"; export * from "./NewDeviceLoginTemplate";
export * from "./OrgAdminBreakglassAccessTemplate"; export * from "./OrgAdminBreakglassAccessTemplate";
export * from "./OrgAdminProjectGrantAccessTemplate"; export * from "./OrgAdminProjectGrantAccessTemplate";
export * from "./OrganizationAssignmentTemplate";
export * from "./OrganizationInvitationTemplate"; export * from "./OrganizationInvitationTemplate";
export * from "./PasswordResetTemplate"; export * from "./PasswordResetTemplate";
export * from "./PasswordSetupTemplate"; export * from "./PasswordSetupTemplate";
@@ -18,6 +18,7 @@ import {
NewDeviceLoginTemplate, NewDeviceLoginTemplate,
OrgAdminBreakglassAccessTemplate, OrgAdminBreakglassAccessTemplate,
OrgAdminProjectGrantAccessTemplate, OrgAdminProjectGrantAccessTemplate,
OrganizationAssignmentTemplate,
OrganizationInvitationTemplate, OrganizationInvitationTemplate,
PasswordResetTemplate, PasswordResetTemplate,
PasswordSetupTemplate, PasswordSetupTemplate,
@@ -61,6 +62,7 @@ export enum SmtpTemplates {
// HistoricalSecretList = "historicalSecretLeakIncident", not used anymore? // HistoricalSecretList = "historicalSecretLeakIncident", not used anymore?
NewDeviceJoin = "newDevice", NewDeviceJoin = "newDevice",
OrgInvite = "organizationInvitation", OrgInvite = "organizationInvitation",
OrgAssignment = "organizationAssignment",
ResetPassword = "passwordReset", ResetPassword = "passwordReset",
SetupPassword = "passwordSetup", SetupPassword = "passwordSetup",
SecretLeakIncident = "secretLeakIncident", SecretLeakIncident = "secretLeakIncident",
@@ -94,6 +96,7 @@ export enum SmtpHost {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = { const EmailTemplateMap: Record<SmtpTemplates, React.FC<any>> = {
[SmtpTemplates.OrgInvite]: OrganizationInvitationTemplate, [SmtpTemplates.OrgInvite]: OrganizationInvitationTemplate,
[SmtpTemplates.OrgAssignment]: OrganizationAssignmentTemplate,
[SmtpTemplates.NewDeviceJoin]: NewDeviceLoginTemplate, [SmtpTemplates.NewDeviceJoin]: NewDeviceLoginTemplate,
[SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate, [SmtpTemplates.SignupEmailVerification]: SignupEmailVerificationTemplate,
[SmtpTemplates.EmailMfa]: EmailMfaTemplate, [SmtpTemplates.EmailMfa]: EmailMfaTemplate,
@@ -1,6 +1,13 @@
import { CronJob } from "cron"; import { CronJob } from "cron";
import { IdentityAuthMethod, OrgMembershipRole, TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas"; import {
IdentityAuthMethod,
OrgMembershipRole,
OrgMembershipStatus,
TSuperAdmin,
TSuperAdminUpdate,
TUsers
} from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore";
import { import {
@@ -13,7 +20,12 @@ import {
import { crypto } from "@app/lib/crypto/cryptography"; import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types";
import { isDisposableEmail } from "@app/lib/validator";
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
import { TokenType } from "@app/services/auth-token/auth-token-types";
import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { TAuthLoginFactory } from "../auth/auth-login-service"; import { TAuthLoginFactory } from "../auth/auth-login-service";
import { ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; import { ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type";
@@ -43,7 +55,9 @@ import {
TAdminGetUsersDTO, TAdminGetUsersDTO,
TAdminIntegrationConfig, TAdminIntegrationConfig,
TAdminSignUpDTO, TAdminSignUpDTO,
TGetOrganizationsDTO TCreateOrganizationDTO,
TGetOrganizationsDTO,
TResendOrgInviteDTO
} from "./super-admin-types"; } from "./super-admin-types";
type TSuperAdminServiceFactoryDep = { type TSuperAdminServiceFactoryDep = {
@@ -59,11 +73,13 @@ type TSuperAdminServiceFactoryDep = {
authService: Pick<TAuthLoginFactory, "generateUserTokens">; authService: Pick<TAuthLoginFactory, "generateUserTokens">;
kmsService: Pick<TKmsServiceFactory, "encryptWithRootKey" | "decryptWithRootKey" | "updateEncryptionStrategy">; kmsService: Pick<TKmsServiceFactory, "encryptWithRootKey" | "decryptWithRootKey" | "updateEncryptionStrategy">;
kmsRootConfigDAL: TKmsRootConfigDALFactory; kmsRootConfigDAL: TKmsRootConfigDALFactory;
orgService: Pick<TOrgServiceFactory, "createOrganization">; orgService: Pick<TOrgServiceFactory, "createOrganization" | "inviteUserToOrganization">;
keyStore: Pick<TKeyStoreFactory, "getItem" | "setItemWithExpiry" | "deleteItem" | "deleteItems">; keyStore: Pick<TKeyStoreFactory, "getItem" | "setItemWithExpiry" | "deleteItem" | "deleteItems">;
licenseService: Pick<TLicenseServiceFactory, "onPremFeatures">; licenseService: Pick<TLicenseServiceFactory, "onPremFeatures" | "updateSubscriptionOrgMemberCount">;
microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "initializeTeamsBot">; microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "initializeTeamsBot">;
invalidateCacheQueue: TInvalidateCacheQueueFactory; invalidateCacheQueue: TInvalidateCacheQueueFactory;
smtpService: Pick<TSmtpService, "sendMail">;
tokenService: TAuthTokenServiceFactory;
}; };
export type TSuperAdminServiceFactory = ReturnType<typeof superAdminServiceFactory>; export type TSuperAdminServiceFactory = ReturnType<typeof superAdminServiceFactory>;
@@ -123,7 +139,9 @@ export const superAdminServiceFactory = ({
identityTokenAuthDAL, identityTokenAuthDAL,
identityOrgMembershipDAL, identityOrgMembershipDAL,
microsoftTeamsService, microsoftTeamsService,
invalidateCacheQueue invalidateCacheQueue,
smtpService,
tokenService
}: TSuperAdminServiceFactoryDep) => { }: TSuperAdminServiceFactoryDep) => {
const initServerCfg = async () => { const initServerCfg = async () => {
// TODO(akhilmhdh): bad pattern time less change this later to me itself // TODO(akhilmhdh): bad pattern time less change this later to me itself
@@ -732,6 +750,159 @@ export const superAdminServiceFactory = ({
return organizations; return organizations;
}; };
const createOrganization = async (
{ name, inviteAdminEmails: emails }: TCreateOrganizationDTO,
actor: OrgServiceActor
) => {
const appCfg = getConfig();
const inviteAdminEmails = [...new Set(emails)];
if (!appCfg.isDevelopmentMode && appCfg.isCloud)
throw new BadRequestError({ message: "This endpoint is not supported for cloud instances" });
const serverAdmin = await userDAL.findById(actor.id);
const plan = licenseService.onPremFeatures;
const isEmailInvalid = await isDisposableEmail(inviteAdminEmails);
if (isEmailInvalid) {
throw new BadRequestError({
message: "Disposable emails are not allowed",
name: "InviteUser"
});
}
const { organization, users: usersToEmail } = await orgDAL.transaction(async (tx) => {
const org = await orgService.createOrganization(
{
orgName: name,
userEmail: serverAdmin?.email ?? serverAdmin?.username // identities can be server admins so we can't require this
},
tx
);
const users: Pick<TUsers, "id" | "firstName" | "lastName" | "email" | "username" | "isAccepted">[] = [];
for await (const inviteeEmail of inviteAdminEmails) {
const usersByUsername = await userDAL.findUserByUsername(inviteeEmail, tx);
let inviteeUser =
usersByUsername?.length > 1
? usersByUsername.find((el) => el.username === inviteeEmail)
: usersByUsername?.[0];
// if the user doesn't exist we create the user with the email
if (!inviteeUser) {
// TODO(carlos): will be removed once the function receives usernames instead of emails
const usersByEmail = await userDAL.findUserByEmail(inviteeEmail, tx);
if (usersByEmail?.length === 1) {
[inviteeUser] = usersByEmail;
} else {
inviteeUser = await userDAL.create(
{
isAccepted: false,
email: inviteeEmail,
username: inviteeEmail,
authMethods: [AuthMethod.EMAIL],
isGhost: false
},
tx
);
}
}
const inviteeUserId = inviteeUser?.id;
const existingEncryptionKey = await userDAL.findUserEncKeyByUserId(inviteeUserId, tx);
// when user is missing the encrytion keys
// this could happen either if user doesn't exist or user didn't find step 3 of generating the encryption keys of srp
// So what we do is we generate a random secure password and then encrypt it with a random pub-private key
// Then when user sign in (as login is not possible as isAccepted is false) we rencrypt the private key with the user password
if (!inviteeUser || (inviteeUser && !inviteeUser?.isAccepted && !existingEncryptionKey)) {
await userDAL.createUserEncryption(
{
userId: inviteeUserId,
encryptionVersion: 2
},
tx
);
}
if (plan?.slug !== "enterprise" && plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) {
// limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed
throw new BadRequestError({
name: "InviteUser",
message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members."
});
}
await orgDAL.createMembership(
{
userId: inviteeUser.id,
inviteEmail: inviteeEmail,
orgId: org.id,
role: OrgMembershipRole.Admin,
status: inviteeUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited,
isActive: true
},
tx
);
users.push(inviteeUser);
}
return { organization: org, users };
});
await licenseService.updateSubscriptionOrgMemberCount(organization.id);
await Promise.allSettled(
usersToEmail.map(async (user) => {
if (!user.email) return;
if (user.isAccepted) {
return smtpService.sendMail({
template: SmtpTemplates.OrgAssignment,
subjectLine: "You've been added to an Infisical organization",
recipients: [user.email],
substitutions: {
inviterFirstName: serverAdmin?.firstName,
inviterUsername: serverAdmin?.email,
organizationName: organization.name,
email: user.email,
organizationId: organization.id,
callback_url: `${appCfg.SITE_URL}/login?org_id=${organization.id}`
}
});
}
// new user, send regular invite
const token = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_ORG_INVITATION,
userId: user.id,
orgId: organization.id
});
return smtpService.sendMail({
template: SmtpTemplates.OrgInvite,
subjectLine: "Infisical organization invitation",
recipients: [user.email],
substitutions: {
inviterFirstName: serverAdmin?.firstName,
inviterUsername: serverAdmin?.email,
organizationName: organization.name,
email: user.email,
organizationId: organization.id,
token,
callback_url: `${appCfg.SITE_URL}/signupinvite`
}
});
})
);
return organization;
};
const deleteOrganization = async (organizationId: string) => { const deleteOrganization = async (organizationId: string) => {
const organization = await orgDAL.deleteById(organizationId); const organization = await orgDAL.deleteById(organizationId);
return organization; return organization;
@@ -763,6 +934,86 @@ export const superAdminServiceFactory = ({
return organizationMembership; return organizationMembership;
}; };
const joinOrganization = async (orgId: string, actor: OrgServiceActor) => {
const serverAdmin = await userDAL.findById(actor.id);
if (!serverAdmin) {
throw new NotFoundError({ message: "Could not find server admin user" });
}
const org = await orgDAL.findById(orgId);
if (!org) {
throw new NotFoundError({ message: `Could not organization with ID "${orgId}"` });
}
const existingOrgMembership = await orgMembershipDAL.findOne({ userId: serverAdmin.id, orgId });
if (existingOrgMembership) {
throw new BadRequestError({ message: `You are already a part of the organization with ID ${orgId}` });
}
const orgMembership = await orgDAL.createMembership({
userId: serverAdmin.id,
orgId: org.id,
role: OrgMembershipRole.Admin,
status: OrgMembershipStatus.Accepted,
isActive: true
});
return orgMembership;
};
const resendOrgInvite = async ({ organizationId, membershipId }: TResendOrgInviteDTO, actor: OrgServiceActor) => {
const orgMembership = await orgMembershipDAL.findOne({ id: membershipId, orgId: organizationId });
if (!orgMembership) {
throw new NotFoundError({ name: "Organization Membership", message: "Organization membership not found" });
}
if (orgMembership.status === OrgMembershipStatus.Accepted) {
throw new BadRequestError({
message: "This user has already accepted their invitation."
});
}
if (!orgMembership.userId) {
throw new NotFoundError({ message: "Cannot find user associated with Org Membership." });
}
if (!orgMembership.inviteEmail) {
throw new BadRequestError({ message: "No invite email associated with user." });
}
const org = await orgDAL.findOrgById(orgMembership.orgId);
const appCfg = getConfig();
const serverAdmin = await userDAL.findById(actor.id);
const token = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_ORG_INVITATION,
userId: orgMembership.userId,
orgId: orgMembership.orgId
});
await smtpService.sendMail({
template: SmtpTemplates.OrgInvite,
subjectLine: "Infisical organization invitation",
recipients: [orgMembership.inviteEmail],
substitutions: {
inviterFirstName: serverAdmin?.firstName,
inviterUsername: serverAdmin?.email,
organizationName: org?.name,
email: orgMembership.inviteEmail,
organizationId: orgMembership.orgId,
token,
callback_url: `${appCfg.SITE_URL}/signupinvite`
}
});
return orgMembership;
};
const getIdentities = async ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => { const getIdentities = async ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => {
const identities = await identityDAL.getIdentitiesByFilter({ const identities = await identityDAL.getIdentitiesByFilter({
limit, limit,
@@ -901,6 +1152,9 @@ export const superAdminServiceFactory = ({
initializeEnvConfigSync, initializeEnvConfigSync,
getEnvOverrides, getEnvOverrides,
getEnvOverridesOrganized, getEnvOverridesOrganized,
deleteUsers deleteUsers,
createOrganization,
joinOrganization,
resendOrgInvite
}; };
}; };
@@ -34,6 +34,16 @@ export type TGetOrganizationsDTO = {
searchTerm: string; searchTerm: string;
}; };
export type TCreateOrganizationDTO = {
name: string;
inviteAdminEmails: string[];
};
export type TResendOrgInviteDTO = {
organizationId: string;
membershipId: string;
};
export enum LoginMethod { export enum LoginMethod {
EMAIL = "email", EMAIL = "email",
GOOGLE = "google", GOOGLE = "google",
+40
View File
@@ -0,0 +1,40 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
],
parser: '@babel/eslint-parser',
parserOptions: {
ecmaVersion: 2021,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
requireConfigFile: false,
babelOptions: {
presets: ['@babel/preset-react'],
},
},
plugins: ['react'],
rules: {
'react/jsx-uses-react': 'error',
'react/jsx-uses-vars': 'error',
},
settings: {
react: {
version: 'detect',
},
},
ignorePatterns: [
'node_modules/',
'dist/',
'build/',
'*.config.js',
],
};
+8
View File
@@ -98,6 +98,7 @@
{ {
"group": "App Connections", "group": "App Connections",
"pages": [ "pages": [
"integrations/app-connections",
"integrations/app-connections/overview", "integrations/app-connections/overview",
{ {
"group": "Connections", "group": "Connections",
@@ -184,6 +185,7 @@
{ {
"group": "User Authentication", "group": "User Authentication",
"pages": [ "pages": [
"integrations/user-authentication",
"documentation/platform/auth-methods/email-password", "documentation/platform/auth-methods/email-password",
{ {
"group": "SSO", "group": "SSO",
@@ -243,6 +245,7 @@
{ {
"group": "Machine Identities", "group": "Machine Identities",
"pages": [ "pages": [
"integrations/machine-authentication",
"documentation/platform/identities/alicloud-auth", "documentation/platform/identities/alicloud-auth",
"documentation/platform/identities/aws-auth", "documentation/platform/identities/aws-auth",
"documentation/platform/identities/azure-auth", "documentation/platform/identities/azure-auth",
@@ -417,6 +420,7 @@
{ {
"group": "Secret Rotation", "group": "Secret Rotation",
"pages": [ "pages": [
"integrations/secret-rotations",
"documentation/platform/secret-rotation/overview", "documentation/platform/secret-rotation/overview",
"documentation/platform/secret-rotation/auth0-client-secret", "documentation/platform/secret-rotation/auth0-client-secret",
"documentation/platform/secret-rotation/aws-iam-user-secret", "documentation/platform/secret-rotation/aws-iam-user-secret",
@@ -432,6 +436,7 @@
{ {
"group": "Dynamic Secrets", "group": "Dynamic Secrets",
"pages": [ "pages": [
"integrations/dynamic-secrets",
"documentation/platform/dynamic-secrets/overview", "documentation/platform/dynamic-secrets/overview",
"documentation/platform/dynamic-secrets/aws-elasticache", "documentation/platform/dynamic-secrets/aws-elasticache",
"documentation/platform/dynamic-secrets/aws-iam", "documentation/platform/dynamic-secrets/aws-iam",
@@ -502,6 +507,7 @@
{ {
"group": "Secret Syncs", "group": "Secret Syncs",
"pages": [ "pages": [
"integrations/secret-syncs",
"integrations/secret-syncs/overview", "integrations/secret-syncs/overview",
{ {
"group": "Syncs", "group": "Syncs",
@@ -607,6 +613,7 @@
{ {
"group": "Framework Integrations", "group": "Framework Integrations",
"pages": [ "pages": [
"integrations/framework-integrations",
"integrations/frameworks/spring-boot-maven", "integrations/frameworks/spring-boot-maven",
"integrations/frameworks/react", "integrations/frameworks/react",
"integrations/frameworks/vue", "integrations/frameworks/vue",
@@ -2448,6 +2455,7 @@
"sdks/languages/cpp", "sdks/languages/cpp",
"sdks/languages/rust", "sdks/languages/rust",
"sdks/languages/go", "sdks/languages/go",
"sdks/languages/php",
"sdks/languages/ruby" "sdks/languages/ruby"
] ]
} }
@@ -6,84 +6,133 @@ description: "Learn how to stream Infisical Audit Logs to external logging provi
<Info> <Info>
Audit log streams is a paid feature. Audit log streams is a paid feature.
If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical, then you should contact [email protected] to purchase an enterprise license to use it.
then you should contact [email protected] to purchase an enterprise license to use it.
</Info> </Info>
Infisical Audit Log Streaming enables you to transmit your organization's Audit Logs to external logging providers for monitoring and analysis. Infisical Audit Log Streaming enables you to transmit your organization's audit logs to external logging providers for monitoring and analysis.
The logs are formatted in JSON, requiring your logging provider to support JSON-based log parsing.
## Overview ## Overview
<Steps> <Steps>
<Step title="Navigate to Organization Settings in your sidebar." /> <Step title="Create Stream">
<Step title="Select Audit Log Streams Tab."> 1. Navigate to **Organization Settings**
![stream create](/images/platform/audit-log-streams/stream-create.png) 2. Select the **Audit Log Streams** tab
</Step> 3. Click **Add Log Stream**
<Step title="Click on Create">
![stream create](/images/platform/audit-log-streams/stream-inputs.png)
Provide the following values ![stream create](/images/platform/audit-log-streams/stream-create.png)
<ParamField path="Endpoint URL" type="string" required> </Step>
The HTTPS endpoint URL of the logging provider that collects the JSON stream. <Step title="Select Provider">
</ParamField> If your log provider is included in this list, select it. Otherwise click on **Custom** to input your own Endpoint URL and headers.
<ParamField path="Headers" type="string" >
The HTTP headers for the logging provider for identification and authentication. ![select provider](/images/platform/audit-log-streams/select-provider.png)
</ParamField> </Step>
<Step title="Input Credentials">
Depending on your chosen provider, you'll be asked to input different credentials.
For **Custom**, you need to input an endpoint URL and headers.
![custom provider](/images/platform/audit-log-streams/custom-provider.png)
Once you're finished, click **Create Log Stream**.
</Step>
<Step title="Log Stream Created">
Your audit logs are now ready to be streamed.
![stream list](/images/platform/audit-log-streams/stream-list.png)
</Step> </Step>
</Steps> </Steps>
![stream listt](/images/platform/audit-log-streams/stream-list.png)
Your Audit Logs are now ready to be streamed.
## Example Providers ## Example Providers
### Better Stack <AccordionGroup>
<Accordion title="Better Stack">
You can stream to Better Stack using a **Custom** log stream.
<Steps> <Steps>
<Step title="Select Connect Source"> <Step title="Connect Source">
![better stack connect source](/images/platform/audit-log-streams/betterstack-create-source.png) On Better Stack, select **Connect Source** and click **Create source** after providing a name.
</Step>
<Step title="Provide a name and select platform"/>
<Step title="Provide Audit Log Stream inputs">
![better stack connect](/images/platform/audit-log-streams/betterstack-source-details.png)
1. Copy the **endpoint** from Better Stack to the **Endpoint URL** field. ![better stack connect source](/images/platform/audit-log-streams/betterstack-create-source.png)
3. Create a new header with key **Authorization** and set the value as **Bearer \<source token from betterstack\>**.
</Step>
</Steps>
### Datadog Once your source is created, take note of the **endpoint** and **Source token** for the next step.
<Steps> ![better stack connect](/images/platform/audit-log-streams/betterstack-source-details.png)
<Step title="Navigate to API Keys section"> </Step>
![api key create](/images/platform/audit-log-streams/datadog-api-sidebar.png) <Step title="Create Audit Log Stream on Infisical">
</Step> On Infisical, create a new audit log stream and select the **Custom** option.
<Step title="Select New Key and provide a key name">
![api key form](/images/platform/audit-log-streams/data-create-api-key.png)
![api key form](/images/platform/audit-log-streams/data-dog-api-key.png)
</Step>
<Step title="Find your Datadog region specific logging endpoint.">
![datadog url](/images/platform/audit-log-streams/datadog-logging-endpoint.png)
1. Navigate to the [Datadog Send Logs API documentation](https://docs.datadoghq.com/api/latest/logs/?code-lang=curl&site=us5#send-logs). ![select custom](/images/platform/audit-log-streams/select-custom.png)
2. Pick your Datadog account region.
3. Obtain your Datadog logging endpoint URL.
</Step>
<Step title="Provide audit log stream inputs">
![datadog api key details](/images/platform/audit-log-streams/datadog-source-details.png)
1. Copy the **logging endpoint** from Datadog to the **Endpoint URL** field. 1. Fill in the endpoint URL with your Better Stack source endpoint
2. Copy the **API Key** from previous step 2. Create a new header with key `Authorization` and set the value as `Bearer <betterstack-src-token>`
3. Create a new header with key **DD-API-KEY** and set the value as **API Key**.
</Step>
</Steps>
## Audit Log Stream Data ![custom provider](/images/platform/audit-log-streams/custom-provider.png)
Each log entry sent to the external logging provider will follow the same structure. Once you're finished, click **Create Log Stream**.
</Step>
</Steps>
</Accordion>
<Accordion title="Datadog">
You can stream to Datadog using the **Datadog** provider log stream.
<Steps>
<Step title="Navigate to API Keys section">
![api key create](/images/platform/audit-log-streams/datadog-api-sidebar.png)
</Step>
<Step title="Select New Key and provide a key name">
![api key form](/images/platform/audit-log-streams/data-create-api-key.png)
![api key form](/images/platform/audit-log-streams/data-dog-api-key.png)
</Step>
<Step title="Create Audit Log Stream on Infisical">
On Infisical, create a new audit log stream and select the **Datadog** provider option.
Input your **Datadog Region** and the **Token** obtained from step 2.
![datadog details](/images/platform/audit-log-streams/datadog-details.png)
Once you're finished, click **Create Log Stream**.
</Step>
</Steps>
</Accordion>
<Accordion title="Splunk">
You can stream to Splunk using the **Splunk** provider log stream.
<Steps>
<Step title="Obtain Splunk Token">
Navigate to **Settings** > **Data Inputs**.
![splunk data inputs](/images/platform/audit-log-streams/splunk-data-inputs.png)
Click on **HTTP Event Collector**.
![splunk http collector](/images/platform/audit-log-streams/splunk-http-collector.png)
Click on **New Token** in the top left.
![splunk new token](/images/platform/audit-log-streams/splunk-new-token.png)
Provide a name and click **Next**.
![splunk name](/images/platform/audit-log-streams/splunk-name.png)
On the next page, click **Review** and then **Submit** at the top. On the final page you'll see your token.
Copy the **Token Value** and your Splunk hostname from the URL to be used for later.
![splunk credentials](/images/platform/audit-log-streams/splunk-credentials.png)
</Step>
<Step title="Create Audit Log Stream on Infisical">
On Infisical, create a new audit log stream and select the **Splunk** provider option.
Input your **Splunk Hostname** and the **Token** obtained from step 1.
![splunk details](/images/platform/audit-log-streams/splunk-details.png)
Once you're finished, click **Create Log Stream**.
</Step>
</Steps>
</Accordion>
</AccordionGroup>
### Example Log Entry ### Example Log Entry
@@ -117,106 +166,109 @@ Each log entry sent to the external logging provider will follow the same struct
``` ```
### Audit Logs Structure ### Audit Logs Structure
<Warning>
Streamed audit log structure **varies based on provider**, but they all share the audit log fields shown below.
</Warning>
<ParamField path="id" type="string" required> <ParamField path="id" type="string" required>
The unique identifier for the log entry. The unique identifier for the log entry.
</ParamField> </ParamField>
<ParamField path="actor" type="platform | user | service | identity | scimClient | unknownUser" required> <ParamField path="actor" type="platform | user | service | identity | scimClient | unknownUser" required>
The entity responsible for performing or causing the event; this can be a user or service. The entity responsible for performing or causing the event; this can be a user or service.
</ParamField> </ParamField>
<ParamField path="actorMetadata" type="object" required> <ParamField path="actorMetadata" type="object" required>
The metadata associated with the actor. This varies based on the actor type. The metadata associated with the actor. This varies based on the actor type.
<Accordion title="User Metadata"> <AccordionGroup>
This metadata is present when the `actor` field is set to `user`. <Accordion title="User Metadata">
This metadata is present when the `actor` field is set to `user`.
<ParamField path="userId" type="string" required> <ParamField path="userId" type="string" required>
The unique identifier for the actor. The unique identifier for the actor.
</ParamField> </ParamField>
<ParamField path="email" type="string" required> <ParamField path="email" type="string" required>
The email address of the actor. The email address of the actor.
</ParamField> </ParamField>
<ParamField path="username" type="string" required> <ParamField path="username" type="string" required>
The username of the actor. The username of the actor.
</ParamField> </ParamField>
</Accordion> </Accordion>
<Accordion title="Identity Metadata">
This metadata is present when the `actor` field is set to `identity`.
<Accordion title="Identity Metadata"> <ParamField path="identityId" type="string" required>
This metadata is present when the `actor` field is set to `identity`. The unique identifier for the identity.
</ParamField>
<ParamField path="name" type="string" required>
The name of the identity.
</ParamField>
</Accordion>
<Accordion title="Service Token Metadata">
This metadata is present when the `actor` field is set to `service`.
<ParamField path="identityId" type="string" required> <ParamField path="serviceId" type="string" required>
The unique identifier for the identity. The unique identifier for the service.
</ParamField> </ParamField>
<ParamField path="name" type="string" required> <ParamField path="name" type="string" required>
The name of the identity. The name of the service.
</ParamField> </ParamField>
</Accordion> </Accordion>
</AccordionGroup>
<Accordion title="Service Token Metadata">
This metadata is present when the `actor` field is set to `service`.
<ParamField path="serviceId" type="string" required>
The unique identifier for the service.
</ParamField>
<ParamField path="name" type="string" required>
The name of the service.
</ParamField>
</Accordion>
<Note>
If the `actor` field is set to `platform`, `scimClient`, or `unknownUser`, the `actorMetadata` field will be an empty object.
</Note>
<Note>
If the `actor` field is set to `platform`, `scimClient`, or `unknownUser`, the `actorMetadata` field will be an empty object.
</Note>
</ParamField> </ParamField>
<ParamField path="ipAddress" type="string" required> <ParamField path="ipAddress" type="string" required>
The IP address of the actor. The IP address of the actor.
</ParamField> </ParamField>
<ParamField path="eventType" type="string" required> <ParamField path="eventType" type="string" required>
The type of event that occurred. Below you can see a list of possible event types. More event types will be added in the future as we expand our audit logs further. The type of event that occurred. Below you can see a list of possible event types. More event types will be added in the future as we expand our audit logs further.
`get-secrets`, `delete-secrets`, `get-secret`, `create-secret`, `update-secret`, `delete-secret`, `get-workspace-key`, `authorize-integration`, `update-integration-auth`, `unauthorize-integration`, `create-integration`, `delete-integration`, `add-trusted-ip`, `update-trusted-ip`, `delete-trusted-ip`, `create-service-token`, `delete-service-token`, `create-identity`, `update-identity`, `delete-identity`, `login-identity-universal-auth`, `add-identity-universal-auth`, `update-identity-universal-auth`, `get-identity-universal-auth`, `create-identity-universal-auth-client-secret`, `revoke-identity-universal-auth-client-secret`, `get-identity-universal-auth-client-secret`, `create-environment`, `update-environment`, `delete-environment`, `add-workspace-member`, `remove-workspace-member`, `create-folder`, `update-folder`, `delete-folder`, `create-webhook`, `update-webhook-status`, `delete-webhook`, `webhook-triggered`, `get-secret-imports`, `create-secret-import`, `update-secret-import`, `delete-secret-import`, `update-user-workspace-role`, `update-user-workspace-denied-permissions`, `create-certificate-authority`, `get-certificate-authority`, `update-certificate-authority`, `delete-certificate-authority`, `get-certificate-authority-csr`, `get-certificate-authority-cert`, `sign-intermediate`, `import-certificate-authority-cert`, `get-certificate-authority-crl`, `issue-cert`, `get-cert`, `delete-cert`, `revoke-cert`, `get-cert-body`, `create-pki-alert`, `get-pki-alert`, `update-pki-alert`, `delete-pki-alert`, `create-pki-collection`, `get-pki-collection`, `update-pki-collection`, `delete-pki-collection`, `get-pki-collection-items`, `add-pki-collection-item`, `delete-pki-collection-item`, `org-admin-accessed-project`, `create-certificate-template`, `update-certificate-template`, `delete-certificate-template`, `get-certificate-template`, `create-certificate-template-est-config`, `update-certificate-template-est-config`, `get-certificate-template-est-config`, `update-project-slack-config`, `get-project-slack-config`, `integration-synced`, `create-shared-secret`, `delete-shared-secret`, `read-shared-secret`. `get-secrets`, `delete-secrets`, `get-secret`, `create-secret`, `update-secret`, `delete-secret`, `get-workspace-key`, `authorize-integration`, `update-integration-auth`, `unauthorize-integration`, `create-integration`, `delete-integration`, `add-trusted-ip`, `update-trusted-ip`, `delete-trusted-ip`, `create-service-token`, `delete-service-token`, `create-identity`, `update-identity`, `delete-identity`, `login-identity-universal-auth`, `add-identity-universal-auth`, `update-identity-universal-auth`, `get-identity-universal-auth`, `create-identity-universal-auth-client-secret`, `revoke-identity-universal-auth-client-secret`, `get-identity-universal-auth-client-secret`, `create-environment`, `update-environment`, `delete-environment`, `add-workspace-member`, `remove-workspace-member`, `create-folder`, `update-folder`, `delete-folder`, `create-webhook`, `update-webhook-status`, `delete-webhook`, `webhook-triggered`, `get-secret-imports`, `create-secret-import`, `update-secret-import`, `delete-secret-import`, `update-user-workspace-role`, `update-user-workspace-denied-permissions`, `create-certificate-authority`, `get-certificate-authority`, `update-certificate-authority`, `delete-certificate-authority`, `get-certificate-authority-csr`, `get-certificate-authority-cert`, `sign-intermediate`, `import-certificate-authority-cert`, `get-certificate-authority-crl`, `issue-cert`, `get-cert`, `delete-cert`, `revoke-cert`, `get-cert-body`, `create-pki-alert`, `get-pki-alert`, `update-pki-alert`, `delete-pki-alert`, `create-pki-collection`, `get-pki-collection`, `update-pki-collection`, `delete-pki-collection`, `get-pki-collection-items`, `add-pki-collection-item`, `delete-pki-collection-item`, `org-admin-accessed-project`, `create-certificate-template`, `update-certificate-template`, `delete-certificate-template`, `get-certificate-template`, `create-certificate-template-est-config`, `update-certificate-template-est-config`, `get-certificate-template-est-config`, `update-project-slack-config`, `get-project-slack-config`, `integration-synced`, `create-shared-secret`, `delete-shared-secret`, `read-shared-secret`.
</ParamField> </ParamField>
<ParamField path="eventMetadata" type="object" required> <ParamField path="eventMetadata" type="object" required>
The metadata associated with the event. This varies based on the event type. The metadata associated with the event. This varies based on the event type.
</ParamField> </ParamField>
<ParamField path="userAgent" type="string"> <ParamField path="userAgent" type="string">
The user agent of the actor, if applicable. The user agent of the actor, if applicable.
</ParamField> </ParamField>
<ParamField path="userAgentType" type="web | cli | k8-operator | terraform | other | InfisicalPythonSDK | InfisicalNodeSDK"> <ParamField path="userAgentType" type="web | cli | k8-operator | terraform | other | InfisicalPythonSDK | InfisicalNodeSDK">
The type of user agent. The type of user agent.
</ParamField> </ParamField>
<ParamField path="expiresAt" type="string" required> <ParamField path="expiresAt" type="string" required>
The expiration date of the log entry. When this date is reached, the log entry will be deleted from Infisical. The expiration date of the log entry. When this date is reached, the log entry will be deleted from Infisical.
</ParamField> </ParamField>
<ParamField path="createdAt" type="string" required> <ParamField path="createdAt" type="string" required>
The creation date of the log entry. The creation date of the log entry.
</ParamField> </ParamField>
<ParamField path="updatedAt" type="string" required> <ParamField path="updatedAt" type="string" required>
The last update date of the log entry. This is unlikely to be out of sync with the `createdAt` field, as we do not update log entries after they've been created. The last update date of the log entry. This is unlikely to be out of sync with the `createdAt` field, as we do not update log entries after they've been created.
</ParamField> </ParamField>
<ParamField path="orgId" type="string" required> <ParamField path="orgId" type="string" required>
The unique identifier for the organization where the event occurred. The unique identifier for the organization where the event occurred.
</ParamField> </ParamField>
<ParamField path="projectId" type="string"> <ParamField path="projectId" type="string">
The unique identifier for the project where the event occurred. The unique identifier for the project where the event occurred.
The `projectId` field will only be present if the event occurred at the project level, not the organization level. The `projectId` field will only be present if the event occurred at the project level, not the organization level.
</ParamField> </ParamField>
<ParamField path="projectName" type="string"> <ParamField path="projectName" type="string">
The name of the project where the event occurred. The name of the project where the event occurred.
The `projectName` field will only be present if the event occurred at the project level, not the organization level. The `projectName` field will only be present if the event occurred at the project level, not the organization level.
</ParamField> </ParamField>
@@ -8,6 +8,7 @@ Every time a secret change is performed, a new version of the same secret is cre
Such versions can be accessed visually by opening up the [secret sidebar](/documentation/platform/project#drawer) (as seen below) or [retrieved via API](/api-reference/endpoints/secrets/read) Such versions can be accessed visually by opening up the [secret sidebar](/documentation/platform/project#drawer) (as seen below) or [retrieved via API](/api-reference/endpoints/secrets/read)
by specifying the `version` query parameter. by specifying the `version` query parameter.
![secret versioning overview](../../images/platform/secret-versioning-overview.png)
![secret versioning](../../images/platform/secret-versioning.png) ![secret versioning](../../images/platform/secret-versioning.png)
The secret versioning functionality is heavily connected to [Point-in-time Recovery](/documentation/platform/pit-recovery) of secrets in Infisical. The secret versioning functionality is heavily connected to [Point-in-time Recovery](/documentation/platform/pit-recovery) of secrets in Infisical.
Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Some files were not shown because too many files have changed in this diff Show More