MVP preliminary idea for initial sync behavior

This commit is contained in:
Tuan Dang
2024-03-07 15:25:53 -08:00
368 changed files with 13516 additions and 3496 deletions
+18 -11
View File
@@ -4,6 +4,7 @@ import { TUsers, UserDeviceSchema } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto";
import { BadRequestError } from "@app/lib/errors";
import { getServerCfg } from "@app/services/super-admin/super-admin-service";
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
import { TokenType } from "../auth-token/auth-token-types";
@@ -261,21 +262,27 @@ export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }:
/*
* OAuth2 login for google,github, and other oauth2 provider
* */
const oauth2Login = async ({
email,
firstName,
lastName,
authMethod,
callbackPort,
isSignupAllowed
}: TOauthLoginDTO) => {
const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => {
let user = await userDAL.findUserByEmail(email);
const serverCfg = await getServerCfg();
const appCfg = getConfig();
const isOauthSignUpDisabled = !isSignupAllowed && !user;
if (isOauthSignUpDisabled) throw new BadRequestError({ message: "User signup disabled", name: "Oauth 2 login" });
if (!user) {
user = await userDAL.create({ email, firstName, lastName, authMethods: [authMethod] });
// Create a new user based on oAuth
if (!serverCfg?.allowSignUp) throw new BadRequestError({ message: "Sign up disabled", name: "Oauth 2 login" });
if (serverCfg?.allowedSignUpDomain) {
const domain = email.split("@")[1];
const allowedDomains = serverCfg.allowedSignUpDomain.split(",").map((e) => e.trim());
if (!allowedDomains.includes(domain))
throw new BadRequestError({
message: `Email with a domain (@${domain}) is not supported`,
name: "Oauth 2 login"
});
}
user = await userDAL.create({ email, firstName, lastName, authMethods: [authMethod], isGhost: false });
}
const isLinkingRequired = !user?.authMethods?.includes(authMethod);
const isUserCompleted = user.isAccepted;
@@ -28,5 +28,4 @@ export type TOauthLoginDTO = {
lastName?: string;
authMethod: AuthMethod;
callbackPort?: string;
isSignupAllowed?: boolean;
};
@@ -50,7 +50,7 @@ export const authSignupServiceFactory = ({
throw new Error("Failed to send verification code for complete account");
}
if (!user) {
user = await userDAL.create({ authMethods: [AuthMethod.EMAIL], email });
user = await userDAL.create({ authMethods: [AuthMethod.EMAIL], email, isGhost: false });
}
if (!user) throw new Error("Failed to create user");
+6 -3
View File
@@ -17,21 +17,24 @@ export enum AuthTokenType {
API_KEY = "apiKey",
SERVICE_ACCESS_TOKEN = "serviceAccessToken",
SERVICE_REFRESH_TOKEN = "serviceRefreshToken",
IDENTITY_ACCESS_TOKEN = "identityAccessToken"
IDENTITY_ACCESS_TOKEN = "identityAccessToken",
SCIM_TOKEN = "scimToken"
}
export enum AuthMode {
JWT = "jwt",
SERVICE_TOKEN = "serviceToken",
API_KEY = "apiKey",
IDENTITY_ACCESS_TOKEN = "identityAccessToken"
IDENTITY_ACCESS_TOKEN = "identityAccessToken",
SCIM_TOKEN = "scimToken"
}
export enum ActorType { // would extend to AWS, Azure, ...
USER = "user", // userIdentity
SERVICE = "service",
IDENTITY = "identity",
Machine = "machine"
Machine = "machine",
SCIM_CLIENT = "scimClient"
}
export type AuthModeJwtTokenPayload = {
@@ -54,6 +54,8 @@ export const identityUaServiceFactory = ({
const identityUa = await identityUaDAL.findOne({ clientId });
if (!identityUa) throw new UnauthorizedError();
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId });
checkIPAgainstBlocklist({
ipAddress: ip,
trustedIps: identityUa.clientSecretTrustedIps as TIp[]
@@ -131,7 +133,7 @@ export const identityUaServiceFactory = ({
}
);
return { accessToken, identityUa, validClientSecretInfo, identityAccessToken };
return { accessToken, identityUa, validClientSecretInfo, identityAccessToken, identityMembershipOrg };
};
const attachUa = async ({
@@ -1,10 +1,35 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { TableName, TIntegrationAuths, TIntegrationAuthsUpdate } from "@app/db/schemas";
import { BadRequestError, DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
export type TIntegrationAuthDALFactory = ReturnType<typeof integrationAuthDALFactory>;
export const integrationAuthDALFactory = (db: TDbClient) => {
const integrationAuthOrm = ormify(db, TableName.IntegrationAuth);
return integrationAuthOrm;
const bulkUpdate = async (
data: Array<{ filter: Partial<TIntegrationAuths>; data: TIntegrationAuthsUpdate }>,
tx?: Knex
) => {
try {
const integrationAuths = await Promise.all(
data.map(async ({ filter, data: updateData }) => {
const [doc] = await (tx || db)(TableName.IntegrationAuth).where(filter).update(updateData).returning("*");
if (!doc) throw new BadRequestError({ message: "Failed to update document" });
return doc;
})
);
return integrationAuths;
} catch (error) {
throw new DatabaseError({ error, name: "bulk update secret" });
}
};
return {
...integrationAuthOrm,
bulkUpdate
};
};
@@ -43,6 +43,7 @@ import {
import { getIntegrationOptions, Integrations, IntegrationUrls } from "./integration-list";
import { getTeams } from "./integration-team";
import { exchangeCode, exchangeRefresh } from "./integration-token";
import { access } from "node:fs";
type TIntegrationAuthServiceFactoryDep = {
integrationAuthDAL: TIntegrationAuthDALFactory;
@@ -591,6 +592,7 @@ export const integrationAuthServiceFactory = ({
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
const botKey = await projectBotService.getBotKey(integrationAuth.projectId);
const { accessToken } = await getIntegrationAccessToken(integrationAuth, botKey);
const { data } = await request.get<THerokuPipelineCoupling[]>(
`${IntegrationUrls.HEROKU_API_URL}/pipeline-couplings`,
{
@@ -601,7 +603,7 @@ export const integrationAuthServiceFactory = ({
}
}
);
return data.map(({ app: { id: appId }, stage, pipeline: { id: pipelineId, name } }) => ({
app: { appId },
stage,
@@ -37,6 +37,12 @@ export enum IntegrationType {
OAUTH2 = "oauth2"
}
export enum IntegrationSyncBehavior {
OVERWRITE_TARGET = "overwrite-target",
PREFER_TARGET = "prefer-target",
PREFER_SOURCE = "prefer-source"
}
export enum IntegrationUrls {
// integration oauth endpoints
GCP_TOKEN_URL = "https://oauth2.googleapis.com/token",
@@ -20,11 +20,20 @@ import sodium from "libsodium-wrappers";
import isEqual from "lodash.isequal";
import { z } from "zod";
import { TIntegrationAuths, TIntegrations } from "@app/db/schemas";
import { TIntegrationAuths, TIntegrations, SecretType } from "@app/db/schemas";
import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { Integrations, IntegrationUrls } from "./integration-list";
import { Integrations, IntegrationUrls, IntegrationSyncBehavior } from "./integration-list";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TSecretDALFactory } from "@app/services/secret/secret-dal";
import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal";
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal";
import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal";
import { createManySecretsRawHelper } from "../secret/secret-fns";
const getSecretKeyValuePair = (secrets: Record<string, { value: string | null; comment?: string } | null>) =>
Object.keys(secrets).reduce<Record<string, string | null | undefined>>((prev, key) => {
@@ -441,16 +450,19 @@ const syncSecretsAWSParameterStore = async ({
}) => {
if (!accessId) return;
AWS.config.update({
const config = new AWS.Config({
region: integration.region as string,
accessKeyId: accessId,
secretAccessKey: accessToken
credentials: {
accessKeyId: accessId,
secretAccessKey: accessToken
}
});
const ssm = new AWS.SSM({
apiVersion: "2014-11-06",
region: integration.region as string
});
ssm.config.update(config);
const params = {
Path: integration.path as string,
@@ -514,12 +526,6 @@ const syncSecretsAWSParameterStore = async ({
}
})
);
AWS.config.update({
region: undefined,
accessKeyId: undefined,
secretAccessKey: undefined
});
};
/**
@@ -541,12 +547,6 @@ const syncSecretsAWSSecretManager = async ({
try {
if (!accessId) return;
AWS.config.update({
region: integration.region as string,
accessKeyId: accessId,
secretAccessKey: accessToken
});
secretsManager = new SecretsManagerClient({
region: integration.region as string,
credentials: {
@@ -575,12 +575,6 @@ const syncSecretsAWSSecretManager = async ({
})
);
}
AWS.config.update({
region: undefined,
accessKeyId: undefined,
secretAccessKey: undefined
});
} catch (err) {
if (err instanceof ResourceNotFoundException && secretsManager) {
await secretsManager.send(
@@ -590,26 +584,45 @@ const syncSecretsAWSSecretManager = async ({
})
);
}
AWS.config.update({
region: undefined,
accessKeyId: undefined,
secretAccessKey: undefined
});
}
};
/**
* Sync/push [secrets] to Heroku app named [integration.app]
* server.services...
*/
const syncSecretsHeroku = async ({
projectDAL,
secretDAL,
secretVersionDAL,
secretBlindIndexDAL,
secretTagDAL,
secretVersionTagDAL,
folderDAL,
botKey, // TODO: consider getting botKey inside this fn
projectId,
environment,
secretPath,
integration,
secrets,
accessToken
}: {
projectDAL: TProjectDALFactory;
secretDAL: TSecretDALFactory;
secretVersionDAL: TSecretVersionDALFactory;
secretBlindIndexDAL: TSecretBlindIndexDALFactory;
secretTagDAL: TSecretTagDALFactory;
secretVersionTagDAL: TSecretVersionTagDALFactory;
folderDAL: TSecretFolderDALFactory;
botKey: string;
projectId: string;
environment: string;
secretPath: string;
integration: TIntegrations;
secrets: Record<string, { value: string; comment?: string } | null>;
accessToken: string;
}) => {
const herokuSecrets = (
await request.get(`${IntegrationUrls.HEROKU_API_URL}/apps/${integration.app}/config-vars`, {
headers: {
@@ -619,13 +632,63 @@ const syncSecretsHeroku = async ({
}
})
).data;
let secretsToAdd: { [key: string]: string } = {};
let secretsToUpdate: { [key: string]: string } = {};
const metadata = z.record(z.any()).parse(integration.metadata);
Object.keys(herokuSecrets).forEach((key) => {
if (!(key in secrets)) {
secrets[key] = null;
switch (metadata.syncBehavior) {
case IntegrationSyncBehavior.OVERWRITE_TARGET: {
if (!(key in secrets)) secrets[key] = null;
break;
};
case IntegrationSyncBehavior.PREFER_TARGET: {
secrets[key] = herokuSecrets[key];
if (!(key in secrets)) {
secretsToAdd[key] = herokuSecrets[key];
} else {
if (secrets[key] !== herokuSecrets[key]) {
secretsToUpdate[key] = secrets[key]?.value as string;
}
}
break;
};
case IntegrationSyncBehavior.PREFER_SOURCE: {
if(!(key in secrets)) {
secrets[key] = herokuSecrets[key];
secretsToAdd[key] = herokuSecrets[key];
}
break;
};
default: {
if (!(key in secrets)) secrets[key] = null;
break;
};
}
});
await createManySecretsRawHelper({
botKey,
projectDAL,
secretDAL,
secretVersionDAL,
secretBlindIndexDAL,
secretTagDAL,
secretVersionTagDAL,
folderDAL,
projectId,
environment,
path: secretPath,
secrets: Object.keys(secretsToAdd).map((key) => ({
secretName: key,
secretValue: secretsToAdd[key],
type: SecretType.Shared,
secretComment: ""
}))
});
await request.patch(
`${IntegrationUrls.HEROKU_API_URL}/apps/${integration.app}/config-vars`,
getSecretKeyValuePair(secrets),
@@ -2950,8 +3013,22 @@ const syncSecretsHasuraCloud = async ({
/**
* Sync/push [secrets] to [app] in integration named [integration]
*
* Do this in terms of DAL
*
*/
export const syncIntegrationSecrets = async ({
projectDAL,
secretDAL,
secretVersionDAL,
secretBlindIndexDAL,
secretTagDAL,
secretVersionTagDAL,
folderDAL,
botKey,
projectId,
environment,
secretPath,
integration,
integrationAuth,
secrets,
@@ -2959,6 +3036,17 @@ export const syncIntegrationSecrets = async ({
accessToken,
appendices
}: {
projectDAL: TProjectDALFactory;
secretDAL: TSecretDALFactory;
secretVersionDAL: TSecretVersionDALFactory;
secretBlindIndexDAL: TSecretBlindIndexDALFactory;
secretTagDAL: TSecretTagDALFactory;
secretVersionTagDAL: TSecretVersionTagDALFactory;
folderDAL: TSecretFolderDALFactory;
botKey: string;
projectId: string;
environment: string;
secretPath: string;
integration: TIntegrations;
integrationAuth: TIntegrationAuths;
secrets: Record<string, { value: string; comment?: string }>;
@@ -2999,9 +3087,20 @@ export const syncIntegrationSecrets = async ({
break;
case Integrations.HEROKU:
await syncSecretsHeroku({
projectDAL,
secretDAL,
secretVersionDAL,
secretBlindIndexDAL,
secretTagDAL,
secretVersionTagDAL,
folderDAL,
botKey,
projectId,
environment,
secretPath,
integration,
secrets,
accessToken
accessToken,
});
break;
case Integrations.VERCEL:
+86 -2
View File
@@ -76,7 +76,8 @@ export const orgDALFactory = (db: TDbClient) => {
db.ref("lastName").withSchema(TableName.Users),
db.ref("id").withSchema(TableName.Users).as("userId"),
db.ref("publicKey").withSchema(TableName.UserEncryptionKey)
);
)
.where({ isGhost: false }); // MAKE SURE USER IS NOT A GHOST USER
return members.map(({ email, firstName, lastName, userId, publicKey, ...data }) => ({
...data,
user: { email, firstName, lastName, id: userId, publicKey }
@@ -86,6 +87,79 @@ export const orgDALFactory = (db: TDbClient) => {
}
};
const findOrgMembersByEmail = async (orgId: string, emails: string[]) => {
try {
const members = await db(TableName.OrgMembership)
.where({ orgId })
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
.leftJoin<TUserEncryptionKeys>(
TableName.UserEncryptionKey,
`${TableName.UserEncryptionKey}.userId`,
`${TableName.Users}.id`
)
.select(
db.ref("id").withSchema(TableName.OrgMembership),
db.ref("inviteEmail").withSchema(TableName.OrgMembership),
db.ref("orgId").withSchema(TableName.OrgMembership),
db.ref("role").withSchema(TableName.OrgMembership),
db.ref("roleId").withSchema(TableName.OrgMembership),
db.ref("status").withSchema(TableName.OrgMembership),
db.ref("email").withSchema(TableName.Users),
db.ref("firstName").withSchema(TableName.Users),
db.ref("lastName").withSchema(TableName.Users),
db.ref("id").withSchema(TableName.Users).as("userId"),
db.ref("publicKey").withSchema(TableName.UserEncryptionKey)
)
.where({ isGhost: false })
.whereIn("email", emails);
return members.map(({ email, firstName, lastName, userId, publicKey, ...data }) => ({
...data,
user: { email, firstName, lastName, id: userId, publicKey }
}));
} catch (error) {
throw new DatabaseError({ error, name: "Find all org members" });
}
};
const findOrgGhostUser = async (orgId: string) => {
try {
const member = await db(TableName.OrgMembership)
.where({ orgId })
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
.leftJoin(TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id`)
.select(
db.ref("id").withSchema(TableName.OrgMembership),
db.ref("orgId").withSchema(TableName.OrgMembership),
db.ref("role").withSchema(TableName.OrgMembership),
db.ref("roleId").withSchema(TableName.OrgMembership),
db.ref("status").withSchema(TableName.OrgMembership),
db.ref("email").withSchema(TableName.Users),
db.ref("id").withSchema(TableName.Users).as("userId"),
db.ref("publicKey").withSchema(TableName.UserEncryptionKey)
)
.where({ isGhost: true })
.first();
return member;
} catch (error) {
return null;
}
};
const ghostUserExists = async (orgId: string) => {
try {
const member = await db(TableName.OrgMembership)
.where({ orgId })
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
.leftJoin(TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id`)
.select(db.ref("id").withSchema(TableName.Users).as("userId"))
.where({ isGhost: true })
.first();
return Boolean(member);
} catch (error) {
return false;
}
};
const create = async (dto: TOrganizationsInsert, tx?: Knex) => {
try {
const [organization] = await (tx || db)(TableName.Organization).insert(dto).returning("*");
@@ -165,7 +239,14 @@ export const orgDALFactory = (db: TDbClient) => {
// eslint-disable-next-line
.where(buildFindFilter(filter))
.join(TableName.Users, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`)
.select(selectAllTableCols(TableName.OrgMembership), db.ref("email").withSchema(TableName.Users));
.join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`)
.select(
selectAllTableCols(TableName.OrgMembership),
db.ref("email").withSchema(TableName.Users),
db.ref("firstName").withSchema(TableName.Users),
db.ref("lastName").withSchema(TableName.Users),
db.ref("scimEnabled").withSchema(TableName.Organization)
);
if (limit) void query.limit(limit);
if (offset) void query.offset(offset);
if (sort) {
@@ -184,6 +265,9 @@ export const orgDALFactory = (db: TDbClient) => {
findAllOrgMembers,
findOrgById,
findAllOrgsByUserId,
ghostUserExists,
findOrgMembersByEmail,
findOrgGhostUser,
create,
updateById,
deleteById,
+41
View File
@@ -0,0 +1,41 @@
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
type TDeleteOrgMembership = {
orgMembershipId: string;
orgId: string;
orgDAL: Pick<TOrgDALFactory, "findMembership" | "deleteMembershipById" | "transaction">;
projectDAL: Pick<TProjectDALFactory, "find">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find" | "delete">;
};
export const deleteOrgMembership = async ({
orgMembershipId,
orgId,
orgDAL,
projectDAL,
projectMembershipDAL
}: TDeleteOrgMembership) => {
const membership = await orgDAL.transaction(async (tx) => {
// delete org membership
const orgMembership = await orgDAL.deleteMembershipById(orgMembershipId, orgId, tx);
const projects = await projectDAL.find({ orgId }, { tx });
// delete associated project memberships
await projectMembershipDAL.delete(
{
$in: {
projectId: projects.map((project) => project.id)
},
userId: orgMembership.userId as string
},
tx
);
return orgMembership;
});
return membership;
};
+134 -7
View File
@@ -1,6 +1,8 @@
import { ForbiddenError } from "@casl/ability";
import slugify from "@sindresorhus/slugify";
import crypto from "crypto";
import jwt from "jsonwebtoken";
import { Knex } from "knex";
import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas";
import { TProjects } from "@app/db/schemas/projects";
@@ -11,6 +13,7 @@ import { TSamlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-
import { getConfig } from "@app/lib/config/env";
import { generateAsymmetricKeyPair } from "@app/lib/crypto";
import { generateSymmetricKey, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { generateUserSrpKeys } from "@app/lib/crypto/srp";
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { isDisposableEmail } from "@app/lib/validator";
@@ -19,6 +22,8 @@ import { ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type";
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
import { TokenType } from "../auth-token/auth-token-types";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TUserDALFactory } from "../user/user-dal";
import { TIncidentContactsDALFactory } from "./incident-contacts-dal";
@@ -28,6 +33,7 @@ import { TOrgRoleDALFactory } from "./org-role-dal";
import {
TDeleteOrgMembershipDTO,
TFindAllWorkspacesDTO,
TFindOrgMembersByEmailDTO,
TInviteUserToOrgDTO,
TUpdateOrgDTO,
TUpdateOrgMembershipDTO,
@@ -40,6 +46,8 @@ type TOrgServiceFactoryDep = {
orgRoleDAL: TOrgRoleDALFactory;
userDAL: TUserDALFactory;
projectDAL: TProjectDALFactory;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findProjectMembershipsByUserId" | "delete">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "delete">;
incidentContactDAL: TIncidentContactsDALFactory;
samlConfigDAL: Pick<TSamlConfigDALFactory, "findOne" | "findEnforceableSamlCfg">;
smtpService: TSmtpService;
@@ -61,6 +69,8 @@ export const orgServiceFactory = ({
permissionService,
smtpService,
projectDAL,
projectMembershipDAL,
projectKeyDAL,
tokenService,
orgBotDAL,
licenseService,
@@ -93,6 +103,15 @@ export const orgServiceFactory = ({
return members;
};
const findOrgMembersByEmail = async ({ actor, actorId, orgId, emails }: TFindOrgMembersByEmailDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member);
const members = await orgDAL.findOrgMembersByEmail(orgId, emails);
return members;
};
const findAllWorkspaces = async ({ actor, actorId, actorOrgId, orgId }: TFindAllWorkspacesDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace);
@@ -118,6 +137,54 @@ export const orgServiceFactory = ({
return workspaces.filter((workspace) => organizationWorkspaceIds.has(workspace.id));
};
const addGhostUser = async (orgId: string, tx?: Knex) => {
const email = `sudo-${alphaNumericNanoId(16)}-${orgId}@infisical.com`; // We add a nanoid because the email is unique. And we have to create a new ghost user each time, so we can have access to the private key.
const password = crypto.randomBytes(128).toString("hex");
const user = await userDAL.create(
{
isGhost: true,
authMethods: [AuthMethod.EMAIL],
email,
isAccepted: true
},
tx
);
const encKeys = await generateUserSrpKeys(email, password);
await userDAL.upsertUserEncryptionKey(
user.id,
{
encryptionVersion: 2,
protectedKey: encKeys.protectedKey,
protectedKeyIV: encKeys.protectedKeyIV,
protectedKeyTag: encKeys.protectedKeyTag,
publicKey: encKeys.publicKey,
encryptedPrivateKey: encKeys.encryptedPrivateKey,
iv: encKeys.encryptedPrivateKeyIV,
tag: encKeys.encryptedPrivateKeyTag,
salt: encKeys.salt,
verifier: encKeys.verifier
},
tx
);
const createMembershipData = {
orgId,
userId: user.id,
role: OrgMembershipRole.Admin,
status: OrgMembershipStatus.Accepted
};
await orgDAL.createMembership(createMembershipData, tx);
return {
user,
keys: encKeys
};
};
/*
* Update organization details
* */
@@ -126,16 +193,32 @@ export const orgServiceFactory = ({
actorId,
actorOrgId,
orgId,
data: { name, slug, authEnforced }
data: { name, slug, authEnforced, scimEnabled }
}: TUpdateOrgDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings);
const plan = await licenseService.getPlan(orgId);
if (authEnforced !== undefined) {
if (!plan?.samlSSO)
throw new BadRequestError({
message:
"Failed to enforce/un-enforce SAML SSO due to plan restriction. Upgrade plan to enforce/un-enforce SAML SSO."
});
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
}
if (authEnforced) {
if (scimEnabled !== undefined) {
if (!plan?.scim)
throw new BadRequestError({
message:
"Failed to enable/disable SCIM provisioning due to plan restriction. Upgrade plan to enable/disable SCIM provisioning."
});
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Scim);
}
if (authEnforced || scimEnabled) {
const samlCfg = await samlConfigDAL.findEnforceableSamlCfg(orgId);
if (!samlCfg)
throw new BadRequestError({
@@ -147,7 +230,8 @@ export const orgServiceFactory = ({
const org = await orgDAL.updateById(orgId, {
name,
slug: slug ? slugify(slug) : undefined,
authEnforced
authEnforced,
scimEnabled
});
if (!org) throw new BadRequestError({ name: "Org not found", message: "Organization not found" });
return org;
@@ -321,7 +405,8 @@ export const orgServiceFactory = ({
{
email: inviteeEmail,
isAccepted: false,
authMethods: [AuthMethod.EMAIL]
authMethods: [AuthMethod.EMAIL],
isGhost: false
},
tx
);
@@ -424,10 +509,50 @@ export const orgServiceFactory = ({
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member);
const membership = await orgDAL.deleteMembershipById(membershipId, orgId);
const deletedMembership = await orgDAL.transaction(async (tx) => {
const orgMembership = await orgDAL.deleteMembershipById(membershipId, orgId, tx);
await licenseService.updateSubscriptionOrgMemberCount(orgId);
return membership;
if (!orgMembership.userId) {
await licenseService.updateSubscriptionOrgMemberCount(orgId);
return orgMembership;
}
// Get all the project memberships of the user in the organization
const projectMemberships = await projectMembershipDAL.findProjectMembershipsByUserId(orgId, orgMembership.userId);
// Delete all the project memberships of the user in the organization
await projectMembershipDAL.delete(
{
$in: {
id: projectMemberships.map((membership) => membership.id)
}
},
tx
);
// Get all the project keys of the user in the organization
const projectKeys = await projectKeyDAL.find({
$in: {
projectId: projectMemberships.map((membership) => membership.projectId)
},
receiverId: orgMembership.userId
});
// Delete all the project keys of the user in the organization
await projectKeyDAL.delete(
{
$in: {
id: projectKeys.map((key) => key.id)
}
},
tx
);
await licenseService.updateSubscriptionOrgMemberCount(orgId);
return orgMembership;
});
return deletedMembership;
};
/*
@@ -470,10 +595,12 @@ export const orgServiceFactory = ({
inviteUserToOrganization,
verifyUserToOrg,
updateOrg,
findOrgMembersByEmail,
createOrganization,
deleteOrganizationById,
deleteOrgMembership,
findAllWorkspaces,
addGhostUser,
updateOrgMembership,
// incident contacts
findIncidentContacts,
+8 -1
View File
@@ -30,6 +30,13 @@ export type TVerifyUserToOrgDTO = {
code: string;
};
export type TFindOrgMembersByEmailDTO = {
actor: ActorType;
actorId: string;
orgId: string;
emails: string[];
};
export type TFindAllWorkspacesDTO = {
actor: ActorType;
actorId: string;
@@ -38,5 +45,5 @@ export type TFindAllWorkspacesDTO = {
};
export type TUpdateOrgDTO = {
data: Partial<{ name: string; slug: string; authEnforced: boolean }>;
data: Partial<{ name: string; slug: string; authEnforced: boolean; scimEnabled: boolean }>;
} & TOrgPermission;
@@ -27,5 +27,19 @@ export const projectBotDALFactory = (db: TDbClient) => {
}
};
return { ...projectBotOrm, findOne };
const findProjectByBotId = async (botId: string) => {
try {
const project = await db(TableName.ProjectBot)
.where({ [`${TableName.ProjectBot}.id` as "id"]: botId })
.join(TableName.Project, `${TableName.ProjectBot}.projectId`, `${TableName.Project}.id`)
.select(selectAllTableCols(TableName.Project))
.first();
return project || null;
} catch (error) {
throw new DatabaseError({ error, name: "Find project by bot id" });
}
};
return { ...projectBotOrm, findOne, findProjectByBotId };
};
@@ -1,125 +1,111 @@
import { ForbiddenError } from "@casl/ability";
import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas";
import { ProjectVersion, SecretKeyEncoding } 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 {
decryptAsymmetric,
decryptSymmetric,
decryptSymmetric128BitHexKeyUTF8,
encryptSymmetric,
encryptSymmetric128BitHexKeyUTF8,
generateAsymmetricKeyPair
} from "@app/lib/crypto";
import { decryptAsymmetric, generateAsymmetricKeyPair } from "@app/lib/crypto";
import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { BadRequestError } from "@app/lib/errors";
import { TProjectPermission } from "@app/lib/types";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectBotDALFactory } from "./project-bot-dal";
import { TSetActiveStateDTO } from "./project-bot-types";
import { TFindBotByProjectIdDTO, TGetPrivateKeyDTO, TSetActiveStateDTO } from "./project-bot-types";
type TProjectBotServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
projectDAL: Pick<TProjectDALFactory, "findById">;
projectBotDAL: TProjectBotDALFactory;
};
export type TProjectBotServiceFactory = ReturnType<typeof projectBotServiceFactory>;
export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: TProjectBotServiceFactoryDep) => {
const getBotKey = async (projectId: string) => {
const appCfg = getConfig();
const encryptionKey = appCfg.ENCRYPTION_KEY;
const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY;
export const projectBotServiceFactory = ({
projectBotDAL,
projectDAL,
permissionService
}: TProjectBotServiceFactoryDep) => {
const getBotPrivateKey = ({ bot }: TGetPrivateKeyDTO) =>
infisicalSymmetricDecrypt({
keyEncoding: bot.keyEncoding as SecretKeyEncoding,
iv: bot.iv,
tag: bot.tag,
ciphertext: bot.encryptedPrivateKey
});
const getBotKey = async (projectId: string) => {
const bot = await projectBotDAL.findOne({ projectId });
if (!bot) throw new BadRequestError({ message: "failed to find bot key" });
if (!bot.isActive) throw new BadRequestError({ message: "Bot is not active" });
if (!bot.encryptedProjectKeyNonce || !bot.encryptedProjectKey)
throw new BadRequestError({ message: "Encryption key missing" });
if (rootEncryptionKey && (bot.keyEncoding as SecretKeyEncoding) === SecretKeyEncoding.BASE64) {
const privateKeyBot = decryptSymmetric({
iv: bot.iv,
tag: bot.tag,
ciphertext: bot.encryptedPrivateKey,
key: rootEncryptionKey
});
return decryptAsymmetric({
ciphertext: bot.encryptedProjectKey,
privateKey: privateKeyBot,
nonce: bot.encryptedProjectKeyNonce,
publicKey: bot.sender.publicKey
});
}
if (encryptionKey && (bot.keyEncoding as SecretKeyEncoding) === SecretKeyEncoding.UTF8) {
const privateKeyBot = decryptSymmetric128BitHexKeyUTF8({
iv: bot.iv,
tag: bot.tag,
ciphertext: bot.encryptedPrivateKey,
key: encryptionKey
});
return decryptAsymmetric({
ciphertext: bot.encryptedProjectKey,
privateKey: privateKeyBot,
nonce: bot.encryptedProjectKeyNonce,
publicKey: bot.sender.publicKey
});
}
const botPrivateKey = getBotPrivateKey({ bot });
throw new BadRequestError({
message: "Failed to obtain bot copy of workspace key needed for operation"
return decryptAsymmetric({
ciphertext: bot.encryptedProjectKey,
privateKey: botPrivateKey,
nonce: bot.encryptedProjectKeyNonce,
publicKey: bot.sender.publicKey
});
};
const findBotByProjectId = async ({ actorId, actor, actorOrgId, projectId }: TProjectPermission) => {
const findBotByProjectId = async ({
actorId,
actor,
projectId,
actorOrgId,
privateKey,
botKey,
publicKey
}: TFindBotByProjectIdDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations);
const appCfg = getConfig();
const bot = await projectBotDAL.transaction(async (tx) => {
const doc = await projectBotDAL.findOne({ projectId }, tx);
if (doc) return doc;
const { publicKey, privateKey } = generateAsymmetricKeyPair();
if (appCfg.ROOT_ENCRYPTION_KEY) {
const { iv, tag, ciphertext } = encryptSymmetric(privateKey, appCfg.ROOT_ENCRYPTION_KEY);
return projectBotDAL.create(
{
name: "Infisical Bot",
projectId,
tag,
iv,
encryptedPrivateKey: ciphertext,
isActive: false,
publicKey,
algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.BASE64
},
tx
);
const keys = privateKey && publicKey ? { privateKey, publicKey } : generateAsymmetricKeyPair();
const { iv, tag, ciphertext, encoding, algorithm } = infisicalSymmetricEncypt(keys.privateKey);
const project = await projectDAL.findById(projectId, tx);
if (project.version === ProjectVersion.V2) {
throw new BadRequestError({ message: "Failed to create bot, project is upgraded." });
}
if (appCfg.ENCRYPTION_KEY) {
const { iv, tag, ciphertext } = encryptSymmetric128BitHexKeyUTF8(privateKey, appCfg.ENCRYPTION_KEY);
return projectBotDAL.create(
{
name: "Infisical Bot",
projectId,
tag,
iv,
encryptedPrivateKey: ciphertext,
isActive: false,
publicKey,
algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.UTF8
},
tx
);
}
throw new BadRequestError({ message: "Failed to create bot due to missing encryption key" });
return projectBotDAL.create(
{
name: "Infisical Bot",
projectId,
tag,
iv,
encryptedPrivateKey: ciphertext,
isActive: false,
publicKey: keys.publicKey,
algorithm,
keyEncoding: encoding,
...(botKey && {
encryptedProjectKey: botKey.encryptedKey,
encryptedProjectKeyNonce: botKey.nonce
})
},
tx
);
});
return bot;
};
const findProjectByBotId = async (botId: string) => {
try {
const bot = await projectBotDAL.findProjectByBotId(botId);
return bot;
} catch (e) {
throw new BadRequestError({ message: "Failed to find bot by ID" });
}
};
const setBotActiveState = async ({ actor, botId, botKey, actorId, actorOrgId, isActive }: TSetActiveStateDTO) => {
const bot = await projectBotDAL.findById(botId);
if (!bot) throw new BadRequestError({ message: "Bot not found" });
@@ -127,6 +113,16 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T
const { permission } = await permissionService.getProjectPermission(actor, actorId, bot.projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations);
const project = await projectBotDAL.findProjectByBotId(botId);
if (!project) {
throw new BadRequestError({ message: "Failed to find project by bot ID" });
}
if (project.version === ProjectVersion.V2) {
throw new BadRequestError({ message: "Failed to set bot active for upgraded project. Bot is already active" });
}
if (isActive) {
if (!botKey?.nonce || !botKey?.encryptedKey) {
throw new BadRequestError({ message: "Failed to set bot active - missing bot key" });
@@ -153,6 +149,8 @@ export const projectBotServiceFactory = ({ projectBotDAL, permissionService }: T
return {
findBotByProjectId,
setBotActiveState,
getBotPrivateKey,
findProjectByBotId,
getBotKey
};
};
@@ -1,3 +1,4 @@
import { TProjectBots } from "@app/db/schemas";
import { TProjectPermission } from "@app/lib/types";
export type TSetActiveStateDTO = {
@@ -8,3 +9,16 @@ export type TSetActiveStateDTO = {
};
botId: string;
} & Omit<TProjectPermission, "projectId">;
export type TFindBotByProjectIdDTO = {
privateKey?: string;
publicKey?: string;
botKey?: {
nonce: string;
encryptedKey: string;
};
} & TProjectPermission;
export type TGetPrivateKeyDTO = {
bot: TProjectBots;
};
@@ -1,3 +1,5 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName, TProjectKeys } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
@@ -10,10 +12,11 @@ export const projectKeyDALFactory = (db: TDbClient) => {
const findLatestProjectKey = async (
userId: string,
projectId: string
projectId: string,
tx?: Knex
): Promise<(TProjectKeys & { sender: { publicKey: string } }) | undefined> => {
try {
const projectKey = await db(TableName.ProjectKeys)
const projectKey = await (tx || db)(TableName.ProjectKeys)
.join(TableName.Users, `${TableName.ProjectKeys}.senderId`, `${TableName.Users}.id`)
.join(TableName.UserEncryptionKey, `${TableName.UserEncryptionKey}.userId`, `${TableName.Users}.id`)
.where({ projectId, receiverId: userId })
@@ -29,9 +32,9 @@ export const projectKeyDALFactory = (db: TDbClient) => {
}
};
const findAllProjectUserPubKeys = async (projectId: string) => {
const findAllProjectUserPubKeys = async (projectId: string, tx?: Knex) => {
try {
const pubKeys = await db(TableName.ProjectMembership)
const pubKeys = await (tx || db)(TableName.ProjectMembership)
.where({ projectId })
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
.join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`)
@@ -1,7 +1,7 @@
import { TDbClient } from "@app/db";
import { TableName, TUserEncryptionKeys } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
import { ormify, selectAllTableCols } from "@app/lib/knex";
export type TProjectMembershipDALFactory = ReturnType<typeof projectMembershipDALFactory>;
@@ -24,20 +24,83 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
db.ref("projectId").withSchema(TableName.ProjectMembership),
db.ref("role").withSchema(TableName.ProjectMembership),
db.ref("roleId").withSchema(TableName.ProjectMembership),
db.ref("isGhost").withSchema(TableName.Users),
db.ref("email").withSchema(TableName.Users),
db.ref("publicKey").withSchema(TableName.UserEncryptionKey),
db.ref("firstName").withSchema(TableName.Users),
db.ref("lastName").withSchema(TableName.Users),
db.ref("id").withSchema(TableName.Users).as("userId")
);
return members.map(({ email, firstName, lastName, publicKey, ...data }) => ({
)
.where({ isGhost: false });
return members.map(({ email, firstName, lastName, publicKey, isGhost, ...data }) => ({
...data,
user: { email, firstName, lastName, id: data.userId, publicKey }
user: { email, firstName, lastName, id: data.userId, publicKey, isGhost }
}));
} catch (error) {
throw new DatabaseError({ error, name: "Find all project members" });
}
};
return { ...projectMemberOrm, findAllProjectMembers };
const findProjectGhostUser = async (projectId: string) => {
try {
const ghostUser = await db(TableName.ProjectMembership)
.where({ projectId })
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
.select(selectAllTableCols(TableName.Users))
.where({ isGhost: true })
.first();
return ghostUser;
} catch (error) {
throw new DatabaseError({ error, name: "Find project top-level user" });
}
};
const findMembershipsByEmail = async (projectId: string, emails: string[]) => {
try {
const members = await db(TableName.ProjectMembership)
.where({ projectId })
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
.join<TUserEncryptionKeys>(
TableName.UserEncryptionKey,
`${TableName.UserEncryptionKey}.userId`,
`${TableName.Users}.id`
)
.select(
selectAllTableCols(TableName.ProjectMembership),
db.ref("id").withSchema(TableName.Users).as("userId"),
db.ref("email").withSchema(TableName.Users)
)
.whereIn("email", emails)
.where({ isGhost: false });
return members.map(({ userId, email, ...data }) => ({
...data,
user: { id: userId, email }
}));
} catch (error) {
throw new DatabaseError({ error, name: "Find members by email" });
}
};
const findProjectMembershipsByUserId = async (orgId: string, userId: string) => {
try {
const memberships = await db(TableName.ProjectMembership)
.where({ userId })
.join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`)
.where({ [`${TableName.Project}.orgId` as "orgId"]: orgId })
.select(selectAllTableCols(TableName.ProjectMembership));
return memberships;
} catch (error) {
throw new DatabaseError({ error, name: "Find project memberships by user id" });
}
};
return {
...projectMemberOrm,
findAllProjectMembers,
findProjectGhostUser,
findMembershipsByEmail,
findProjectMembershipsByUserId
};
};
@@ -1,15 +1,28 @@
/* eslint-disable no-await-in-loop */
import { ForbiddenError } from "@casl/ability";
import { OrgMembershipStatus, ProjectMembershipRole, TableName } from "@app/db/schemas";
import {
OrgMembershipStatus,
ProjectMembershipRole,
ProjectVersion,
SecretKeyEncoding,
TableName,
TProjectMemberships,
TUsers
} from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
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 { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
import { BadRequestError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn";
import { ActorType } from "../auth/auth-type";
import { TOrgDALFactory } from "../org/org-dal";
import { TProjectDALFactory } from "../project/project-dal";
import { assignWorkspaceKeysToMembers } from "../project/project-fns";
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
import { TProjectRoleDALFactory } from "../project-role/project-role-dal";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
@@ -17,7 +30,9 @@ import { TUserDALFactory } from "../user/user-dal";
import { TProjectMembershipDALFactory } from "./project-membership-dal";
import {
TAddUsersToWorkspaceDTO,
TDeleteProjectMembershipDTO,
TAddUsersToWorkspaceNonE2EEDTO,
TDeleteProjectMembershipOldDTO,
TDeleteProjectMembershipsDTO,
TGetProjectMembershipDTO,
TInviteUserToProjectDTO,
TUpdateProjectMembershipDTO
@@ -26,11 +41,12 @@ import {
type TProjectMembershipServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
smtpService: TSmtpService;
projectBotDAL: TProjectBotDALFactory;
projectMembershipDAL: TProjectMembershipDALFactory;
userDAL: Pick<TUserDALFactory, "findById" | "findOne">;
userDAL: Pick<TUserDALFactory, "findById" | "findOne" | "findUserByProjectMembershipId" | "find">;
projectRoleDAL: Pick<TProjectRoleDALFactory, "findOne">;
orgDAL: Pick<TOrgDALFactory, "findMembership">;
projectDAL: Pick<TProjectDALFactory, "findById">;
orgDAL: Pick<TOrgDALFactory, "findMembership" | "findOrgMembersByEmail">;
projectDAL: Pick<TProjectDALFactory, "findById" | "findProjectGhostUser" | "transaction">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
};
@@ -42,6 +58,7 @@ export const projectMembershipServiceFactory = ({
projectMembershipDAL,
smtpService,
projectRoleDAL,
projectBotDAL,
orgDAL,
userDAL,
projectDAL,
@@ -55,64 +72,90 @@ export const projectMembershipServiceFactory = ({
return projectMembershipDAL.findAllProjectMembers(projectId);
};
const inviteUserToProject = async ({ actorId, actor, actorOrgId, projectId, email }: TInviteUserToProjectDTO) => {
const inviteUserToProject = async ({ actorId, actor, actorOrgId, projectId, emails }: TInviteUserToProjectDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member);
const invitee = await userDAL.findOne({ email });
if (!invitee || !invitee.isAccepted)
throw new BadRequestError({
message: "Faield to validate invitee",
name: "Invite user to project"
});
const inviteeMembership = await projectMembershipDAL.findOne({
userId: invitee.id,
projectId
});
if (inviteeMembership)
throw new BadRequestError({
message: "Existing member of project",
name: "Invite user to project"
});
const invitees: TUsers[] = [];
const project = await projectDAL.findById(projectId);
const inviteeMembershipOrg = await orgDAL.findMembership({
userId: invitee.id,
orgId: project.orgId,
status: OrgMembershipStatus.Accepted
const users = await userDAL.find({
$in: { email: emails }
});
if (!inviteeMembershipOrg)
throw new BadRequestError({
message: "Failed to validate invitee org membership",
name: "Invite user to project"
await projectDAL.transaction(async (tx) => {
for (const invitee of users) {
if (!invitee.isAccepted)
throw new BadRequestError({
message: "Failed to validate invitee",
name: "Invite user to project"
});
const inviteeMembership = await projectMembershipDAL.findOne(
{
userId: invitee.id,
projectId
},
tx
);
if (inviteeMembership) {
throw new BadRequestError({
message: "Existing member of project",
name: "Invite user to project"
});
}
const inviteeMembershipOrg = await orgDAL.findMembership({
userId: invitee.id,
orgId: project.orgId,
status: OrgMembershipStatus.Accepted
});
if (!inviteeMembershipOrg) {
throw new BadRequestError({
message: "Failed to validate invitee org membership",
name: "Invite user to project"
});
}
await projectMembershipDAL.create(
{
userId: invitee.id,
projectId,
role: ProjectMembershipRole.Member
},
tx
);
invitees.push(invitee);
}
const appCfg = getConfig();
await smtpService.sendMail({
template: SmtpTemplates.WorkspaceInvite,
subjectLine: "Infisical workspace invitation",
recipients: invitees.map((i) => i.email),
substitutions: {
workspaceName: project.name,
callback_url: `${appCfg.SITE_URL}/login`
}
});
});
const latestKey = await projectKeyDAL.findLatestProjectKey(actorId, projectId);
await projectMembershipDAL.create({
userId: invitee.id,
projectId,
role: ProjectMembershipRole.Member
});
const sender = await userDAL.findById(actorId);
const appCfg = getConfig();
await smtpService.sendMail({
template: SmtpTemplates.WorkspaceInvite,
subjectLine: "Infisical workspace invitation",
recipients: [invitee.email],
substitutions: {
inviterFirstName: sender.firstName,
inviterEmail: sender.email,
workspaceName: project.name,
callback_url: `${appCfg.SITE_URL}/login`
}
});
return { invitee, latestKey };
return { invitees, latestKey };
};
const addUsersToProject = async ({ projectId, actorId, actor, actorOrgId, members }: TAddUsersToWorkspaceDTO) => {
const addUsersToProject = async ({
projectId,
actorId,
actor,
actorOrgId,
members,
sendEmails = true
}: TAddUsersToWorkspaceDTO) => {
const project = await projectDAL.findById(projectId);
if (!project) throw new BadRequestError({ message: "Project not found" });
@@ -134,11 +177,16 @@ export const projectMembershipServiceFactory = ({
await projectMembershipDAL.transaction(async (tx) => {
await projectMembershipDAL.insertMany(
orgMembers.map(({ userId }) => ({
projectId,
userId: userId as string,
role: ProjectMembershipRole.Member
})),
orgMembers.map(({ userId, id: membershipId }) => {
const role =
members.find((i) => i.orgMembershipId === membershipId)?.projectRole || ProjectMembershipRole.Member;
return {
projectId,
userId: userId as string,
role
};
}),
tx
);
const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId);
@@ -153,22 +201,134 @@ export const projectMembershipServiceFactory = ({
tx
);
});
const sender = await userDAL.findById(actorId);
const appCfg = getConfig();
await smtpService.sendMail({
template: SmtpTemplates.WorkspaceInvite,
subjectLine: "Infisical workspace invitation",
recipients: orgMembers.map(({ email }) => email).filter(Boolean),
substitutions: {
inviterFirstName: sender.firstName,
inviterEmail: sender.email,
workspaceName: project.name,
callback_url: `${appCfg.SITE_URL}/login`
}
});
if (sendEmails) {
const appCfg = getConfig();
await smtpService.sendMail({
template: SmtpTemplates.WorkspaceInvite,
subjectLine: "Infisical workspace invitation",
recipients: orgMembers.map(({ email }) => email).filter(Boolean),
substitutions: {
workspaceName: project.name,
callback_url: `${appCfg.SITE_URL}/login`
}
});
}
return orgMembers;
};
const addUsersToProjectNonE2EE = async ({
projectId,
actorId,
actor,
emails,
sendEmails = true
}: TAddUsersToWorkspaceNonE2EEDTO) => {
const project = await projectDAL.findById(projectId);
if (!project) throw new BadRequestError({ message: "Project not found" });
if (project.version === ProjectVersion.V1) {
throw new BadRequestError({ message: "Please upgrade your project on your dashboard" });
}
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member);
const orgMembers = await orgDAL.findOrgMembersByEmail(project.orgId, emails);
if (orgMembers.length !== emails.length) throw new BadRequestError({ message: "Some users are not part of org" });
if (!orgMembers.length) return [];
const existingMembers = await projectMembershipDAL.find({
projectId,
$in: { userId: orgMembers.map(({ user }) => user.id).filter(Boolean) }
});
if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" });
const ghostUser = await projectDAL.findProjectGhostUser(projectId);
if (!ghostUser) {
throw new BadRequestError({
message: "Failed to find sudo user"
});
}
const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId);
if (!ghostUserLatestKey) {
throw new BadRequestError({
message: "Failed to find sudo user latest key"
});
}
const bot = await projectBotDAL.findOne({ projectId });
if (!bot) {
throw new BadRequestError({
message: "Failed to find bot"
});
}
const botPrivateKey = infisicalSymmetricDecrypt({
keyEncoding: bot.keyEncoding as SecretKeyEncoding,
iv: bot.iv,
tag: bot.tag,
ciphertext: bot.encryptedPrivateKey
});
const newWsMembers = assignWorkspaceKeysToMembers({
decryptKey: ghostUserLatestKey,
userPrivateKey: botPrivateKey,
members: orgMembers.map((membership) => ({
orgMembershipId: membership.id,
projectMembershipRole: ProjectMembershipRole.Member,
userPublicKey: membership.user.publicKey
}))
});
const members: TProjectMemberships[] = [];
await projectMembershipDAL.transaction(async (tx) => {
const result = await projectMembershipDAL.insertMany(
orgMembers.map(({ user }) => ({
projectId,
userId: user.id,
role: ProjectMembershipRole.Member
})),
tx
);
members.push(...result);
const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId);
await projectKeyDAL.insertMany(
orgMembers.map(({ user, id }) => ({
encryptedKey: encKeyGroupByOrgMembId[id][0].workspaceEncryptedKey,
nonce: encKeyGroupByOrgMembId[id][0].workspaceEncryptedNonce,
senderId: ghostUser.id,
receiverId: user.id,
projectId
})),
tx
);
});
if (sendEmails) {
const appCfg = getConfig();
await smtpService.sendMail({
template: SmtpTemplates.WorkspaceInvite,
subjectLine: "Infisical workspace invitation",
recipients: orgMembers.map(({ user }) => user.email).filter(Boolean),
substitutions: {
workspaceName: project.name,
callback_url: `${appCfg.SITE_URL}/login`
}
});
}
return members;
};
const updateProjectMembership = async ({
actorId,
actor,
@@ -180,6 +340,15 @@ export const projectMembershipServiceFactory = ({
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member);
const membershipUser = await userDAL.findUserByProjectMembershipId(membershipId);
if (membershipUser?.isGhost) {
throw new BadRequestError({
message: "Unauthorized member update",
name: "Update project membership"
});
}
const isCustomRole = !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole);
if (isCustomRole) {
const customRole = await projectRoleDAL.findOne({ slug: role, projectId });
@@ -205,16 +374,26 @@ export const projectMembershipServiceFactory = ({
return membership;
};
// This is old and should be removed later. Its not used anywhere, but it is exposed in our API. So to avoid breaking changes, we are keeping it for now.
const deleteProjectMembership = async ({
actorId,
actor,
actorOrgId,
projectId,
membershipId
}: TDeleteProjectMembershipDTO) => {
}: TDeleteProjectMembershipOldDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Member);
const member = await userDAL.findUserByProjectMembershipId(membershipId);
if (member?.isGhost) {
throw new BadRequestError({
message: "Unauthorized member delete",
name: "Delete project membership"
});
}
const membership = await projectMembershipDAL.transaction(async (tx) => {
const [deletedMembership] = await projectMembershipDAL.delete({ projectId, id: membershipId }, tx);
await projectKeyDAL.delete({ receiverId: deletedMembership.userId, projectId }, tx);
@@ -223,11 +402,74 @@ export const projectMembershipServiceFactory = ({
return membership;
};
const deleteProjectMemberships = async ({
actorId,
actor,
actorOrgId,
projectId,
emails
}: TDeleteProjectMembershipsDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Member);
const project = await projectDAL.findById(projectId);
if (!project) {
throw new BadRequestError({
message: "Project not found",
name: "Delete project membership"
});
}
const projectMembers = await projectMembershipDAL.findMembershipsByEmail(projectId, emails);
if (projectMembers.length !== emails.length) {
throw new BadRequestError({
message: "Some users are not part of project",
name: "Delete project membership"
});
}
if (actor === ActorType.USER && projectMembers.some(({ user }) => user.id === actorId)) {
throw new BadRequestError({
message: "Cannot remove yourself from project",
name: "Delete project membership"
});
}
const memberships = await projectMembershipDAL.transaction(async (tx) => {
const deletedMemberships = await projectMembershipDAL.delete(
{
projectId,
$in: {
id: projectMembers.map(({ id }) => id)
}
},
tx
);
await projectKeyDAL.delete(
{
projectId,
$in: {
receiverId: projectMembers.map(({ user }) => user.id).filter(Boolean)
}
},
tx
);
return deletedMemberships;
});
return memberships;
};
return {
getProjectMemberships,
inviteUserToProject,
updateProjectMembership,
deleteProjectMembership,
addUsersToProjectNonE2EE,
deleteProjectMemberships,
deleteProjectMembership, // TODO: Remove this
addUsersToProject
};
};
@@ -1,9 +1,10 @@
import { ProjectMembershipRole } from "@app/db/schemas";
import { TProjectPermission } from "@app/lib/types";
export type TGetProjectMembershipDTO = TProjectPermission;
export type TInviteUserToProjectDTO = {
email: string;
emails: string[];
} & TProjectPermission;
export type TUpdateProjectMembershipDTO = {
@@ -11,14 +12,25 @@ export type TUpdateProjectMembershipDTO = {
role: string;
} & TProjectPermission;
export type TDeleteProjectMembershipDTO = {
export type TDeleteProjectMembershipOldDTO = {
membershipId: string;
} & TProjectPermission;
export type TDeleteProjectMembershipsDTO = {
emails: string[];
} & TProjectPermission;
export type TAddUsersToWorkspaceDTO = {
sendEmails?: boolean;
members: {
orgMembershipId: string;
workspaceEncryptedKey: string;
workspaceEncryptedNonce: string;
projectRole: ProjectMembershipRole;
}[];
} & TProjectPermission;
export type TAddUsersToWorkspaceNonE2EEDTO = {
sendEmails?: boolean;
emails: string[];
} & TProjectPermission;
+46 -3
View File
@@ -1,6 +1,8 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { ProjectsSchema, TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ProjectsSchema, ProjectUpgradeStatus, ProjectVersion, TableName, TProjectsUpdate } from "@app/db/schemas";
import { BadRequestError, DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
export type TProjectDALFactory = ReturnType<typeof projectDALFactory>;
@@ -52,6 +54,32 @@ export const projectDALFactory = (db: TDbClient) => {
}
};
const findProjectGhostUser = async (projectId: string) => {
try {
const ghostUser = await db(TableName.ProjectMembership)
.where({ projectId })
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
.select(selectAllTableCols(TableName.Users))
.where({ isGhost: true })
.first();
return ghostUser;
} catch (error) {
throw new DatabaseError({ error, name: "Find project top-level user" });
}
};
const setProjectUpgradeStatus = async (projectId: string, status: ProjectUpgradeStatus | null, tx?: Knex) => {
try {
const data: TProjectsUpdate = {
upgradeStatus: status
} as const;
await (tx || db)(TableName.Project).where({ id: projectId }).update(data);
} catch (error) {
throw new DatabaseError({ error, name: "Set project upgrade status" });
}
};
const findAllProjectsByIdentity = async (identityId: string) => {
try {
const workspaces = await db(TableName.IdentityProjectMembership)
@@ -132,10 +160,25 @@ export const projectDALFactory = (db: TDbClient) => {
}
};
const checkProjectUpgradeStatus = async (projectId: string) => {
const project = await projectOrm.findById(projectId);
const upgradeInProgress =
project.upgradeStatus === ProjectUpgradeStatus.InProgress && project.version === ProjectVersion.V1;
if (upgradeInProgress) {
throw new BadRequestError({
message: "Project is currently being upgraded, and secrets cannot be written. Please try again"
});
}
};
return {
...projectOrm,
findAllProjects,
setProjectUpgradeStatus,
findAllProjectsByIdentity,
findProjectById
findProjectGhostUser,
findProjectById,
checkProjectUpgradeStatus
};
};
@@ -0,0 +1,51 @@
import crypto from "crypto";
import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto";
import { AddUserToWsDTO } from "./project-types";
export const assignWorkspaceKeysToMembers = ({ members, decryptKey, userPrivateKey }: AddUserToWsDTO) => {
const plaintextProjectKey = decryptAsymmetric({
ciphertext: decryptKey.encryptedKey,
nonce: decryptKey.nonce,
publicKey: decryptKey.sender.publicKey,
privateKey: userPrivateKey
});
const newWsMembers = members.map(({ orgMembershipId, userPublicKey, projectMembershipRole }) => {
const { ciphertext: inviteeCipherText, nonce: inviteeNonce } = encryptAsymmetric(
plaintextProjectKey,
userPublicKey,
userPrivateKey
);
return {
orgMembershipId,
projectRole: projectMembershipRole,
workspaceEncryptedKey: inviteeCipherText,
workspaceEncryptedNonce: inviteeNonce
};
});
return newWsMembers;
};
type TCreateProjectKeyDTO = {
publicKey: string;
privateKey: string;
plainProjectKey?: string;
};
export const createProjectKey = ({ publicKey, privateKey, plainProjectKey }: TCreateProjectKeyDTO) => {
// 3. Create a random key that we'll use as the project key.
const randomBytes = plainProjectKey || crypto.randomBytes(16).toString("hex");
// 4. Encrypt the project key with the users key pair.
const { ciphertext: encryptedProjectKey, nonce: encryptedProjectKeyIv } = encryptAsymmetric(
randomBytes,
publicKey,
privateKey
);
return { key: encryptedProjectKey, iv: encryptedProjectKeyIv };
};
@@ -0,0 +1,568 @@
/* eslint-disable no-await-in-loop */
import {
IntegrationAuthsSchema,
ProjectMembershipRole,
ProjectUpgradeStatus,
ProjectVersion,
SecretApprovalRequestsSecretsSchema,
SecretKeyEncoding,
SecretsSchema,
SecretVersionsSchema,
TIntegrationAuths,
TSecretApprovalRequestsSecrets,
TSecrets,
TSecretVersions
} from "@app/db/schemas";
import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal";
import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal";
import { RequestState } from "@app/ee/services/secret-approval-request/secret-approval-request-types";
import {
decryptIntegrationAuths,
decryptSecretApprovals,
decryptSecrets,
decryptSecretVersions
} from "@app/lib/crypto";
import {
decryptAsymmetric,
encryptSymmetric128BitHexKeyUTF8,
infisicalSymmetricDecrypt,
infisicalSymmetricEncypt
} from "@app/lib/crypto/encryption";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueJobTypes, TQueueServiceFactory } from "@app/queue";
import { TIntegrationAuthDALFactory } from "../integration-auth/integration-auth-dal";
import { TOrgDALFactory } from "../org/org-dal";
import { TOrgServiceFactory } from "../org/org-service";
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TSecretDALFactory } from "../secret/secret-dal";
import { TSecretVersionDALFactory } from "../secret/secret-version-dal";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TUserDALFactory } from "../user/user-dal";
import { TProjectDALFactory } from "./project-dal";
import { assignWorkspaceKeysToMembers, createProjectKey } from "./project-fns";
export type TProjectQueueFactory = ReturnType<typeof projectQueueFactory>;
type TProjectQueueFactoryDep = {
queueService: TQueueServiceFactory;
secretVersionDAL: Pick<TSecretVersionDALFactory, "find" | "bulkUpdateNoVersionIncrement" | "delete">;
folderDAL: Pick<TSecretFolderDALFactory, "find">;
secretDAL: Pick<TSecretDALFactory, "find" | "bulkUpdateNoVersionIncrement">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "find" | "create" | "delete" | "insertMany">;
secretApprovalRequestDAL: Pick<TSecretApprovalRequestDALFactory, "find">;
secretApprovalSecretDAL: Pick<TSecretApprovalRequestSecretDALFactory, "find" | "bulkUpdateNoVersionIncrement">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne" | "delete" | "create">;
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create">;
integrationAuthDAL: TIntegrationAuthDALFactory;
userDAL: Pick<TUserDALFactory, "findUserEncKeyByUserId">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "find">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "transaction" | "updateById" | "setProjectUpgradeStatus" | "find">;
orgDAL: Pick<TOrgDALFactory, "findMembership">;
};
export const projectQueueFactory = ({
queueService,
secretDAL,
folderDAL,
userDAL,
secretVersionDAL,
integrationAuthDAL,
secretApprovalRequestDAL,
secretApprovalSecretDAL,
projectKeyDAL,
projectBotDAL,
projectEnvDAL,
orgDAL,
projectDAL,
orgService,
projectMembershipDAL
}: TProjectQueueFactoryDep) => {
const upgradeProject = async (dto: TQueueJobTypes["upgrade-project-to-ghost"]["payload"]) => {
await queueService.queue(QueueName.UpgradeProjectToGhost, QueueJobs.UpgradeProjectToGhost, dto, {
attempts: 1,
removeOnComplete: true,
removeOnFail: {
count: 5 // keep the most recent jobs
}
});
};
queueService.start(QueueName.UpgradeProjectToGhost, async ({ data }) => {
try {
const [project] = await projectDAL.find({
id: data.projectId,
version: ProjectVersion.V1
});
const oldProjectKey = await projectKeyDAL.findLatestProjectKey(data.startedByUserId, data.projectId);
if (!project) {
throw new Error("Project not found");
}
if (!oldProjectKey) {
throw new Error("Old project key not found");
}
if (project.upgradeStatus !== ProjectUpgradeStatus.Failed && project.upgradeStatus !== null) {
throw new Error("Project upgrade status is not valid");
}
await projectDAL.setProjectUpgradeStatus(data.projectId, ProjectUpgradeStatus.InProgress); // Set the status to in progress. This is important to prevent multiple upgrades at the same time.
// eslint-disable-next-line no-promise-executor-return
// await new Promise((resolve) => setTimeout(resolve, 50_000));
const userPrivateKey = infisicalSymmetricDecrypt({
keyEncoding: data.encryptedPrivateKey.keyEncoding,
ciphertext: data.encryptedPrivateKey.encryptedKey,
iv: data.encryptedPrivateKey.encryptedKeyIv,
tag: data.encryptedPrivateKey.encryptedKeyTag
});
const decryptedPlainProjectKey = decryptAsymmetric({
ciphertext: oldProjectKey.encryptedKey,
nonce: oldProjectKey.nonce,
publicKey: oldProjectKey.sender.publicKey,
privateKey: userPrivateKey
});
const projectEnvs = await projectEnvDAL.find({
projectId: project.id
});
const projectFolders = await folderDAL.find({
$in: {
envId: projectEnvs.map((env) => env.id)
}
});
// Get all the secrets within the project (as encrypted)
const projectIntegrationAuths = await integrationAuthDAL.find({
projectId: project.id
});
const secrets: TSecrets[] = [];
const secretVersions: TSecretVersions[] = [];
const approvalSecrets: TSecretApprovalRequestsSecrets[] = [];
const folderSecretVersionIdsToDelete: string[] = [];
for (const folder of projectFolders) {
const folderSecrets = await secretDAL.find({ folderId: folder.id });
const folderSecretVersions = await secretVersionDAL.find(
{
folderId: folder.id
},
// Only get the latest 700 secret versions for each folder.
{
limit: 1000,
sort: [["createdAt", "desc"]]
}
);
const deletedSecretVersions = await secretVersionDAL.find(
{
folderId: folder.id
},
{
// Get all the secret versions that are not the latest 700
offset: 1000
}
);
folderSecretVersionIdsToDelete.push(...deletedSecretVersions.map((el) => el.id));
const approvalRequests = await secretApprovalRequestDAL.find({
status: RequestState.Open,
folderId: folder.id
});
const secretApprovals = await secretApprovalSecretDAL.find({
$in: {
requestId: approvalRequests.map((el) => el.id)
}
});
secrets.push(...folderSecrets);
secretVersions.push(...folderSecretVersions);
approvalSecrets.push(...secretApprovals);
}
const decryptedSecrets = decryptSecrets(secrets, userPrivateKey, oldProjectKey);
const decryptedSecretVersions = decryptSecretVersions(secretVersions, userPrivateKey, oldProjectKey);
const decryptedApprovalSecrets = decryptSecretApprovals(approvalSecrets, userPrivateKey, oldProjectKey);
const decryptedIntegrationAuths = decryptIntegrationAuths(projectIntegrationAuths, userPrivateKey, oldProjectKey);
// Get the existing bot and the existing project keys for the members of the project
const existingBot = await projectBotDAL.findOne({ projectId: project.id }).catch(() => null);
const existingProjectKeys = await projectKeyDAL.find({ projectId: project.id });
// TRANSACTION START
await projectDAL.transaction(async (tx) => {
await projectDAL.updateById(project.id, { version: ProjectVersion.V2 }, tx);
// Create a ghost user
const ghostUser = await orgService.addGhostUser(project.orgId, tx);
// Create a project key
const { key: newEncryptedProjectKey, iv: newEncryptedProjectKeyIv } = createProjectKey({
plainProjectKey: decryptedPlainProjectKey,
publicKey: ghostUser.keys.publicKey,
privateKey: ghostUser.keys.plainPrivateKey
});
// Create a new project key for the GHOST
await projectKeyDAL.create(
{
projectId: project.id,
receiverId: ghostUser.user.id,
encryptedKey: newEncryptedProjectKey,
nonce: newEncryptedProjectKeyIv,
senderId: ghostUser.user.id
},
tx
);
// Create a membership for the ghost user
await projectMembershipDAL.create(
{
projectId: project.id,
userId: ghostUser.user.id,
role: ProjectMembershipRole.Admin
},
tx
);
// If a bot already exists, delete it
if (existingBot) {
await projectBotDAL.delete({ id: existingBot.id }, tx);
}
// Delete all the existing project keys
await projectKeyDAL.delete(
{
projectId: project.id,
$in: {
id: existingProjectKeys.map((key) => key.id)
}
},
tx
);
const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.user.id, project.id, tx);
if (!ghostUserLatestKey) {
throw new Error("User latest key not found (V2 Upgrade)");
}
const newProjectMembers: {
encryptedKey: string;
nonce: string;
senderId: string;
receiverId: string;
projectId: string;
}[] = [];
for (const key of existingProjectKeys) {
const user = await userDAL.findUserEncKeyByUserId(key.receiverId);
const [orgMembership] = await orgDAL.findMembership({ userId: key.receiverId, orgId: project.orgId });
if (!user) {
throw new Error(`User with ID ${key.receiverId} was not found during upgrade.`);
}
if (!orgMembership) {
// This can happen. Since we don't remove project memberships and project keys when a user is removed from an org, this is a valid case.
logger.info("User is not in organization", {
userId: key.receiverId,
orgId: project.orgId,
projectId: project.id
});
// eslint-disable-next-line no-continue
continue;
}
const [newMember] = assignWorkspaceKeysToMembers({
decryptKey: ghostUserLatestKey,
userPrivateKey: ghostUser.keys.plainPrivateKey,
members: [
{
userPublicKey: user.publicKey,
orgMembershipId: orgMembership.id,
projectMembershipRole: ProjectMembershipRole.Admin
}
]
});
newProjectMembers.push({
encryptedKey: newMember.workspaceEncryptedKey,
nonce: newMember.workspaceEncryptedNonce,
senderId: ghostUser.user.id,
receiverId: user.id,
projectId: project.id
});
}
// Create project keys for all the old members
await projectKeyDAL.insertMany(newProjectMembers, tx);
// Encrypt the bot private key (which is the same as the ghost user)
const { iv, tag, ciphertext, encoding, algorithm } = infisicalSymmetricEncypt(ghostUser.keys.plainPrivateKey);
// 5. Create a bot for the project
const newBot = await projectBotDAL.create(
{
name: "Infisical Bot (Ghost)",
projectId: project.id,
tag,
iv,
encryptedPrivateKey: ciphertext,
isActive: true,
publicKey: ghostUser.keys.publicKey,
senderId: ghostUser.user.id,
encryptedProjectKey: newEncryptedProjectKey,
encryptedProjectKeyNonce: newEncryptedProjectKeyIv,
algorithm,
keyEncoding: encoding
},
tx
);
const botPrivateKey = infisicalSymmetricDecrypt({
keyEncoding: newBot.keyEncoding as SecretKeyEncoding,
iv: newBot.iv,
tag: newBot.tag,
ciphertext: newBot.encryptedPrivateKey
});
const botKey = decryptAsymmetric({
ciphertext: newBot.encryptedProjectKey!,
privateKey: botPrivateKey,
nonce: newBot.encryptedProjectKeyNonce!,
publicKey: ghostUser.keys.publicKey
});
const updatedSecrets: TSecrets[] = [];
const updatedSecretVersions: TSecretVersions[] = [];
const updatedSecretApprovals: TSecretApprovalRequestsSecrets[] = [];
const updatedIntegrationAuths: TIntegrationAuths[] = [];
for (const rawSecret of decryptedSecrets) {
const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecret.decrypted.secretKey, botKey);
const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecret.decrypted.secretValue || "", botKey);
const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(
rawSecret.decrypted.secretComment || "",
botKey
);
const payload: TSecrets = {
...rawSecret.original,
keyEncoding: SecretKeyEncoding.UTF8,
secretKeyCiphertext: secretKeyEncrypted.ciphertext,
secretKeyIV: secretKeyEncrypted.iv,
secretKeyTag: secretKeyEncrypted.tag,
secretValueCiphertext: secretValueEncrypted.ciphertext,
secretValueIV: secretValueEncrypted.iv,
secretValueTag: secretValueEncrypted.tag,
secretCommentCiphertext: secretCommentEncrypted.ciphertext,
secretCommentIV: secretCommentEncrypted.iv,
secretCommentTag: secretCommentEncrypted.tag
} as const;
if (!SecretsSchema.safeParse(payload).success) {
throw new Error(`Invalid secret payload: ${JSON.stringify(payload)}`);
}
updatedSecrets.push(payload);
}
for (const rawSecretVersion of decryptedSecretVersions) {
const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecretVersion.decrypted.secretKey, botKey);
const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(
rawSecretVersion.decrypted.secretValue || "",
botKey
);
const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(
rawSecretVersion.decrypted.secretComment || "",
botKey
);
const payload: TSecretVersions = {
...rawSecretVersion.original,
keyEncoding: SecretKeyEncoding.UTF8,
secretKeyCiphertext: secretKeyEncrypted.ciphertext,
secretKeyIV: secretKeyEncrypted.iv,
secretKeyTag: secretKeyEncrypted.tag,
secretValueCiphertext: secretValueEncrypted.ciphertext,
secretValueIV: secretValueEncrypted.iv,
secretValueTag: secretValueEncrypted.tag,
secretCommentCiphertext: secretCommentEncrypted.ciphertext,
secretCommentIV: secretCommentEncrypted.iv,
secretCommentTag: secretCommentEncrypted.tag
} as const;
if (!SecretVersionsSchema.safeParse(payload).success) {
throw new Error(`Invalid secret version payload: ${JSON.stringify(payload)}`);
}
updatedSecretVersions.push(payload);
}
for (const rawSecretApproval of decryptedApprovalSecrets) {
const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(rawSecretApproval.decrypted.secretKey, botKey);
const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(
rawSecretApproval.decrypted.secretValue || "",
botKey
);
const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(
rawSecretApproval.decrypted.secretComment || "",
botKey
);
const payload: TSecretApprovalRequestsSecrets = {
...rawSecretApproval.original,
keyEncoding: SecretKeyEncoding.UTF8,
secretKeyCiphertext: secretKeyEncrypted.ciphertext,
secretKeyIV: secretKeyEncrypted.iv,
secretKeyTag: secretKeyEncrypted.tag,
secretValueCiphertext: secretValueEncrypted.ciphertext,
secretValueIV: secretValueEncrypted.iv,
secretValueTag: secretValueEncrypted.tag,
secretCommentCiphertext: secretCommentEncrypted.ciphertext,
secretCommentIV: secretCommentEncrypted.iv,
secretCommentTag: secretCommentEncrypted.tag
} as const;
if (!SecretApprovalRequestsSecretsSchema.safeParse(payload).success) {
throw new Error(`Invalid secret approval payload: ${JSON.stringify(payload)}`);
}
updatedSecretApprovals.push(payload);
}
for (const integrationAuth of decryptedIntegrationAuths) {
const access = encryptSymmetric128BitHexKeyUTF8(integrationAuth.decrypted.access, botKey);
const accessId = encryptSymmetric128BitHexKeyUTF8(integrationAuth.decrypted.accessId, botKey);
const refresh = encryptSymmetric128BitHexKeyUTF8(integrationAuth.decrypted.refresh, botKey);
const payload: TIntegrationAuths = {
...integrationAuth.original,
keyEncoding: SecretKeyEncoding.UTF8,
accessCiphertext: access.ciphertext,
accessIV: access.iv,
accessTag: access.tag,
accessIdCiphertext: accessId.ciphertext,
accessIdIV: accessId.iv,
accessIdTag: accessId.tag,
refreshCiphertext: refresh.ciphertext,
refreshIV: refresh.iv,
refreshTag: refresh.tag
} as const;
if (!IntegrationAuthsSchema.safeParse(payload).success) {
throw new Error(`Invalid integration auth payload: ${JSON.stringify(payload)}`);
}
updatedIntegrationAuths.push(payload);
}
if (updatedSecrets.length !== secrets.length) {
throw new Error("Failed to update some secrets");
}
if (updatedSecretVersions.length !== secretVersions.length) {
throw new Error("Failed to update some secret versions");
}
if (updatedSecretApprovals.length !== approvalSecrets.length) {
throw new Error("Failed to update some secret approvals");
}
if (updatedIntegrationAuths.length !== projectIntegrationAuths.length) {
throw new Error("Failed to update some integration auths");
}
const secretUpdates = await secretDAL.bulkUpdateNoVersionIncrement(updatedSecrets, tx);
const secretVersionUpdates = await secretVersionDAL.bulkUpdateNoVersionIncrement(updatedSecretVersions, tx);
const secretApprovalUpdates = await secretApprovalSecretDAL.bulkUpdateNoVersionIncrement(
updatedSecretApprovals,
tx
);
const integrationAuthUpdates = await integrationAuthDAL.bulkUpdate(
updatedIntegrationAuths.map((el) => ({
filter: { id: el.id },
data: {
...el,
id: undefined
}
})),
tx
);
// Delete all secret versions that are no longer needed. We only store the latest 100 versions for each secret.
await secretVersionDAL.delete(
{
$in: {
id: folderSecretVersionIdsToDelete
}
},
tx
);
if (
secretUpdates.length !== updatedSecrets.length ||
secretVersionUpdates.length !== updatedSecretVersions.length ||
secretApprovalUpdates.length !== updatedSecretApprovals.length ||
integrationAuthUpdates.length !== updatedIntegrationAuths.length
) {
throw new Error("Parts of the upgrade failed. Some secrets were not updated");
}
await projectDAL.setProjectUpgradeStatus(data.projectId, null, tx);
// await new Promise((resolve) => setTimeout(resolve, 15_000));
// throw new Error("Transaction was successful!");
});
} catch (err) {
const [project] = await projectDAL
.find({
id: data.projectId,
version: ProjectVersion.V1
})
.catch(() => [null]);
if (!project) {
logger.error("Failed to upgrade project, because no project was found", data);
} else {
await projectDAL.setProjectUpgradeStatus(data.projectId, ProjectUpgradeStatus.Failed);
logger.error("Failed to upgrade project", err, {
extra: {
project,
jobData: data
}
});
}
throw err;
}
});
queueService.listen(QueueName.UpgradeProjectToGhost, "failed", (job, err) => {
logger.error(err, "Upgrade project failed", job?.data);
});
return {
upgradeProject
};
};
+263 -17
View File
@@ -1,22 +1,40 @@
import { ForbiddenError } from "@casl/ability";
import slugify from "@sindresorhus/slugify";
import { ProjectMembershipRole } from "@app/db/schemas";
import { ProjectMembershipRole, ProjectVersion } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { isAtLeastAsPrivileged } from "@app/lib/casl";
import { getConfig } from "@app/lib/config/env";
import { createSecretBlindIndex } from "@app/lib/crypto";
import { BadRequestError } from "@app/lib/errors";
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TProjectPermission } from "@app/lib/types";
import { ActorType } from "../auth/auth-type";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal";
import { TOrgServiceFactory } from "../org/org-service";
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal";
import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TUserDALFactory } from "../user/user-dal";
import { TProjectDALFactory } from "./project-dal";
import { TCreateProjectDTO, TDeleteProjectDTO, TGetProjectDTO } from "./project-types";
import { assignWorkspaceKeysToMembers, createProjectKey } from "./project-fns";
import { TProjectQueueFactory } from "./project-queue";
import {
TCreateProjectDTO,
TDeleteProjectDTO,
TGetProjectDTO,
TUpdateProjectDTO,
TUpgradeProjectDTO
} from "./project-types";
export const DEFAULT_PROJECT_ENVS = [
{ name: "Development", slug: "dev" },
@@ -26,11 +44,18 @@ export const DEFAULT_PROJECT_ENVS = [
type TProjectServiceFactoryDep = {
projectDAL: TProjectDALFactory;
folderDAL: Pick<TSecretFolderDALFactory, "insertMany">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "insertMany">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create">;
projectQueue: TProjectQueueFactory;
userDAL: TUserDALFactory;
folderDAL: TSecretFolderDALFactory;
projectEnvDAL: Pick<TProjectEnvDALFactory, "insertMany" | "find">;
identityOrgMembershipDAL: TIdentityOrgDALFactory;
identityProjectDAL: TIdentityProjectDALFactory;
projectKeyDAL: Pick<TProjectKeyDALFactory, "create" | "findLatestProjectKey" | "delete" | "find" | "insertMany">;
projectBotDAL: Pick<TProjectBotDALFactory, "create" | "findById" | "delete" | "findOne">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create" | "findProjectGhostUser" | "findOne">;
secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "create">;
permissionService: TPermissionServiceFactory;
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
};
@@ -38,8 +63,15 @@ export type TProjectServiceFactory = ReturnType<typeof projectServiceFactory>;
export const projectServiceFactory = ({
projectDAL,
projectQueue,
projectKeyDAL,
permissionService,
userDAL,
folderDAL,
orgService,
identityProjectDAL,
projectBotDAL,
identityOrgMembershipDAL,
secretBlindIndexDAL,
projectMembershipDAL,
projectEnvDAL,
@@ -48,8 +80,13 @@ export const projectServiceFactory = ({
/*
* Create workspace. Make user the admin
* */
const createProject = async ({ orgId, actor, actorId, actorOrgId, workspaceName }: TCreateProjectDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgId);
const createProject = async ({ orgId, actor, actorId, actorOrgId, workspaceName, slug }: TCreateProjectDTO) => {
const { permission, membership: orgMembership } = await permissionService.getOrgPermission(
actor,
actorId,
orgId,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace);
const appCfg = getConfig();
@@ -64,20 +101,28 @@ export const projectServiceFactory = ({
});
}
const newProject = projectDAL.transaction(async (tx) => {
const results = await projectDAL.transaction(async (tx) => {
const ghostUser = await orgService.addGhostUser(orgId, tx);
const project = await projectDAL.create(
{ name: workspaceName, orgId, slug: slugify(`${workspaceName}-${alphaNumericNanoId(4)}`) },
{
name: workspaceName,
orgId,
slug: slug || slugify(`${workspaceName}-${alphaNumericNanoId(4)}`),
version: ProjectVersion.V2
},
tx
);
// set user as admin member for proeject
// set ghost user as admin of project
await projectMembershipDAL.create(
{
userId: actorId,
userId: ghostUser.user.id,
role: ProjectMembershipRole.Admin,
projectId: project.id
},
tx
);
// generate the blind index for project
await secretBlindIndexDAL.create(
{
@@ -99,18 +144,165 @@ export const projectServiceFactory = ({
envs.map(({ id }) => ({ name: ROOT_FOLDER_NAME, envId: id, version: 1 })),
tx
);
// _id for backward compat
return { ...project, environments: envs, _id: project.id };
// 3. Create a random key that we'll use as the project key.
const { key: encryptedProjectKey, iv: encryptedProjectKeyIv } = createProjectKey({
publicKey: ghostUser.keys.publicKey,
privateKey: ghostUser.keys.plainPrivateKey
});
// 4. Save the project key for the ghost user.
await projectKeyDAL.create(
{
projectId: project.id,
receiverId: ghostUser.user.id,
encryptedKey: encryptedProjectKey,
nonce: encryptedProjectKeyIv,
senderId: ghostUser.user.id
},
tx
);
const { iv, tag, ciphertext, encoding, algorithm } = infisicalSymmetricEncypt(ghostUser.keys.plainPrivateKey);
// 5. Create & a bot for the project
await projectBotDAL.create(
{
name: "Infisical Bot (Ghost)",
projectId: project.id,
tag,
iv,
encryptedProjectKey,
encryptedProjectKeyNonce: encryptedProjectKeyIv,
encryptedPrivateKey: ciphertext,
isActive: true,
publicKey: ghostUser.keys.publicKey,
senderId: ghostUser.user.id,
algorithm,
keyEncoding: encoding
},
tx
);
// Find the ghost users latest key
const latestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.user.id, project.id, tx);
if (!latestKey) {
throw new Error("Latest key not found for user");
}
// If the project is being created by a user, add the user to the project as an admin
if (actor === ActorType.USER) {
// Find public key of user
const user = await userDAL.findUserEncKeyByUserId(actorId);
if (!user) {
throw new Error("User not found");
}
const [projectAdmin] = assignWorkspaceKeysToMembers({
decryptKey: latestKey,
userPrivateKey: ghostUser.keys.plainPrivateKey,
members: [
{
userPublicKey: user.publicKey,
orgMembershipId: orgMembership.id,
projectMembershipRole: ProjectMembershipRole.Admin
}
]
});
// Create a membership for the user
await projectMembershipDAL.create(
{
projectId: project.id,
userId: user.id,
role: projectAdmin.projectRole
},
tx
);
// Create a project key for the user
await projectKeyDAL.create(
{
encryptedKey: projectAdmin.workspaceEncryptedKey,
nonce: projectAdmin.workspaceEncryptedNonce,
senderId: ghostUser.user.id,
receiverId: user.id,
projectId: project.id
},
tx
);
}
// If the project is being created by an identity, add the identity to the project as an admin
else if (actor === ActorType.IDENTITY) {
// Find identity org membership
const identityOrgMembership = await identityOrgMembershipDAL.findOne(
{
identityId: actorId,
orgId: project.orgId
},
tx
);
// If identity org membership not found, throw error
if (!identityOrgMembership) {
throw new BadRequestError({
message: `Failed to find identity with id ${actorId}`
});
}
// Get the role permission for the identity
const { permission: rolePermission, role: customRole } = await permissionService.getOrgPermissionByRole(
ProjectMembershipRole.Admin,
orgId
);
const hasPrivilege = isAtLeastAsPrivileged(permission, rolePermission);
if (!hasPrivilege)
throw new ForbiddenRequestError({
message: "Failed to add identity to project with more privileged role"
});
const isCustomRole = Boolean(customRole);
await identityProjectDAL.create(
{
identityId: actorId,
projectId: project.id,
role: isCustomRole ? ProjectMembershipRole.Custom : ProjectMembershipRole.Admin,
roleId: customRole?.id
},
tx
);
}
return {
...project,
environments: envs,
_id: project.id
};
});
return newProject;
return results;
};
const deleteProject = async ({ actor, actorId, actorOrgId, projectId }: TDeleteProjectDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project);
const deletedProject = await projectDAL.deleteById(projectId);
const deletedProject = await projectDAL.transaction(async (tx) => {
const project = await projectDAL.deleteById(projectId, tx);
const projectGhostUser = await projectMembershipDAL.findProjectGhostUser(projectId).catch(() => null);
// Delete the org membership for the ghost user if it's found.
if (projectGhostUser) {
await userDAL.deleteById(projectGhostUser.id, tx);
}
return project;
});
return deletedProject;
};
@@ -124,6 +316,17 @@ export const projectServiceFactory = ({
return projectDAL.findProjectById(projectId);
};
const updateProject = async ({ projectId, actor, actorId, actorOrgId, update }: TUpdateProjectDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings);
const updatedProject = await projectDAL.updateById(projectId, {
name: update.name,
autoCapitalization: update.autoCapitalization
});
return updatedProject;
};
const toggleAutoCapitalization = async ({
projectId,
actor,
@@ -146,12 +349,55 @@ export const projectServiceFactory = ({
return updatedProject;
};
const upgradeProject = async ({ projectId, actor, actorId, userPrivateKey }: TUpgradeProjectDTO) => {
const { permission, membership } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project);
if (membership?.role !== ProjectMembershipRole.Admin) {
throw new ForbiddenRequestError({
message: "User must be admin"
});
}
const encryptedPrivateKey = infisicalSymmetricEncypt(userPrivateKey);
await projectQueue.upgradeProject({
projectId,
startedByUserId: actorId,
encryptedPrivateKey: {
encryptedKey: encryptedPrivateKey.ciphertext,
encryptedKeyIv: encryptedPrivateKey.iv,
encryptedKeyTag: encryptedPrivateKey.tag,
keyEncoding: encryptedPrivateKey.encoding
}
});
};
const getProjectUpgradeStatus = async ({ projectId, actor, actorId }: TProjectPermission) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets);
const project = await projectDAL.findProjectById(projectId);
if (!project) {
throw new BadRequestError({
message: `Project with id ${projectId} not found`
});
}
return project.upgradeStatus || null;
};
return {
createProject,
deleteProject,
getProjects,
updateProject,
getProjectUpgradeStatus,
getAProject,
toggleAutoCapitalization,
updateName
updateName,
upgradeProject
};
};
@@ -1,3 +1,6 @@
import { ProjectMembershipRole, TProjectKeys } from "@app/db/schemas";
import { TProjectPermission } from "@app/lib/types";
import { ActorType } from "../auth/auth-type";
export type TCreateProjectDTO = {
@@ -6,6 +9,7 @@ export type TCreateProjectDTO = {
actorOrgId?: string;
orgId: string;
workspaceName: string;
slug?: string;
};
export type TDeleteProjectDTO = {
@@ -21,3 +25,24 @@ export type TGetProjectDTO = {
actorOrgId?: string;
projectId: string;
};
export type TUpdateProjectDTO = {
update: {
name?: string;
autoCapitalization?: boolean;
};
} & TProjectPermission;
export type TUpgradeProjectDTO = {
userPrivateKey: string;
} & TProjectPermission;
export type AddUserToWsDTO = {
decryptKey: TProjectKeys & { sender: { publicKey: string } };
userPrivateKey: string;
members: {
orgMembershipId: string;
projectMembershipRole: ProjectMembershipRole;
userPublicKey: string;
}[];
};
@@ -41,13 +41,12 @@ export const fnSecretsFromImports = async ({
environment: importEnv.slug,
environmentInfo: importEnv,
folderId: importedFolders?.[i]?.id,
secrets: importedFolders?.[i]?.id
? importedSecsGroupByFolderId[importedFolders?.[i]?.id as string].map((item) => ({
...item,
environment: importEnv.slug,
workspace: "", // This field should not be used, it's only here to keep the older Python SDK versions backwards compatible with the new Postgres backend.
_id: item.id // The old Python SDK depends on the _id field being returned. We return this to keep the older Python SDK versions backwards compatible with the new Postgres backend.
}))
: []
// this will ensure for cases when secrets are empty. Could be due to missing folder for a path or when emtpy secrets inside a given path
secrets: (importedSecsGroupByFolderId?.[importedFolders?.[i]?.id as string] || []).map((item) => ({
...item,
environment: importEnv.slug,
workspace: "", // This field should not be used, it's only here to keep the older Python SDK versions backwards compatible with the new Postgres backend.
_id: item.id // The old Python SDK depends on the _id field being returned. We return this to keep the older Python SDK versions backwards compatible with the new Postgres backend.
}))
}));
};
@@ -4,6 +4,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError } from "@app/lib/errors";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TSecretDALFactory } from "../secret/secret-dal";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
@@ -21,6 +22,7 @@ type TSecretImportServiceFactoryDep = {
secretImportDAL: TSecretImportDALFactory;
folderDAL: TSecretFolderDALFactory;
secretDAL: Pick<TSecretDALFactory, "find">;
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus">;
projectEnvDAL: TProjectEnvDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
@@ -34,6 +36,7 @@ export const secretImportServiceFactory = ({
projectEnvDAL,
permissionService,
folderDAL,
projectDAL,
secretDAL
}: TSecretImportServiceFactoryDep) => {
const createImport = async ({
@@ -62,6 +65,8 @@ export const secretImportServiceFactory = ({
})
);
await projectDAL.checkProjectUpgradeStatus(projectId);
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create import" });
@@ -19,7 +19,7 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Tags);
const existingTag = await secretTagDAL.findOne({ slug });
const existingTag = await secretTagDAL.findOne({ slug, projectId });
if (existingTag) throw new BadRequestError({ message: "Tag already exist" });
const newTag = await secretTagDAL.create({
+49 -2
View File
@@ -22,7 +22,11 @@ export const secretDALFactory = (db: TDbClient) => {
// the idea is to use postgres specific function
// insert with id this will cause a conflict then merge the data
const bulkUpdate = async (data: Array<{ filter: Partial<TSecrets>; data: TSecretsUpdate }>, tx?: Knex) => {
const bulkUpdate = async (
data: Array<{ filter: Partial<TSecrets>; data: TSecretsUpdate }>,
tx?: Knex
) => {
try {
const secs = await Promise.all(
data.map(async ({ filter, data: updateData }) => {
@@ -41,6 +45,35 @@ export const secretDALFactory = (db: TDbClient) => {
}
};
const bulkUpdateNoVersionIncrement = async (data: TSecrets[], tx?: Knex) => {
try {
const existingSecrets = await secretOrm.find(
{
$in: {
id: data.map((el) => el.id)
}
},
{ tx }
);
if (existingSecrets.length !== data.length) {
throw new BadRequestError({ message: "Some of the secrets do not exist" });
}
if (data.length === 0) return [];
const updatedSecrets = await (tx || db)(TableName.Secret)
.insert(data)
.onConflict("id") // this will cause a conflict then merge the data
.merge() // Merge the data with the existing data
.returning("*");
return updatedSecrets;
} catch (error) {
throw new DatabaseError({ error, name: "bulk update secret" });
}
};
const deleteMany = async (
data: Array<{ blindIndex: string; type: SecretType }>,
folderId: string,
@@ -57,6 +90,12 @@ export const secretDALFactory = (db: TDbClient) => {
type: el.type,
...(el.type === SecretType.Personal ? { userId } : {})
});
if (el.type === SecretType.Shared) {
void bd.orWhere({
secretBlindIndex: el.blindIndex,
type: SecretType.Personal
});
}
});
})
.delete()
@@ -139,5 +178,13 @@ export const secretDALFactory = (db: TDbClient) => {
}
};
return { ...secretOrm, update, bulkUpdate, deleteMany, findByFolderId, findByBlindIndexes };
return {
...secretOrm,
update,
bulkUpdate,
deleteMany,
bulkUpdateNoVersionIncrement,
findByFolderId,
findByBlindIndexes
};
};
+219 -2
View File
@@ -1,12 +1,31 @@
/* eslint-disable no-await-in-loop */
import path from "path";
import { SecretKeyEncoding, TSecretBlindIndexes, TSecrets } from "@app/db/schemas";
import {
SecretKeyEncoding,
TSecretBlindIndexes,
TSecrets,
SecretEncryptionAlgo,
SecretType,
TableName
} from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { buildSecretBlindIndexFromName, decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
import {
buildSecretBlindIndexFromName,
encryptSymmetric128BitHexKeyUTF8,
decryptSymmetric128BitHexKeyUTF8
} from "@app/lib/crypto";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TSecretDALFactory } from "./secret-dal";
import {
TCreateManySecretsRawHelper,
TFnSecretBulkInsert,
TFnSecretBlindIndexCheck
} from "./secret-types";
import { groupBy, unique } from "@app/lib/fn";
import { BadRequestError } from "@app/lib/errors";
export const generateSecretBlindIndexBySalt = async (secretName: string, secretBlindIndexDoc: TSecretBlindIndexes) => {
const appCfg = getConfig();
@@ -228,3 +247,201 @@ export const decryptSecretRaw = (secret: TSecrets & { workspace: string; environ
user: secret.userId
};
};
/**
* Checks and handles secrets using a blind index method.
* The function generates mappings between secret names and their blind indexes, validates user IDs for personal secrets, and retrieves secrets from the database based on their blind indexes.
* For new secrets (isNew = true), it ensures they don't already exist in the database.
* For existing secrets, it verifies their presence in the database.
* If discrepancies are found, errors are thrown. The function returns mappings and the fetched secrets.
*/
export const fnSecretBlindIndexCheck = async ({
inputSecrets,
folderId,
isNew,
userId,
blindIndexCfg,
secretDAL
}: TFnSecretBlindIndexCheck) => {
const blindIndex2KeyName: Record<string, string> = {}; // used at audit log point
const keyName2BlindIndex = await Promise.all(
inputSecrets.map(({ secretName }) => generateSecretBlindIndexBySalt(secretName, blindIndexCfg))
).then((blindIndexes) =>
blindIndexes.reduce<Record<string, string>>((prev, curr, i) => {
// eslint-disable-next-line
prev[inputSecrets[i].secretName] = curr;
blindIndex2KeyName[curr] = inputSecrets[i].secretName;
return prev;
}, {})
);
if (inputSecrets.some(({ type }) => type === SecretType.Personal) && !userId) {
throw new BadRequestError({ message: "Missing user id for personal secret" });
}
const secrets = await secretDAL.findByBlindIndexes(
folderId,
inputSecrets.map(({ secretName, type }) => ({
blindIndex: keyName2BlindIndex[secretName],
type: type || SecretType.Shared
})),
userId
);
if (isNew) {
if (secrets.length) throw new BadRequestError({ message: "Secret already exist" });
} else {
const secretKeysInDB = unique(secrets, (el) => el.secretBlindIndex as string).map(
(el) => blindIndex2KeyName[el.secretBlindIndex as string]
);
const hasUnknownSecretsProvided = secretKeysInDB.length !== inputSecrets.length;
if (hasUnknownSecretsProvided) {
const keysMissingInDB = Object.keys(keyName2BlindIndex).filter((key) => !secretKeysInDB.includes(key));
throw new BadRequestError({
message: `Secret not found: blind index ${keysMissingInDB.join(",")}`
});
}
}
return { blindIndex2KeyName, keyName2BlindIndex, secrets };
};
// these functions are special functions shared by a couple of resources
// used by secret approval, rotation or anywhere in which secret needs to modified
export const fnSecretBulkInsert = async ({ // TODO: Pick types here
folderId,
inputSecrets,
secretDAL,
secretVersionDAL,
secretTagDAL,
secretVersionTagDAL,
tx
}: TFnSecretBulkInsert) => {
const newSecrets = await secretDAL.insertMany(
inputSecrets.map(({ tags, ...el }) => ({ ...el, folderId })),
tx
);
const newSecretGroupByBlindIndex = groupBy(newSecrets, (item) => item.secretBlindIndex as string);
const newSecretTags = inputSecrets.flatMap(({ tags: secretTags = [], secretBlindIndex }) =>
secretTags.map((tag) => ({
[`${TableName.SecretTag}Id` as const]: tag,
[`${TableName.Secret}Id` as const]: newSecretGroupByBlindIndex[secretBlindIndex as string][0].id
}))
);
const secretVersions = await secretVersionDAL.insertMany(
inputSecrets.map(({ tags, ...el }) => ({
...el,
folderId,
secretId: newSecretGroupByBlindIndex[el.secretBlindIndex as string][0].id
})),
tx
);
if (newSecretTags.length) {
const secTags = await secretTagDAL.saveTagsToSecret(newSecretTags, tx);
const secVersionsGroupBySecId = groupBy(secretVersions, (i) => i.secretId);
const newSecretVersionTags = secTags.flatMap(({ secretsId, secret_tagsId }) => ({
[`${TableName.SecretVersion}Id` as const]: secVersionsGroupBySecId[secretsId][0].id,
[`${TableName.SecretTag}Id` as const]: secret_tagsId
}));
await secretVersionTagDAL.insertMany(newSecretVersionTags, tx);
}
return newSecrets.map((secret) => ({ ...secret, _id: secret.id }));
};
export const createManySecretsRawHelper = async ({ // TODO: place on top
projectId,
environment,
path,
secrets,
userId,
botKey, // TODO: consider getting botKey inside this fn
projectDAL,
secretDAL,
secretVersionDAL,
secretBlindIndexDAL,
secretTagDAL,
secretVersionTagDAL,
folderDAL
}: TCreateManySecretsRawHelper) => {
await projectDAL.checkProjectUpgradeStatus(projectId);
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
const blindIndexCfg = await secretBlindIndexDAL.findOne({ projectId });
if (!blindIndexCfg) throw new BadRequestError({ message: "Blind index not found", name: "Update secret" });
const inputSecrets = await Promise.all(
secrets.map(async (secret) => {
const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretName, botKey);
const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretValue || "", botKey);
const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretComment || "", botKey);
if (secret.type === SecretType.Personal) {
const sharedExist = await secretDAL.findOne({
secretBlindIndex: keyName2BlindIndex[secret.secretName],
folderId,
type: SecretType.Shared
});
if (!sharedExist)
throw new BadRequestError({
message: "Failed to create personal secret override for no corresponding shared secret"
});
if (!userId) throw new BadRequestError({ message: "Missing user id for personal secret" });
}
const tags = secret.tags ? await secretTagDAL.findManyTagsById(projectId, secret.tags) : [];
if ((secret.tags || []).length !== tags.length) throw new BadRequestError({ message: "Tag not found" });
return ({
type: secret.type,
userId: secret.type === SecretType.Personal ? userId : null,
secretName: secret.secretName,
secretKeyCiphertext: secretKeyEncrypted.ciphertext,
secretKeyIV: secretKeyEncrypted.iv,
secretKeyTag: secretKeyEncrypted.tag,
secretValueCiphertext: secretValueEncrypted.ciphertext,
secretValueIV: secretValueEncrypted.iv,
secretValueTag: secretValueEncrypted.tag,
secretCommentCiphertext: secretCommentEncrypted.ciphertext,
secretCommentIV: secretCommentEncrypted.iv,
secretCommentTag: secretCommentEncrypted.tag,
skipMultilineEncoding: secret.skipMultilineEncoding,
tags: secret.tags
});
})
);
// insert operation
const { keyName2BlindIndex } = await fnSecretBlindIndexCheck({
inputSecrets,
folderId,
isNew: true,
blindIndexCfg,
secretDAL
});
const newSecrets = await secretDAL.transaction(async (tx) =>
fnSecretBulkInsert({
inputSecrets: inputSecrets.map(({ secretName, ...el }) => ({
...el,
version: 0,
secretBlindIndex: keyName2BlindIndex[secretName],
algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.UTF8
})),
folderId,
secretDAL,
secretVersionDAL,
secretTagDAL,
secretVersionTagDAL,
tx
})
);
return newSecrets;
}
+28 -4
View File
@@ -25,6 +25,11 @@ import { TSecretDALFactory } from "./secret-dal";
import { interpolateSecrets } from "./secret-fns";
import { TCreateSecretReminderDTO, THandleReminderDTO, TRemoveSecretReminderDTO } from "./secret-types";
import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal";
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal";
import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal";
export type TSecretQueueFactory = ReturnType<typeof secretQueueFactory>;
type TSecretQueueFactoryDep = {
@@ -32,15 +37,19 @@ type TSecretQueueFactoryDep = {
integrationDAL: Pick<TIntegrationDALFactory, "findByProjectIdV2">;
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
integrationAuthService: Pick<TIntegrationAuthServiceFactory, "getIntegrationAccessToken">;
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath" | "findByManySecretPath">;
secretDAL: Pick<TSecretDALFactory, "findByFolderId" | "find">;
folderDAL: TSecretFolderDALFactory;
secretDAL: TSecretDALFactory;
secretImportDAL: Pick<TSecretImportDALFactory, "find">;
webhookDAL: Pick<TWebhookDALFactory, "findAllWebhooks" | "transaction" | "update" | "bulkUpdate">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
projectDAL: Pick<TProjectDALFactory, "findById">;
projectDAL: TProjectDALFactory;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers">;
smtpService: TSmtpService;
orgDAL: Pick<TOrgDALFactory, "findOrgByProjectId">;
secretVersionDAL: TSecretVersionDALFactory;
secretBlindIndexDAL: TSecretBlindIndexDALFactory;
secretTagDAL: TSecretTagDALFactory;
secretVersionTagDAL: TSecretVersionTagDALFactory;
};
export type TGetSecrets = {
@@ -62,7 +71,11 @@ export const secretQueueFactory = ({
orgDAL,
smtpService,
projectDAL,
projectMembershipDAL
projectMembershipDAL,
secretVersionDAL,
secretBlindIndexDAL,
secretTagDAL,
secretVersionTagDAL,
}: TSecretQueueFactoryDep) => {
const syncIntegrations = async (dto: TGetSecrets) => {
await queueService.queue(QueueName.IntegrationSync, QueueJobs.IntegrationSync, dto, {
@@ -307,6 +320,17 @@ export const secretQueueFactory = ({
}
await syncIntegrationSecrets({
projectDAL,
secretDAL,
secretVersionDAL,
secretBlindIndexDAL,
secretTagDAL,
secretVersionTagDAL,
folderDAL,
botKey,
projectId,
environment,
secretPath,
integration,
integrationAuth,
secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets,
+141 -94
View File
@@ -11,6 +11,7 @@ import { groupBy, pick } from "@app/lib/fn";
import { logger } from "@app/lib/logger";
import { ActorType } from "../auth/auth-type";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
@@ -18,19 +19,22 @@ import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
import { fnSecretsFromImports } from "../secret-import/secret-import-fns";
import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal";
import { TSecretDALFactory } from "./secret-dal";
import { decryptSecretRaw, generateSecretBlindIndexBySalt } from "./secret-fns";
import {
decryptSecretRaw,
fnSecretBlindIndexCheck,
fnSecretBulkInsert
} from "./secret-fns";
import { TSecretQueueFactory } from "./secret-queue";
import {
TCreateManySecretsRawHelper,
TCreateBulkSecretDTO,
TCreateSecretDTO,
TCreateSecretRawDTO,
TDeleteBulkSecretDTO,
TDeleteSecretDTO,
TDeleteSecretRawDTO,
TFnSecretBlindIndexCheck,
TFnSecretBlindIndexCheckV2,
TFnSecretBulkDelete,
TFnSecretBulkInsert,
TFnSecretBulkUpdate,
TGetASecretDTO,
TGetASecretRawDTO,
@@ -49,6 +53,7 @@ type TSecretServiceFactoryDep = {
secretTagDAL: TSecretTagDALFactory;
secretVersionDAL: TSecretVersionDALFactory;
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath">;
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus">;
secretBlindIndexDAL: TSecretBlindIndexDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
@@ -68,6 +73,7 @@ export const secretServiceFactory = ({
permissionService,
snapshotService,
secretQueueService,
projectDAL,
projectBotService,
secretImportDAL,
secretVersionTagDAL
@@ -92,41 +98,6 @@ export const secretServiceFactory = ({
return secretBlindIndex;
};
// these functions are special functions shared by a couple of resources
// used by secret approval, rotation or anywhere in which secret needs to modified
const fnSecretBulkInsert = async ({ folderId, inputSecrets, tx }: TFnSecretBulkInsert) => {
const newSecrets = await secretDAL.insertMany(
inputSecrets.map(({ tags, ...el }) => ({ ...el, folderId })),
tx
);
const newSecretGroupByBlindIndex = groupBy(newSecrets, (item) => item.secretBlindIndex as string);
const newSecretTags = inputSecrets.flatMap(({ tags: secretTags = [], secretBlindIndex }) =>
secretTags.map((tag) => ({
[`${TableName.SecretTag}Id` as const]: tag,
[`${TableName.Secret}Id` as const]: newSecretGroupByBlindIndex[secretBlindIndex as string][0].id
}))
);
const secretVersions = await secretVersionDAL.insertMany(
inputSecrets.map(({ tags, ...el }) => ({
...el,
folderId,
secretId: newSecretGroupByBlindIndex[el.secretBlindIndex as string][0].id
})),
tx
);
if (newSecretTags.length) {
const secTags = await secretTagDAL.saveTagsToSecret(newSecretTags, tx);
const secVersionsGroupBySecId = groupBy(secretVersions, (i) => i.secretId);
const newSecretVersionTags = secTags.flatMap(({ secretsId, secret_tagsId }) => ({
[`${TableName.SecretVersion}Id` as const]: secVersionsGroupBySecId[secretsId][0].id,
[`${TableName.SecretTag}Id` as const]: secret_tagsId
}));
await secretVersionTagDAL.insertMany(newSecretVersionTags, tx);
}
return newSecrets.map((secret) => ({ ...secret, _id: secret.id }));
};
const fnSecretBulkUpdate = async ({ tx, inputSecrets, folderId, projectId }: TFnSecretBulkUpdate) => {
const newSecrets = await secretDAL.bulkUpdate(
inputSecrets.map(({ filter, data: { tags, ...data } }) => ({
@@ -199,54 +170,6 @@ export const secretServiceFactory = ({
return deletedSecrets;
};
// this is a utility function for secret modification
// this will check given secret name blind index exist or not
// if its a created secret set isNew to true
// thus if these blindindex exist it will throw an error
// vice versa when u need to check for updated secret
// this will also return the blind index grouped by secretName
const fnSecretBlindIndexCheck = async ({
inputSecrets,
folderId,
isNew,
userId,
blindIndexCfg
}: TFnSecretBlindIndexCheck) => {
const blindIndex2KeyName: Record<string, string> = {}; // used at audit log point
const keyName2BlindIndex = await Promise.all(
inputSecrets.map(({ secretName }) => generateSecretBlindIndexBySalt(secretName, blindIndexCfg))
).then((blindIndexes) =>
blindIndexes.reduce<Record<string, string>>((prev, curr, i) => {
// eslint-disable-next-line
prev[inputSecrets[i].secretName] = curr;
blindIndex2KeyName[curr] = inputSecrets[i].secretName;
return prev;
}, {})
);
if (inputSecrets.some(({ type }) => type === SecretType.Personal) && !userId) {
throw new BadRequestError({ message: "Missing user id for personal secret" });
}
const secrets = await secretDAL.findByBlindIndexes(
folderId,
inputSecrets.map(({ secretName, type }) => ({
blindIndex: keyName2BlindIndex[secretName],
type: type || SecretType.Shared
})),
userId
);
if (isNew) {
if (secrets.length) throw new BadRequestError({ message: "Secret already exist" });
} else if (secrets.length !== inputSecrets.length)
throw new BadRequestError({
message: `Secret not found: blind index ${JSON.stringify(keyName2BlindIndex)}`
});
return { blindIndex2KeyName, keyName2BlindIndex, secrets };
};
// this is used when secret blind index already exist
// mainly for secret approval
const fnSecretBlindIndexCheckV2 = async ({ inputSecrets, folderId, userId }: TFnSecretBlindIndexCheckV2) => {
@@ -281,6 +204,8 @@ export const secretServiceFactory = ({
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
await projectDAL.checkProjectUpgradeStatus(projectId);
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
@@ -297,7 +222,8 @@ export const secretServiceFactory = ({
folderId,
isNew: true,
userId: actorId,
blindIndexCfg
blindIndexCfg,
secretDAL
});
// if user creating personal check its shared also exist
@@ -334,6 +260,10 @@ export const secretServiceFactory = ({
tags: inputSecret.tags
}
],
secretDAL,
secretVersionDAL,
secretTagDAL,
secretVersionTagDAL,
tx
})
);
@@ -359,6 +289,12 @@ export const secretServiceFactory = ({
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
await projectDAL.checkProjectUpgradeStatus(projectId);
if (inputSecret.newSecretName === "") {
throw new BadRequestError({ message: "New secret name cannot be empty" });
}
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
@@ -375,7 +311,8 @@ export const secretServiceFactory = ({
folderId,
isNew: false,
blindIndexCfg,
userId: actorId
userId: actorId,
secretDAL
});
if (inputSecret.newSecretName && inputSecret.type === SecretType.Personal) {
throw new BadRequestError({ message: "Personal secret cannot change the key name" });
@@ -387,7 +324,8 @@ export const secretServiceFactory = ({
inputSecrets: [{ secretName: inputSecret.newSecretName }],
folderId,
isNew: true,
blindIndexCfg
blindIndexCfg,
secretDAL
});
newSecretNameBlindIndex = kN2NewBlindIndex[inputSecret.newSecretName];
}
@@ -459,6 +397,8 @@ export const secretServiceFactory = ({
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
await projectDAL.checkProjectUpgradeStatus(projectId);
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
@@ -474,7 +414,8 @@ export const secretServiceFactory = ({
inputSecrets: [{ secretName: inputSecret.secretName }],
folderId,
isNew: false,
blindIndexCfg
blindIndexCfg,
secretDAL
});
const deletedSecret = await secretDAL.transaction(async (tx) =>
@@ -650,6 +591,8 @@ export const secretServiceFactory = ({
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
await projectDAL.checkProjectUpgradeStatus(projectId);
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
@@ -661,7 +604,8 @@ export const secretServiceFactory = ({
inputSecrets,
folderId,
isNew: true,
blindIndexCfg
blindIndexCfg,
secretDAL
});
// get all tags
@@ -680,6 +624,10 @@ export const secretServiceFactory = ({
keyEncoding: SecretKeyEncoding.UTF8
})),
folderId,
secretDAL,
secretVersionDAL,
secretTagDAL,
secretVersionTagDAL,
tx
})
);
@@ -705,6 +653,8 @@ export const secretServiceFactory = ({
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
await projectDAL.checkProjectUpgradeStatus(projectId);
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
@@ -716,7 +666,8 @@ export const secretServiceFactory = ({
inputSecrets,
folderId,
isNew: false,
blindIndexCfg
blindIndexCfg,
secretDAL
});
// now find any secret that needs to update its name
@@ -726,7 +677,8 @@ export const secretServiceFactory = ({
inputSecrets: nameUpdatedSecrets,
folderId,
isNew: true,
blindIndexCfg
blindIndexCfg,
secretDAL
});
// get all tags
@@ -776,6 +728,8 @@ export const secretServiceFactory = ({
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
await projectDAL.checkProjectUpgradeStatus(projectId);
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
@@ -787,7 +741,8 @@ export const secretServiceFactory = ({
inputSecrets,
folderId,
isNew: false,
blindIndexCfg
blindIndexCfg,
secretDAL
});
const secretsDeleted = await secretDAL.transaction(async (tx) =>
@@ -871,6 +826,98 @@ export const secretServiceFactory = ({
});
return decryptSecretRaw(secret, botKey);
};
const createManySecretsRawHelper = async ({ // TODO: place on top
projectId,
environment,
path,
secrets,
userId
}: TCreateManySecretsRawHelper) => {
const botKey = await projectBotService.getBotKey(projectId);
if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" });
await projectDAL.checkProjectUpgradeStatus(projectId);
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create secret" });
const folderId = folder.id;
const blindIndexCfg = await secretBlindIndexDAL.findOne({ projectId });
if (!blindIndexCfg) throw new BadRequestError({ message: "Blind index not found", name: "Update secret" });
const inputSecrets = await Promise.all(
secrets.map(async (secret) => {
const secretKeyEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretName, botKey);
const secretValueEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretValue || "", botKey);
const secretCommentEncrypted = encryptSymmetric128BitHexKeyUTF8(secret.secretComment || "", botKey);
if (secret.type === SecretType.Personal) {
const sharedExist = await secretDAL.findOne({
secretBlindIndex: keyName2BlindIndex[secret.secretName],
folderId,
type: SecretType.Shared
});
if (!sharedExist)
throw new BadRequestError({
message: "Failed to create personal secret override for no corresponding shared secret"
});
if (!userId) throw new BadRequestError({ message: "Missing user id for personal secret" });
}
const tags = secret.tags ? await secretTagDAL.findManyTagsById(projectId, secret.tags) : [];
if ((secret.tags || []).length !== tags.length) throw new BadRequestError({ message: "Tag not found" });
return ({
type: secret.type,
userId: secret.type === SecretType.Personal ? userId : null,
secretName: secret.secretName,
secretKeyCiphertext: secretKeyEncrypted.ciphertext,
secretKeyIV: secretKeyEncrypted.iv,
secretKeyTag: secretKeyEncrypted.tag,
secretValueCiphertext: secretValueEncrypted.ciphertext,
secretValueIV: secretValueEncrypted.iv,
secretValueTag: secretValueEncrypted.tag,
secretCommentCiphertext: secretCommentEncrypted.ciphertext,
secretCommentIV: secretCommentEncrypted.iv,
secretCommentTag: secretCommentEncrypted.tag,
skipMultilineEncoding: secret.skipMultilineEncoding,
tags: secret.tags
});
})
);
// insert operation
const { keyName2BlindIndex } = await fnSecretBlindIndexCheck({
inputSecrets,
folderId,
isNew: true,
blindIndexCfg,
secretDAL
});
const newSecrets = await secretDAL.transaction(async (tx) =>
fnSecretBulkInsert({
inputSecrets: inputSecrets.map(({ secretName, ...el }) => ({
...el,
version: 0,
secretBlindIndex: keyName2BlindIndex[secretName],
algorithm: SecretEncryptionAlgo.AES_256_GCM,
keyEncoding: SecretKeyEncoding.UTF8
})),
folderId,
secretDAL,
secretVersionDAL,
secretTagDAL,
secretVersionTagDAL,
tx
})
);
return newSecrets;
}
const createSecretRaw = async ({
secretName,
@@ -2,6 +2,13 @@ import { Knex } from "knex";
import { SecretType, TSecretBlindIndexes, TSecrets, TSecretsInsert, TSecretsUpdate } from "@app/db/schemas";
import { TProjectPermission } from "@app/lib/types";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TSecretDALFactory } from "@app/services/secret/secret-dal";
import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal";
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal";
import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal";
type TPartialSecret = Pick<TSecrets, "id" | "secretReminderRepeatDays" | "secretReminderNote">;
@@ -181,6 +188,10 @@ export type TFnSecretBulkInsert = {
folderId: string;
tx?: Knex;
inputSecrets: Array<Omit<TSecretsInsert, "folderId"> & { tags?: string[] }>;
secretDAL: Pick<TSecretDALFactory, "insertMany">;
secretVersionDAL: Pick<TSecretVersionDALFactory, "insertMany">;
secretTagDAL: Pick<TSecretTagDALFactory, "saveTagsToSecret">;
secretVersionTagDAL: Pick<TSecretVersionTagDALFactory, "insertMany">;
};
export type TFnSecretBulkUpdate = {
@@ -204,6 +215,7 @@ export type TFnSecretBlindIndexCheck = {
blindIndexCfg: TSecretBlindIndexes;
inputSecrets: Array<{ secretName: string; type?: SecretType }>;
isNew: boolean;
secretDAL: Pick<TSecretDALFactory, "findByBlindIndexes">;
};
// when blind index is already present
@@ -229,3 +241,31 @@ export type TRemoveSecretReminderDTO = {
secretId: string;
repeatDays: number;
};
// ---
export type TCreateManySecretsRawHelper = {
projectId: string;
environment: string;
path: string;
secrets: {
secretName: string;
secretValue: string;
type: SecretType;
secretComment?: string;
skipMultilineEncoding?: boolean;
tags?: string[];
metadata?: {
source?: string;
}
}[];
userId?: string; // only relevant for personal secret(s)
botKey: string;
projectDAL: TProjectDALFactory;
secretDAL: TSecretDALFactory;
secretVersionDAL: TSecretVersionDALFactory;
secretBlindIndexDAL: TSecretBlindIndexDALFactory;
secretTagDAL: TSecretTagDALFactory;
secretVersionTagDAL: TSecretVersionTagDALFactory;
folderDAL: TSecretFolderDALFactory;
}
@@ -1,8 +1,8 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName, TSecretVersions } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { TableName, TSecretVersions, TSecretVersionsUpdate } from "@app/db/schemas";
import { BadRequestError, DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
export type TSecretVersionDALFactory = ReturnType<typeof secretVersionDALFactory>;
@@ -36,6 +36,57 @@ export const secretVersionDALFactory = (db: TDbClient) => {
}
};
const bulkUpdate = async (
data: Array<{ filter: Partial<TSecretVersions>; data: TSecretVersionsUpdate }>,
tx?: Knex
) => {
try {
const secs = await Promise.all(
data.map(async ({ filter, data: updateData }) => {
const [doc] = await (tx || db)(TableName.SecretVersion)
.where(filter)
.update(updateData)
.increment("version", 1) // TODO: Is this really needed?
.returning("*");
if (!doc) throw new BadRequestError({ message: "Failed to update document" });
return doc;
})
);
return secs;
} catch (error) {
throw new DatabaseError({ error, name: "bulk update secret" });
}
};
const bulkUpdateNoVersionIncrement = async (data: TSecretVersions[], tx?: Knex) => {
try {
const existingSecretVersions = await secretVersionOrm.find(
{
$in: {
id: data.map((el) => el.id)
}
},
{ tx }
);
if (existingSecretVersions.length !== data.length) {
throw new BadRequestError({ message: "Some of the secret versions do not exist" });
}
if (data.length === 0) return [];
const updatedSecretVersions = await (tx || db)(TableName.SecretVersion)
.insert(data)
.onConflict("id") // this will cause a conflict then merge the data
.merge() // Merge the data with the existing data
.returning("*");
return updatedSecretVersions;
} catch (error) {
throw new DatabaseError({ error, name: "bulk update secret" });
}
};
const findLatestVersionMany = async (folderId: string, secretIds: string[], tx?: Knex) => {
try {
const docs: Array<TSecretVersions & { max: number }> = await (tx || db)(TableName.SecretVersion)
@@ -59,5 +110,11 @@ export const secretVersionDALFactory = (db: TDbClient) => {
}
};
return { ...secretVersionOrm, findLatestVersionMany, findLatestVersionByFolderId };
return {
...secretVersionOrm,
findLatestVersionMany,
bulkUpdate,
findLatestVersionByFolderId,
bulkUpdateNoVersionIncrement
};
};
@@ -1,10 +1,32 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
import { TableName, TUsers } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
export type TServiceTokenDALFactory = ReturnType<typeof serviceTokenDALFactory>;
export const serviceTokenDALFactory = (db: TDbClient) => {
const stOrm = ormify(db, TableName.ServiceToken);
return stOrm;
const findById = async (id: string, tx?: Knex) => {
try {
const doc = await (tx || db)(TableName.ServiceToken)
.leftJoin<TUsers>(
TableName.Users,
`${TableName.Users}.id`,
db.raw(`${TableName.ServiceToken}."createdBy"::uuid`)
)
.where(`${TableName.ServiceToken}.id`, id)
.select(selectAllTableCols(TableName.ServiceToken))
.select(db.ref("email").withSchema(TableName.Users).as("createdByEmail"))
.first();
return doc;
} catch (err) {
throw new DatabaseError({ error: err, name: "FindById" });
}
};
return { ...stOrm, findById };
};
@@ -142,7 +142,7 @@ export const serviceTokenServiceFactory = ({
const updatedToken = await serviceTokenDAL.updateById(serviceToken.id, {
lastUsed: new Date()
});
return updatedToken;
return { ...serviceToken, lastUsed: updatedToken.lastUsed };
};
return {
+2 -1
View File
@@ -25,7 +25,8 @@ export enum SmtpTemplates {
OrgInvite = "organizationInvitation.handlebars",
ResetPassword = "passwordReset.handlebars",
SecretLeakIncident = "secretLeakIncident.handlebars",
WorkspaceInvite = "workspaceInvitation.handlebars"
WorkspaceInvite = "workspaceInvitation.handlebars",
ScimUserProvisioned = "scimUserProvisioned.handlebars"
}
export enum SmtpHost {
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Organization Invitation</title>
</head>
<body>
<h2>Join your organization on Infisical</h2>
<p>You've been invited to join the Infisical organization — {{organizationName}}</p>
<a href="{{callback_url}}">Join now</a>
<h3>What is Infisical?</h3>
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.</p>
</body>
</html>
@@ -1,15 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>Project Invitation</title>
</head>
<body>
</head>
<body>
<h2>Join your team on Infisical</h2>
<p>{{inviterFirstName}} ({{inviterEmail}}) has invited you to their Infisical project — {{workspaceName}}</p>
<p>You have been invited to a new Infisical project — {{workspaceName}}</p>
<a href="{{callback_url}}">Join now</a>
<h3>What is Infisical?</h3>
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.</p>
</body>
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets
and configs.</p>
</body>
</html>
@@ -1,4 +1,5 @@
import { TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas";
import { TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors";
@@ -14,6 +15,7 @@ type TSuperAdminServiceFactoryDep = {
userDAL: TUserDALFactory;
authService: Pick<TAuthLoginFactory, "generateUserTokens">;
orgService: Pick<TOrgServiceFactory, "createOrganization">;
keyStore: Pick<TKeyStoreFactory, "getItem" | "setItemWithExpiry" | "deleteItem">;
};
export type TSuperAdminServiceFactory = ReturnType<typeof superAdminServiceFactory>;
@@ -21,26 +23,53 @@ export type TSuperAdminServiceFactory = ReturnType<typeof superAdminServiceFacto
// eslint-disable-next-line
export let getServerCfg: () => Promise<TSuperAdmin>;
const ADMIN_CONFIG_KEY = "infisical-admin-cfg";
const ADMIN_CONFIG_KEY_EXP = 60; // 60s
const ADMIN_CONFIG_DB_UUID = "00000000-0000-0000-0000-000000000000";
export const superAdminServiceFactory = ({
serverCfgDAL,
userDAL,
authService,
orgService
orgService,
keyStore
}: TSuperAdminServiceFactoryDep) => {
const initServerCfg = async () => {
// TODO(akhilmhdh): bad pattern time less change this later to me itself
getServerCfg = () => serverCfgDAL.findOne({});
getServerCfg = async () => {
const config = await keyStore.getItem(ADMIN_CONFIG_KEY);
// missing in keystore means fetch from db
if (!config) {
const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID);
if (serverCfg) {
await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(serverCfg)); // insert it back to keystore
}
return serverCfg;
}
const serverCfg = await serverCfgDAL.findOne({});
const keyStoreServerCfg = JSON.parse(config) as TSuperAdmin;
return {
...keyStoreServerCfg,
// this is to allow admin router to work
createdAt: new Date(keyStoreServerCfg.createdAt),
updatedAt: new Date(keyStoreServerCfg.updatedAt)
};
};
// reset on initialized
await keyStore.deleteItem(ADMIN_CONFIG_KEY);
const serverCfg = await serverCfgDAL.findById(ADMIN_CONFIG_DB_UUID);
if (serverCfg) return;
const newCfg = await serverCfgDAL.create({ initialized: false, allowSignUp: true });
// @ts-expect-error id is kept as fixed for idempotence and to avoid race condition
const newCfg = await serverCfgDAL.create({ initialized: false, allowSignUp: true, id: ADMIN_CONFIG_DB_UUID });
return newCfg;
};
const updateServerCfg = async (data: TSuperAdminUpdate) => {
const serverCfg = await getServerCfg();
const cfg = await serverCfgDAL.updateById(serverCfg.id, data);
return cfg;
const updatedServerCfg = await serverCfgDAL.updateById(ADMIN_CONFIG_DB_UUID, data);
await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(updatedServerCfg));
return updatedServerCfg;
};
const adminSignUp = async ({
@@ -70,6 +99,7 @@ export const superAdminServiceFactory = ({
lastName,
email,
superAdmin: true,
isGhost: false,
isAccepted: true,
authMethods: [AuthMethod.EMAIL]
},
@@ -96,7 +126,11 @@ export const superAdminServiceFactory = ({
const initialOrganizationName = appCfg.INITIAL_ORGANIZATION_NAME ?? "Admin Org";
await orgService.createOrganization(userInfo.user.id, userInfo.user.email, initialOrganizationName);
const organization = await orgService.createOrganization(
userInfo.user.id,
userInfo.user.email,
initialOrganizationName
);
await updateServerCfg({ initialized: true });
const token = await authService.generateUserTokens({
@@ -106,7 +140,7 @@ export const superAdminServiceFactory = ({
organizationId: undefined
});
// TODO(akhilmhdh-pg): telemetry service
return { token, user: userInfo };
return { token, user: userInfo, organization };
};
return {
@@ -0,0 +1,39 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
export type TTelemetryDALFactory = ReturnType<typeof telemetryDALFactory>;
export const telemetryDALFactory = (db: TDbClient) => {
const getTelemetryInstanceStats = async () => {
try {
const userCount = (await db(TableName.Users).where({ isGhost: false }).count().first())?.count as string;
const users = parseInt(userCount || "0", 10);
const identityCount = (await db(TableName.Identity).count().first())?.count as string;
const identities = parseInt(identityCount || "0", 10);
const projectCount = (await db(TableName.Project).count().first())?.count as string;
const projects = parseInt(projectCount || "0", 10);
const secretCount = (await db(TableName.Secret).count().first())?.count as string;
const secrets = parseInt(secretCount || "0", 10);
const organizationNames = await db(TableName.Organization).select("name");
const organizations = organizationNames.length;
return {
users,
identities,
projects,
secrets,
organizations,
organizationNames: organizationNames.map(({ name }) => name)
};
} catch (error) {
throw new DatabaseError({ error, name: "TelemtryInstanceStats" });
}
};
return { getTelemetryInstanceStats };
};
@@ -0,0 +1,78 @@
import { PostHog } from "posthog-node";
import { TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { getServerCfg } from "../super-admin/super-admin-service";
import { TTelemetryDALFactory } from "./telemetry-dal";
import { TELEMETRY_SECRET_OPERATIONS_KEY, TELEMETRY_SECRET_PROCESSED_KEY } from "./telemetry-service";
import { PostHogEventTypes } from "./telemetry-types";
type TTelemetryQueueServiceFactoryDep = {
queueService: TQueueServiceFactory;
keyStore: Pick<TKeyStoreFactory, "getItem" | "deleteItem">;
telemetryDAL: TTelemetryDALFactory;
};
export type TTelemetryQueueServiceFactory = ReturnType<typeof telemetryQueueServiceFactory>;
export const telemetryQueueServiceFactory = ({
queueService,
keyStore,
telemetryDAL
}: TTelemetryQueueServiceFactoryDep) => {
const appCfg = getConfig();
const postHog =
appCfg.isProductionMode && appCfg.TELEMETRY_ENABLED
? new PostHog(appCfg.POSTHOG_PROJECT_API_KEY, { host: appCfg.POSTHOG_HOST, flushAt: 1, flushInterval: 0 })
: undefined;
queueService.start(QueueName.TelemetryInstanceStats, async () => {
const { instanceId } = await getServerCfg();
const telemtryStats = await telemetryDAL.getTelemetryInstanceStats();
// parse the redis values into integer
const numberOfSecretOperationsMade = parseInt((await keyStore.getItem(TELEMETRY_SECRET_OPERATIONS_KEY)) || "0", 10);
const numberOfSecretProcessed = parseInt((await keyStore.getItem(TELEMETRY_SECRET_PROCESSED_KEY)) || "0", 10);
const stats = { ...telemtryStats, numberOfSecretProcessed, numberOfSecretOperationsMade };
// send to postHog
postHog?.capture({
event: PostHogEventTypes.TelemetryInstanceStats,
distinctId: instanceId,
properties: stats
});
// reset the stats
await keyStore.deleteItem(TELEMETRY_SECRET_PROCESSED_KEY);
await keyStore.deleteItem(TELEMETRY_SECRET_OPERATIONS_KEY);
});
// every day at midnight a telemetry job executes on self hosted
// this sends some telemetry information like instance id secrets operated etc
const startTelemetryCheck = async () => {
// this is a fast way to check its cloud or not
if (appCfg.INFISICAL_CLOUD) return;
// clear previous job
await queueService.stopRepeatableJob(
QueueName.TelemetryInstanceStats,
QueueJobs.TelemetryInstanceStats,
{ pattern: "0 0 * * *", utc: true },
QueueName.TelemetryInstanceStats // just a job id
);
if (postHog) {
await queueService.queue(QueueName.TelemetryInstanceStats, QueueJobs.TelemetryInstanceStats, undefined, {
jobId: QueueName.TelemetryInstanceStats,
repeat: { pattern: "0 0 * * *", utc: true }
});
}
};
queueService.listen(QueueName.TelemetryInstanceStats, "failed", (err) => {
logger.error(err?.failedReason, `${QueueName.TelemetryInstanceStats}: failed`);
});
return {
startTelemetryCheck
};
};
@@ -1,15 +1,24 @@
import { PostHog } from "posthog-node";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { InstanceType } from "@app/ee/services/license/license-types";
import { TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
import { logger } from "@app/lib/logger";
import { TPostHogEvent } from "./telemetry-types";
import { PostHogEventTypes, TPostHogEvent, TSecretModifiedEvent } from "./telemetry-types";
export const TELEMETRY_SECRET_PROCESSED_KEY = "telemetry-secret-processed";
export const TELEMETRY_SECRET_OPERATIONS_KEY = "telemetry-secret-operations";
export type TTelemetryServiceFactory = ReturnType<typeof telemetryServiceFactory>;
export type TTelemetryServiceFactoryDep = {
keyStore: Pick<TKeyStoreFactory, "getItem" | "incrementBy">;
licenseService: Pick<TLicenseServiceFactory, "getInstanceType">;
};
// type TTelemetryServiceFactoryDep = {};
export const telemetryServiceFactory = () => {
export const telemetryServiceFactory = ({ keyStore, licenseService }: TTelemetryServiceFactoryDep) => {
const appCfg = getConfig();
if (appCfg.isProductionMode && !appCfg.TELEMETRY_ENABLED) {
@@ -21,10 +30,9 @@ To opt into telemetry, you can set "TELEMETRY_ENABLED=true" within the environme
`);
}
const postHog =
appCfg.isProductionMode && appCfg.TELEMETRY_ENABLED
? new PostHog(appCfg.POSTHOG_PROJECT_API_KEY, { host: appCfg.POSTHOG_HOST })
: undefined;
const postHog = appCfg.TELEMETRY_ENABLED
? new PostHog(appCfg.POSTHOG_PROJECT_API_KEY, { host: appCfg.POSTHOG_HOST })
: undefined;
// used for email marketting email sending purpose
const sendLoopsEvent = async (email: string, firstName?: string, lastName?: string) => {
@@ -51,18 +59,45 @@ To opt into telemetry, you can set "TELEMETRY_ENABLED=true" within the environme
}
};
const sendPostHogEvents = (event: TPostHogEvent) => {
const sendPostHogEvents = async (event: TPostHogEvent) => {
if (postHog) {
postHog.capture({
event: event.event,
distinctId: event.distinctId,
properties: event.properties
});
const instanceType = licenseService.getInstanceType();
// capture posthog only when its cloud or signup event happens in self hosted
if (instanceType === InstanceType.Cloud || event.event === PostHogEventTypes.UserSignedUp) {
postHog.capture({
event: event.event,
distinctId: event.distinctId,
properties: event.properties
});
return;
}
if (
[
PostHogEventTypes.SecretPulled,
PostHogEventTypes.SecretCreated,
PostHogEventTypes.SecretDeleted,
PostHogEventTypes.SecretUpdated
].includes(event.event)
) {
await keyStore.incrementBy(
TELEMETRY_SECRET_PROCESSED_KEY,
(event as TSecretModifiedEvent).properties.numberOfSecrets
);
await keyStore.incrementBy(TELEMETRY_SECRET_OPERATIONS_KEY, 1);
}
}
};
const flushAll = async () => {
if (postHog) {
await postHog.shutdownAsync();
}
};
return {
sendLoopsEvent,
sendPostHogEvents
sendPostHogEvents,
flushAll
};
};
@@ -8,7 +8,12 @@ export enum PostHogEventTypes {
UserSignedUp = "User Signed Up",
SecretRotated = "secrets rotated",
SecretScannerFull = "historical cloud secret scan",
SecretScannerPush = "cloud secret scan"
SecretScannerPush = "cloud secret scan",
ProjectCreated = "Project Created",
IntegrationCreated = "Integration Created",
MachineIdentityCreated = "Machine Identity Created",
UserOrgInvitation = "User Org Invitation",
TelemetryInstanceStats = "Self Hosted Instance Stats"
}
export type TSecretModifiedEvent = {
@@ -53,9 +58,72 @@ export type TSecretScannerEvent = {
};
};
export type TProjectCreateEvent = {
event: PostHogEventTypes.ProjectCreated;
properties: {
name: string;
orgId: string;
};
};
export type TMachineIdentityCreatedEvent = {
event: PostHogEventTypes.MachineIdentityCreated;
properties: {
name: string;
orgId: string;
identityId: string;
};
};
export type TIntegrationCreatedEvent = {
event: PostHogEventTypes.IntegrationCreated;
properties: {
projectId: string;
integrationId: string;
integration: string; // TODO: fix type
environment: string;
secretPath: string;
url?: string;
app?: string;
appId?: string;
targetEnvironment?: string;
targetEnvironmentId?: string;
targetService?: string;
targetServiceId?: string;
path?: string;
region?: string;
};
};
export type TUserOrgInvitedEvent = {
event: PostHogEventTypes.UserOrgInvitation;
properties: {
inviteeEmail: string;
};
};
export type TTelemetryInstanceStatsEvent = {
event: PostHogEventTypes.TelemetryInstanceStats;
properties: {
users: number;
identities: number;
projects: number;
secrets: number;
organizations: number;
organizationNames: number;
numberOfSecretOperationsMade: number;
numberOfSecretProcessed: number;
};
};
export type TPostHogEvent = { distinctId: string } & (
| TSecretModifiedEvent
| TAdminInitEvent
| TUserSignedUpEvent
| TSecretScannerEvent
| TUserOrgInvitedEvent
| TMachineIdentityCreatedEvent
| TIntegrationCreatedEvent
| TProjectCreateEvent
| TTelemetryInstanceStatsEvent
);
+13 -1
View File
@@ -23,7 +23,7 @@ export const userDALFactory = (db: TDbClient) => {
const findUserEncKeyByEmail = async (email: string) => {
try {
return await db(TableName.Users)
.where({ email })
.where({ email, isGhost: false })
.join(TableName.UserEncryptionKey, `${TableName.Users}.id`, `${TableName.UserEncryptionKey}.userId`)
.first();
} catch (error) {
@@ -47,6 +47,17 @@ export const userDALFactory = (db: TDbClient) => {
}
};
const findUserByProjectMembershipId = async (projectMembershipId: string) => {
try {
return await db(TableName.ProjectMembership)
.where({ [`${TableName.ProjectMembership}.id` as "id"]: projectMembershipId })
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
.first();
} catch (error) {
throw new DatabaseError({ error, name: "Find user by project membership id" });
}
};
const createUserEncryption = async (data: TUserEncryptionKeysInsert, tx?: Knex) => {
try {
const [userEnc] = await (tx || db)(TableName.UserEncryptionKey).insert(data).returning("*");
@@ -111,6 +122,7 @@ export const userDALFactory = (db: TDbClient) => {
findUserEncKeyByEmail,
findUserEncKeyByUserId,
updateUserEncryptionByUserId,
findUserByProjectMembershipId,
upsertUserEncryptionKey,
createUserEncryption,
findOneUserAction,