feat(infisical-pg: completed webhook and service token

This commit is contained in:
Akhil Mohan
2023-12-28 12:38:36 +05:30
parent 771bec6d6d
commit f5159583ae
25 changed files with 927 additions and 18 deletions

View File

@@ -70,6 +70,9 @@ import {
TSecretVersions,
TSecretVersionsInsert,
TSecretVersionsUpdate,
TServiceTokens,
TServiceTokensInsert,
TServiceTokensUpdate,
TSuperAdmin,
TSuperAdminInsert,
TSuperAdminUpdate,
@@ -81,7 +84,10 @@ import {
TUserEncryptionKeysUpdate,
TUsers,
TUsersInsert,
TUsersUpdate
TUsersUpdate,
TWebhooks,
TWebhooksInsert,
TWebhooksUpdate
} from "@app/db/schemas";
declare module "knex/types/tables" {
@@ -191,6 +197,12 @@ declare module "knex/types/tables" {
TIntegrationsInsert,
TIntegrationsUpdate
>;
[TableName.Webhook]: Knex.CompositeTableType<TWebhooks, TWebhooksInsert, TWebhooksUpdate>;
[TableName.ServiceToken]: Knex.CompositeTableType<
TServiceTokens,
TServiceTokensInsert,
TServiceTokensUpdate
>;
[TableName.IntegrationAuth]: Knex.CompositeTableType<
TIntegrationAuths,
TIntegrationAuthsInsert,

View File

@@ -9,10 +9,10 @@ export async function up(knex: Knex): Promise<void> {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("name").notNullable();
t.string("slug").notNullable();
t.string("tagColor").notNullable();
t.string("color");
t.timestamps(true, true, true);
t.uuid("createdBy").notNullable();
t.foreign("createdBy").references("id").inTable(TableName.Users).onDelete("NO ACTION");
t.uuid("createdBy");
t.foreign("createdBy").references("id").inTable(TableName.Users).onDelete("SET NULL");
t.uuid("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
});

View File

@@ -64,8 +64,8 @@ export async function up(knex: Knex): Promise<void> {
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.IntegrationAuth);
await knex.schema.dropTableIfExists(TableName.Integration);
await knex.schema.dropTableIfExists(TableName.IntegrationAuth);
await dropOnUpdateTrigger(knex, TableName.IntegrationAuth);
await dropOnUpdateTrigger(knex, TableName.Integration);
}

View File

@@ -0,0 +1,32 @@
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.ServiceToken))) {
await knex.schema.createTable(TableName.ServiceToken, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("name").notNullable();
t.jsonb("scopes").notNullable();
t.specificType("permissions", "text[]").notNullable();
t.datetime("lastUsed");
t.datetime("expiresAt");
t.text("secretHash").notNullable();
t.text("encryptedKey");
t.text("iv");
t.text("tag");
t.timestamps(true, true, true);
// user is old one
t.string("createdBy").notNullable();
t.uuid("projectId").notNullable();
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
});
}
await createOnUpdateTrigger(knex, TableName.ServiceToken);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.ServiceToken);
await dropOnUpdateTrigger(knex, TableName.ServiceToken);
}

View File

@@ -0,0 +1,32 @@
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.Webhook))) {
await knex.schema.createTable(TableName.Webhook, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("secretPath").notNullable().defaultTo("/");
t.string("url").notNullable();
t.string("lastStatus");
t.text("lastRunErrorMessage");
t.boolean("isDisabled").defaultTo(false).notNullable();
// webhook signature
t.text("encryptedSecretKey");
t.text("iv");
t.text("tag");
t.string("algorithm");
t.string("keyEncoding");
t.timestamps(true, true, true);
t.uuid("envId").notNullable();
t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE");
});
}
await createOnUpdateTrigger(knex, TableName.Webhook);
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.Webhook);
await dropOnUpdateTrigger(knex, TableName.Webhook);
}

View File

@@ -22,7 +22,9 @@ export * from "./secret-tag-junction";
export * from "./secret-tags";
export * from "./secret-versions";
export * from "./secrets";
export * from "./service-tokens";
export * from "./super-admin";
export * from "./user-actions";
export * from "./user-encryption-keys";
export * from "./users";
export * from "./webhooks";

View File

@@ -27,6 +27,8 @@ export enum TableName {
SecretTag = "secret_tags",
Integration = "integrations",
IntegrationAuth = "integration_auths",
ServiceToken = "service_tokens",
Webhook = "webhooks",
JnSecretTag = "secret_tag_junction",
JnSecretVersionTag = "secret_version_tag_junction"
}
@@ -41,6 +43,13 @@ export const UserDeviceSchema = z
.array()
.default([]);
export const ServiceTokenScopes = z
.object({
environment: z.string(),
secretPath: z.string().default("/")
})
.array();
export enum OrgMembershipRole {
Admin = "admin",
Member = "member",

View File

@@ -11,10 +11,10 @@ export const SecretTagsSchema = z.object({
id: z.string().uuid(),
name: z.string(),
slug: z.string(),
tagColor: z.string(),
color: z.string().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date(),
createdBy: z.string().uuid(),
createdBy: z.string().uuid().nullable().optional(),
projectId: z.string().uuid(),
});

View File

@@ -0,0 +1,29 @@
// 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 ServiceTokensSchema = z.object({
id: z.string().uuid(),
name: z.string(),
scopes: z.unknown(),
permissions: z.string().array(),
lastUsed: z.date().nullable().optional(),
expiresAt: z.date().nullable().optional(),
secretHash: z.string(),
encryptedKey: z.string().nullable().optional(),
iv: z.string().nullable().optional(),
tag: z.string().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date(),
createdBy: z.string(),
projectId: z.string().uuid(),
});
export type TServiceTokens = z.infer<typeof ServiceTokensSchema>;
export type TServiceTokensInsert = Omit<TServiceTokens, TImmutableDBKeys>;
export type TServiceTokensUpdate = Partial<Omit<TServiceTokens, TImmutableDBKeys>>;

View File

@@ -0,0 +1,29 @@
// 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 WebhooksSchema = z.object({
id: z.string().uuid(),
secretPath: z.string().default('/'),
url: z.string(),
lastStatus: z.string().nullable().optional(),
lastRunErrorMessage: z.string().nullable().optional(),
isDisabled: z.boolean().default(false),
encryptedSecretKey: z.string().nullable().optional(),
iv: z.string().nullable().optional(),
tag: z.string().nullable().optional(),
algorithm: z.string().nullable().optional(),
keyEncoding: z.string().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date(),
envId: z.string().uuid(),
});
export type TWebhooks = z.infer<typeof WebhooksSchema>;
export type TWebhooksInsert = Omit<TWebhooks, TImmutableDBKeys>;
export type TWebhooksUpdate = Partial<Omit<TWebhooks, TImmutableDBKeys>>;

View File

@@ -42,6 +42,7 @@ import { secretFolderDalFactory } from "@app/services/secret-folder/secret-folde
import { secretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service";
import { secretImportDalFactory } from "@app/services/secret-import/secret-import-dal";
import { secretImportServiceFactory } from "@app/services/secret-import/secret-import-service";
import { secretTagDalFactory } from "@app/services/secret-tag/secret-tag-dal";
import { TSmtpService } from "@app/services/smtp/smtp-service";
import { superAdminDalFactory } from "@app/services/super-admin/super-admin-dal";
import { superAdminServiceFactory } from "@app/services/super-admin/super-admin-service";
@@ -76,6 +77,7 @@ export const registerRoutes = async (
const projectBotDal = projectBotDalFactory(db);
const secretDal = secretDalFactory(db);
const secretTagDal = secretTagDalFactory(db);
const folderDal = secretFolderDalFactory(db);
const secretImportDal = secretImportDalFactory(db);
const secretVersionDal = secretVersionDalFactory(db);
@@ -156,7 +158,8 @@ export const registerRoutes = async (
secretVersionDal,
secretBlindIndexDal,
permissionService,
secretDal
secretDal,
secretTagDal
});
const folderService = secretFolderServiceFactory({
permissionService,

View File

@@ -1,7 +1,7 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName,TIntegrations } from "@app/db/schemas";
import { TableName, TIntegrations } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
@@ -13,6 +13,7 @@ export const integrationDalFactory = (db: TDbClient) => {
const integrationFindQuery = (tx: Knex, filter: Partial<TIntegrations>) =>
tx(TableName.Integration)
.where(filter)
.join(TableName.Environment, `${TableName.Integration}.envId`, `${TableName.Environment}.id`)
.select(tx.ref("name").withSchema(TableName.Environment).as("envName"))
.select(tx.ref("slug").withSchema(TableName.Environment).as("envSlug"))
.select(tx.ref("id").withSchema(TableName.Environment).as("envId"))

View File

@@ -0,0 +1,48 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
export type TSecretTagDalFactory = ReturnType<typeof secretTagDalFactory>;
export const secretTagDalFactory = (db: TDbClient) => {
const secretTagOrm = ormify(db, TableName.SecretTag);
const secretJnTagOrm = ormify(db, TableName.JnSecretTag);
const findManyTagsById = async (projectId: string, ids: string[], tx?: Knex) => {
try {
const tags = await (tx || db)(TableName.SecretTag).where({ projectId }).whereIn("id", ids);
return tags;
} catch (error) {
throw new DatabaseError({ error, name: "Find all by ids" });
}
};
const deleteTagsManySecret = async (projectId: string, secretIds: string[], tx?: Knex) => {
try {
const tags = await (tx || db)(TableName.JnSecretTag)
.join(
TableName.SecretTag,
`${TableName.JnSecretTag}.${TableName.SecretTag}Id`,
`${TableName.SecretTag}.id`
)
.where("projectId", projectId)
.whereIn("secretsId", secretIds)
.delete()
.returning("*");
return tags;
} catch (error) {
throw new DatabaseError({ error, name: "Find all by ids" });
}
};
return {
...secretTagOrm,
saveTagsToSecret: secretJnTagOrm.insertMany,
deleteTagsToSecret: secretJnTagOrm.delete,
deleteTagsManySecret,
findManyTagsById
};
};

View File

@@ -0,0 +1,74 @@
import { ForbiddenError } from "@casl/ability";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import {
ProjectPermissionActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { BadRequestError } from "@app/lib/errors";
import { TSecretTagDalFactory } from "./secret-tag-dal";
import { TCreateTagDTO, TDeleteTagDTO, TListProjectTagsDTO } from "./secret-tag-types";
type TSecretTagServiceFactoryDep = {
secretTagDal: TSecretTagDalFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
export type TSecretTagServiceFactory = ReturnType<typeof secretTagServiceFactory>;
export const secretTagServiceFactory = ({
secretTagDal,
permissionService
}: TSecretTagServiceFactoryDep) => {
const createTag = async ({ name, slug, actor, color, actorId, projectId }: TCreateTagDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
ProjectPermissionSub.Tags
);
const existingTag = await secretTagDal.findOne({ slug });
if (existingTag) throw new BadRequestError({ message: "Tag already exist" });
const newTag = await secretTagDal.create({
projectId,
name,
slug,
color,
createdBy: actorId
});
return newTag;
};
const deleteTag = async ({ actorId, actor, id }: TDeleteTagDTO) => {
const tag = await secretTagDal.findById(id);
if (!tag) throw new BadRequestError({ message: "Tag doesn't exist" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
tag.projectId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete,
ProjectPermissionSub.Tags
);
const deletedTag = await secretTagDal.deleteById(tag.id);
return deletedTag;
};
const getProjectTags = async ({ actor, actorId, projectId }: TListProjectTagsDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.Tags
);
const tags = await secretTagDal.find({ projectId });
return tags;
};
return { createTag, deleteTag, getProjectTags };
};

View File

@@ -0,0 +1,13 @@
import { TProjectPermission } from "@app/lib/types";
export type TCreateTagDTO = {
name: string;
color: string;
slug: string;
} & TProjectPermission;
export type TDeleteTagDTO = {
id: string;
} & Omit<TProjectPermission, "projectId">;
export type TListProjectTagsDTO = TProjectPermission;

View File

@@ -1,9 +1,9 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { SecretType, TableName, TSecrets, TSecretsInsert,TSecretsUpdate } from "@app/db/schemas";
import { SecretType, TableName, TSecrets, TSecretsInsert, TSecretsUpdate } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
import { mergeOneToManyRelation, ormify, selectAllTableCols } from "@app/lib/knex";
export type TSecretDalFactory = ReturnType<typeof secretDalFactory>;
@@ -33,6 +33,7 @@ export const secretDalFactory = (db: TDbClient) => {
try {
const secs = await (tx || db)(TableName.Secret)
.insert(data as TSecretsInsert[])
.increment("version", 1)
.onConflict("id")
.merge()
.returning("*");
@@ -70,12 +71,39 @@ export const secretDalFactory = (db: TDbClient) => {
const findByFolderId = async (folderId: string, userId?: string, tx?: Knex) => {
try {
const sec = await (tx || db)(TableName.Secret)
const secs = await (tx || db)(TableName.Secret)
.where({ folderId })
.where((bd) => {
bd.whereNull("userId").orWhere({ userId: userId || null });
});
return sec;
})
.join(
TableName.JnSecretTag,
`${TableName.Secret}.id`,
`${TableName.JnSecretTag}.${TableName.Secret}Id`
)
.join(
TableName.SecretTag,
`${TableName.JnSecretTag}.${TableName.SecretTag}Id`,
`${TableName.SecretTag}.id`
)
.select(selectAllTableCols(TableName.Secret))
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
.select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
.select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"))
.select(db.ref("name").withSchema(TableName.SecretTag).as("tagName"));
const formatedSecs = mergeOneToManyRelation(
secs,
"id",
({ tagColor, tagId, tagName, tagSlug, ...data }) => data,
({ tagSlug: slug, tagName: name, tagId: id, tagColor: color }) => ({
id,
slug,
name,
color
}),
"tags"
);
return formatedSecs;
} catch (error) {
throw new DatabaseError({ error, name: "get all secret" });
}

View File

@@ -4,9 +4,9 @@ import {
SecretEncryptionAlgo,
SecretKeyEncoding,
SecretType,
TableName,
TSecretBlindIndexes,
TSecrets
} from "@app/db/schemas";
TSecrets} from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import {
ProjectPermissionActions,
@@ -17,6 +17,7 @@ import { buildSecretBlindIndexFromName } from "@app/lib/crypto";
import { BadRequestError } from "@app/lib/errors";
import { TSecretFolderDalFactory } from "../secret-folder/secret-folder-dal";
import { TSecretTagDalFactory } from "../secret-tag/secret-tag-dal";
import { TSecretBlindIndexDalFactory } from "./secret-blind-index-dal";
import { TSecretDalFactory } from "./secret-dal";
import {
@@ -33,6 +34,7 @@ import { TSecretVersionDalFactory } from "./secret-version-dal";
type TSecretServiceFactoryDep = {
secretDal: TSecretDalFactory;
secretTagDal: TSecretTagDalFactory;
secretVersionDal: TSecretVersionDalFactory;
folderDal: TSecretFolderDalFactory;
secretBlindIndexDal: TSecretBlindIndexDalFactory;
@@ -43,6 +45,7 @@ export type TSecretServiceFactory = ReturnType<typeof secretServiceFactory>;
export const secretServiceFactory = ({
secretDal,
secretTagDal,
secretVersionDal,
folderDal,
secretBlindIndexDal,
@@ -108,6 +111,7 @@ export const secretServiceFactory = ({
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
// check if secret exist by finding the secret blindIndex
const existingSecret = await secretDal.findOne({
secretBlindIndex,
folderId,
@@ -115,6 +119,7 @@ export const secretServiceFactory = ({
userId: inputSecret.type === SecretType.Personal ? actorId : null
});
if (existingSecret) throw new BadRequestError({ message: "Secret already exist" });
// if user creating personal check its shared also exist
if (inputSecret.type === SecretType.Personal) {
const sharedExist = await secretDal.findOne({
@@ -128,6 +133,14 @@ export const secretServiceFactory = ({
});
}
// validate tags
// fetch all tags and if not same count throw error meaning one was invalid tags
const tags = inputSecret.tags
? await secretTagDal.findManyTagsById(projectId, inputSecret.tags)
: [];
if ((inputSecret.tags || []).length !== tags.length)
throw new BadRequestError({ message: "Tag not found" });
const secret = await secretDal.transaction(async (tx) => {
const { secretName, type, ...el } = inputSecret;
const doc = await secretDal.create(
@@ -143,6 +156,10 @@ export const secretServiceFactory = ({
},
tx
);
await secretTagDal.saveTagsToSecret(
tags.map(({ id }) => ({ secretsId: doc.id, secret_tagsId: id })),
tx
);
await secretVersionDal.create(
{
secretBlindIndex,
@@ -161,7 +178,7 @@ export const secretServiceFactory = ({
});
// TODO(akhilmhdh-pg): licence check, posthog service and snapshot
return secret;
return { ...secret, tags };
};
const updateSecret = async ({
@@ -207,6 +224,12 @@ export const secretServiceFactory = ({
}
}
const tags = inputSecret.tags
? await secretTagDal.findManyTagsById(projectId, inputSecret.tags)
: [];
if ((inputSecret.tags || []).length !== tags.length)
throw new BadRequestError({ message: "Tag not found" });
const updatedSecret = await secretDal.transaction(async (tx) => {
const { secretName, ...el } = inputSecret;
const [doc] = await secretDal.update(
@@ -222,6 +245,12 @@ export const secretServiceFactory = ({
},
tx
);
// replace tags
await secretTagDal.deleteTagsToSecret({ secretsId: doc.id }, tx);
await secretTagDal.saveTagsToSecret(
tags.map(({ id }) => ({ secretsId: doc.id, secret_tagsId: id })),
tx
);
const { id, createdAt, updatedAt, ...newVersion } = doc;
await secretVersionDal.create(
{
@@ -290,7 +319,6 @@ export const secretServiceFactory = ({
const folderId = folder.id;
const secrets = await secretDal.findByFolderId(folderId, actorId);
return secrets;
};
@@ -370,6 +398,10 @@ export const secretServiceFactory = ({
);
if (exists.length) throw new BadRequestError({ message: "Secret already exist" });
// get all tags
const tagIds = inputSecrets.flatMap(({ tags = [] }) => tags);
const tags = tagIds.length ? await secretTagDal.findManyTagsById(projectId, tagIds) : [];
const secrets = await secretDal.transaction(async (tx) => {
const newSecrets = await secretDal.insertMany(
inputSecrets.map(({ secretName, type, ...el }) => ({
@@ -384,6 +416,20 @@ export const secretServiceFactory = ({
})),
tx
);
if (tags.length) {
await secretTagDal.saveTagsToSecret(
inputSecrets.flatMap(({ tags: secretTags = [], secretName }) => {
const secret = newSecrets.find(
({ secretBlindIndex }) => secretBlindIndexes[secretName] === secretBlindIndex
);
return secretTags.map((tag) => ({
[`${TableName.SecretTag}Id`]: tag,
[`${TableName.Secret}Id`]: secret?.id || ""
}));
}),
tx
);
}
await secretVersionDal.insertMany(
newSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({
...el,
@@ -478,6 +524,11 @@ export const secretServiceFactory = ({
{}
);
// get all tags
const tagIds = inputSecrets.flatMap(({ tags = [] }) => tags);
const tags = tagIds.length ? await secretTagDal.findManyTagsById(projectId, tagIds) : [];
if (tagIds.length !== tags.length) throw new BadRequestError({ message: "Tag not found" });
const secrets = await secretDal.transaction(async (tx) => {
const newSecrets = await secretDal.bulkUpdate(
inputSecrets.map(({ secretName, type, ...el }) => {
@@ -500,6 +551,20 @@ export const secretServiceFactory = ({
}),
tx
);
await secretTagDal.deleteTagsManySecret(
projectId,
newSecrets.map(({ id }) => id),
tx
);
await secretTagDal.saveTagsToSecret(
inputSecrets.flatMap(({ secretName, tags: secretTags = [] }) =>
secretTags.map((secretTag) => ({
[`${TableName.Secret}Id`]: secretsGroupedByBlindIndex[secretName].id,
[`${TableName.SecretTag}Id`]: secretTag
}))
),
tx
);
await secretVersionDal.insertMany(
newSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({
...el,

View File

@@ -18,6 +18,7 @@ export type TCreateSecretDTO = {
skipMultilineEncoding?: boolean;
secretReminderRepeatDays?: number | null;
secretReminderNote?: string | null;
tags?: string[];
metadata?: {
source?: string;
};
@@ -35,6 +36,7 @@ export type TUpdateSecretDTO = {
secretValueCiphertext: string;
secretValueIV: string;
secretValueTag: string;
tags?: string[];
secretCommentCiphertext?: string;
secretCommentIV?: string;
secretCommentTag?: string;
@@ -77,6 +79,7 @@ export type TCreateBulkSecretDTO = {
secretKeyTag: string;
secretValueCiphertext: string;
secretValueIV: string;
tags?: string[];
secretValueTag: string;
secretCommentCiphertext?: string;
secretCommentIV?: string;
@@ -98,6 +101,7 @@ export type TUpdateBulkSecretDTO = {
secretValueCiphertext?: string;
secretValueIV?: string;
secretValueTag?: string;
tags?: string[];
secretCommentCiphertext?: string;
secretCommentIV?: string;
secretCommentTag?: string;

View File

@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TServiceTokenDalFactory = ReturnType<typeof serviceTokenDalFactory>;
export const serviceTokenDalFactory = (db: TDbClient) => {
const stOrm = ormify(db, TableName.ServiceToken);
return stOrm;
};

View File

@@ -0,0 +1,139 @@
import crypto from "node:crypto";
import { ForbiddenError } from "@casl/ability";
import bcrypt from "bcrypt";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import {
ProjectPermissionActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors";
import { ActorType } from "../auth/auth-type";
import { TProjectEnvDalFactory } from "../project-env/project-env-dal";
import { TServiceTokenDalFactory } from "./service-token-dal";
import {
TCreateServiceTokenDTO,
TDeleteServiceTokenDTO,
TGetServiceTokenInfoDTO,
TProjectServiceTokensDTO
} from "./service-token-types";
type TServiceTokenServiceFactoryDep = {
serviceTokenDal: TServiceTokenDalFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
projectEnvDal: Pick<TProjectEnvDalFactory, "findBySlugs">;
};
export type TServiceTokenServiceFactory = ReturnType<typeof serviceTokenServiceFactory>;
export const serviceTokenServiceFactory = ({
serviceTokenDal,
permissionService,
projectEnvDal
}: TServiceTokenServiceFactoryDep) => {
const createServiceToken = async ({
iv,
tag,
name,
actor,
scopes,
actorId,
projectId,
expiresIn,
permissions,
encryptedKey
}: TCreateServiceTokenDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
ProjectPermissionSub.ServiceTokens
);
const appCfg = getConfig();
// validates env
const scopeEnvs = [...new Set(scopes.map(({ environment }) => environment))];
const inputEnvs = await projectEnvDal.findBySlugs(projectId, scopeEnvs);
if (inputEnvs.length !== scopeEnvs.length)
throw new BadRequestError({ message: "Environment not found" });
const secret = crypto.randomBytes(16).toString("hex");
const secretHash = await bcrypt.hash(secret, appCfg.SALT_ROUNDS);
let expiresAt: Date | null = null;
if (expiresIn) {
expiresAt = new Date();
expiresAt.setSeconds(expiresAt.getSeconds() + expiresIn);
}
const createdBy = actorId;
const serviceToken = await serviceTokenDal.create({
name,
createdBy,
encryptedKey,
iv,
tag,
expiresAt,
secretHash,
permissions,
scopes: JSON.stringify(scopes),
projectId
});
const token = `st.${serviceToken.id.toString()}.${secret}`;
// TODO(akhilmhdh-pg): audit log
return { token, serviceToken };
};
const deleteServiceToken = async ({ actorId, actor, id }: TDeleteServiceTokenDTO) => {
const serviceToken = await serviceTokenDal.findById(id);
if (!serviceToken) throw new BadRequestError({ message: "Token not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
serviceToken.projectId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete,
ProjectPermissionSub.ServiceTokens
);
const deletedServiceToken = await serviceTokenDal.deleteById(id);
return deletedServiceToken;
};
const getServiceToken = async ({ actor, actorId }: TGetServiceTokenInfoDTO) => {
if (actor !== ActorType.SERVICE)
throw new BadRequestError({ message: "Service token not found" });
const serviceToken = await serviceTokenDal.findById(actorId);
if (!serviceToken) throw new BadRequestError({ message: "Token not found" });
return serviceToken;
};
const getProjectServiceTokens = async ({
actorId,
actor,
projectId
}: TProjectServiceTokensDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.ServiceTokens
);
const tokens = await serviceTokenDal.find({ projectId });
return tokens;
};
return {
createServiceToken,
deleteServiceToken,
getServiceToken,
getProjectServiceTokens
};
};

View File

@@ -0,0 +1,19 @@
import { TProjectPermission } from "@app/lib/types";
export type TCreateServiceTokenDTO = {
name: string;
scopes: Array<{ environment: string; secretPath: string }>;
encryptedKey: string;
iv: string;
tag: string;
expiresIn?: number | null;
permissions: ["read" | "write"];
} & TProjectPermission;
export type TGetServiceTokenInfoDTO = Omit<TProjectPermission, "projectId">;
export type TDeleteServiceTokenDTO = {
id: string;
} & Omit<TProjectPermission, "projectId">;
export type TProjectServiceTokensDTO = TProjectPermission;

View File

@@ -0,0 +1,94 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName,TWebhooks } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
export type TWebhookDalFactory = ReturnType<typeof webhookDalFactory>;
export const webhookDalFactory = (db: TDbClient) => {
const webhookOrm = ormify(db, TableName.Webhook);
const webhookFindQuery = (tx: Knex, filter: Partial<TWebhooks>) =>
tx(TableName.Webhook)
.where(filter)
.join(TableName.Environment, `${TableName.Webhook}.envId`, `${TableName.Environment}.id`)
.select(tx.ref("name").withSchema(TableName.Environment).as("envName"))
.select(tx.ref("slug").withSchema(TableName.Environment).as("envSlug"))
.select(tx.ref("id").withSchema(TableName.Environment).as("envId"))
.select(tx.ref("projectId").withSchema(TableName.Environment))
.select(selectAllTableCols(TableName.Integration));
const find = async (filter: Partial<TWebhooks>, tx?: Knex) => {
try {
const docs = await webhookFindQuery(tx || db, filter);
return docs.map(({ envId, envSlug, envName, ...el }) => ({
...el,
environment: {
id: envId,
slug: envSlug,
name: envName
}
}));
} catch (error) {
throw new DatabaseError({ error, name: "Find by id webhook" });
}
};
const findOne = async (filter: Partial<TWebhooks>, tx?: Knex) => {
try {
const doc = await webhookFindQuery(tx || db, filter).first();
if (!doc) return;
const { envName: name, envSlug: slug, envId: id, ...el } = doc;
return { ...el, environment: { id, name, slug } };
} catch (error) {
throw new DatabaseError({ error, name: "Find one webhook" });
}
};
const findById = async (id: string, tx?: Knex) => {
try {
const doc = await webhookFindQuery(tx || db, { id }).first();
if (!doc) return;
const { envName: name, envSlug: slug, envId, ...el } = doc;
return { ...el, environment: { id: envId, name, slug } };
} catch (error) {
throw new DatabaseError({ error, name: "Find by id webhook" });
}
};
const findAllWebhooks = async (
projectId: string,
environment?: string,
secretPath?: string,
tx?: Knex
) => {
try {
const webhooks = await (tx || db)(TableName.Webhook)
.where(`${TableName.Environment}.projectId`, projectId)
.where((qb) => {
if (environment) {
qb.where("slug", environment);
}
if (secretPath) {
qb.where("secretPath", secretPath);
}
})
.join(TableName.Environment, `${TableName.Webhook}.envId`, `${TableName.Environment}.id`)
.select(db.ref("name").withSchema(TableName.Environment).as("envName"))
.select(db.ref("slug").withSchema(TableName.Environment).as("envSlug"))
.select(db.ref("id").withSchema(TableName.Environment).as("envId"))
.select(db.ref("projectId").withSchema(TableName.Environment))
.select(selectAllTableCols(TableName.Integration));
return webhooks;
} catch (error) {
throw new DatabaseError({ error, name: "Find all webhooks" });
}
};
return { ...webhookOrm, findById, findOne, find, findAllWebhooks };
};

View File

@@ -0,0 +1,61 @@
import crypto from "node:crypto";
import { SecretKeyEncoding, TWebhooks } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { decryptSymmetric, decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
export const triggerWebhookRequest = async (
{ url, encryptedSecretKey, iv, tag, keyEncoding }: TWebhooks,
data: Record<string, unknown>
) => {
const headers: Record<string, string> = {};
const payload = { ...data, timestamp: Date.now() };
const appCfg = getConfig();
if (encryptedSecretKey) {
const encryptionKey = appCfg.ENCRYPTION_KEY;
const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY;
let secretKey;
if (rootEncryptionKey && keyEncoding === SecretKeyEncoding.BASE64) {
// case: encoding scheme is base64
secretKey = decryptSymmetric({
ciphertext: encryptedSecretKey,
iv: iv as string,
tag: tag as string,
key: rootEncryptionKey
});
} else if (encryptionKey && keyEncoding === SecretKeyEncoding.UTF8) {
// case: encoding scheme is utf8
secretKey = decryptSymmetric128BitHexKeyUTF8({
ciphertext: encryptedSecretKey,
iv: iv as string,
tag: tag as string,
key: encryptionKey
});
}
if (secretKey) {
const webhookSign = crypto
.createHmac("sha256", secretKey)
.update(JSON.stringify(payload))
.digest("hex");
headers["x-infisical-signature"] = `t=${data.timestamp};${webhookSign}`;
}
}
const req = await request.post(url, payload, { headers });
return req;
};
export const getWebhookPayload = (
eventName: string,
workspaceId: string,
environment: string,
secretPath?: string
) => ({
event: eventName,
project: {
workspaceId,
environment,
secretPath
}
});

View File

@@ -0,0 +1,179 @@
import { ForbiddenError } from "@casl/ability";
import { SecretEncryptionAlgo, SecretKeyEncoding, TWebhooksInsert } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import {
ProjectPermissionActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { getConfig } from "@app/lib/config/env";
import { encryptSymmetric, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
import { BadRequestError } from "@app/lib/errors";
import { TProjectEnvDalFactory } from "../project-env/project-env-dal";
import { TWebhookDalFactory } from "./webhook-dal";
import { getWebhookPayload, triggerWebhookRequest } from "./webhook-fns";
import {
TCreateWebhookDTO,
TDeleteWebhookDTO,
TListWebhookDTO,
TTestWebhookDTO,
TUpdateWebhookDTO
} from "./webhook-types";
type TWebhookServiceFactoryDep = {
webhookDal: TWebhookDalFactory;
projectEnvDal: TProjectEnvDalFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
export type TWebhookServiceFactory = ReturnType<typeof webhookServiceFactory>;
export const webhookServiceFactory = ({
webhookDal,
projectEnvDal,
permissionService
}: TWebhookServiceFactoryDep) => {
const createWebhook = async ({
actor,
actorId,
projectId,
webhookUrl,
environment,
secretPath,
webhookSecretKey
}: TCreateWebhookDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
ProjectPermissionSub.Webhooks
);
const env = await projectEnvDal.findOne({ projectId, slug: environment });
if (!env) throw new BadRequestError({ message: "Env not found" });
const insertDoc: TWebhooksInsert = {
url: webhookUrl,
envId: env.id,
isDisabled: false,
secretPath: secretPath || "/"
};
if (webhookSecretKey) {
const appCfg = getConfig();
const encryptionKey = appCfg.ENCRYPTION_KEY;
const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY;
if (rootEncryptionKey) {
const { ciphertext, iv, tag } = encryptSymmetric(webhookSecretKey, rootEncryptionKey);
insertDoc.encryptedSecretKey = ciphertext;
insertDoc.iv = iv;
insertDoc.tag = tag;
insertDoc.algorithm = SecretEncryptionAlgo.AES_256_GCM;
insertDoc.keyEncoding = SecretKeyEncoding.BASE64;
} else if (encryptionKey) {
const { ciphertext, iv, tag } = encryptSymmetric128BitHexKeyUTF8(
webhookSecretKey,
encryptionKey
);
insertDoc.encryptedSecretKey = ciphertext;
insertDoc.iv = iv;
insertDoc.tag = tag;
insertDoc.algorithm = SecretEncryptionAlgo.AES_256_GCM;
insertDoc.keyEncoding = SecretKeyEncoding.UTF8;
}
}
const webhook = await webhookDal.create(insertDoc);
// TODO(akhilmhdh-pg): add audit log
return webhook;
};
const updateWebhook = async ({ actorId, actor, id, isDisabled }: TUpdateWebhookDTO) => {
const webhook = await webhookDal.findById(id);
if (!webhook) throw new BadRequestError({ message: "Webhook not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
webhook.projectId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
ProjectPermissionSub.Webhooks
);
const updatedWebhook = await webhookDal.updateById(id, { isDisabled });
return updatedWebhook;
};
const deleteWebhook = async ({ id, actor, actorId }: TDeleteWebhookDTO) => {
const webhook = await webhookDal.findById(id);
if (!webhook) throw new BadRequestError({ message: "Webhook not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
webhook.projectId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete,
ProjectPermissionSub.Webhooks
);
const deletedWebhook = await webhookDal.deleteById(id);
return deletedWebhook;
};
const testWebhook = async ({ id, actor, actorId }: TTestWebhookDTO) => {
const webhook = await webhookDal.findById(id);
if (!webhook) throw new BadRequestError({ message: "Webhook not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
webhook.projectId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.Webhooks
);
let webhookError: string | undefined;
try {
await triggerWebhookRequest(
webhook,
getWebhookPayload("test", webhook.projectId, webhook.environment.slug, webhook.secretPath)
);
} catch (err) {
webhookError = (err as Error).message;
}
const isSuccess = !webhookError;
const updatedWebhook = await webhookDal.updateById(webhook.id, {
lastStatus: isSuccess ? "success" : "failed",
lastRunErrorMessage: isSuccess ? null : webhookError
});
return updatedWebhook;
};
const listWebhooks = async ({
actorId,
actor,
projectId,
secretPath,
environment
}: TListWebhookDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.Webhooks
);
return webhookDal.findAllWebhooks(projectId, environment, secretPath);
};
return {
createWebhook,
deleteWebhook,
listWebhooks,
updateWebhook,
testWebhook
};
};

View File

@@ -0,0 +1,26 @@
import { TProjectPermission } from "@app/lib/types";
export type TCreateWebhookDTO = {
environment: string;
secretPath?: string;
webhookUrl: string;
webhookSecretKey?: string;
} & TProjectPermission;
export type TUpdateWebhookDTO = {
id: string;
isDisabled?: boolean;
} & Omit<TProjectPermission, "projectId">;
export type TTestWebhookDTO = {
id: string;
} & Omit<TProjectPermission, "projectId">;
export type TDeleteWebhookDTO = {
id: string;
} & Omit<TProjectPermission, "projectId">;
export type TListWebhookDTO = {
environment?: string;
secretPath?: string;
} & TProjectPermission;