mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-14 15:40:47 +00:00
Fix merge conflicts, bring oidc auth up to speed with identity ui changes
This commit is contained in:
@@ -17,6 +17,7 @@ import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
|
||||
import { TokenType } from "../auth-token/auth-token-types";
|
||||
import { TOrgDALFactory } from "../org/org-dal";
|
||||
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||
import { LoginMethod } from "../super-admin/super-admin-types";
|
||||
import { TUserDALFactory } from "../user/user-dal";
|
||||
import { enforceUserLockStatus, validateProviderAuthToken } from "./auth-fns";
|
||||
import {
|
||||
@@ -158,9 +159,22 @@ export const authLoginServiceFactory = ({
|
||||
const userEnc = await userDAL.findUserEncKeyByUsername({
|
||||
username: email
|
||||
});
|
||||
const serverCfg = await getServerCfg();
|
||||
|
||||
if (
|
||||
serverCfg.enabledLoginMethods &&
|
||||
!serverCfg.enabledLoginMethods.includes(LoginMethod.EMAIL) &&
|
||||
!providerAuthToken
|
||||
) {
|
||||
throw new BadRequestError({
|
||||
message: "Login with email is disabled by administrator."
|
||||
});
|
||||
}
|
||||
|
||||
if (!userEnc || (userEnc && !userEnc.isAccepted)) {
|
||||
throw new Error("Failed to find user");
|
||||
}
|
||||
|
||||
if (!userEnc.authMethods?.includes(AuthMethod.EMAIL)) {
|
||||
validateProviderAuthToken(providerAuthToken as string, email);
|
||||
}
|
||||
@@ -507,6 +521,40 @@ export const authLoginServiceFactory = ({
|
||||
let user = await userDAL.findUserByUsername(email);
|
||||
const serverCfg = await getServerCfg();
|
||||
|
||||
if (serverCfg.enabledLoginMethods) {
|
||||
switch (authMethod) {
|
||||
case AuthMethod.GITHUB: {
|
||||
if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITHUB)) {
|
||||
throw new BadRequestError({
|
||||
message: "Login with Github is disabled by administrator.",
|
||||
name: "Oauth 2 login"
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case AuthMethod.GOOGLE: {
|
||||
if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GOOGLE)) {
|
||||
throw new BadRequestError({
|
||||
message: "Login with Google is disabled by administrator.",
|
||||
name: "Oauth 2 login"
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case AuthMethod.GITLAB: {
|
||||
if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITLAB)) {
|
||||
throw new BadRequestError({
|
||||
message: "Login with Gitlab is disabled by administrator.",
|
||||
name: "Oauth 2 login"
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const appCfg = getConfig();
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -364,7 +364,7 @@ export const authSignupServiceFactory = ({
|
||||
tx
|
||||
);
|
||||
const uniqueOrgId = [...new Set(updatedMembersips.map(({ orgId }) => orgId))];
|
||||
await Promise.allSettled(uniqueOrgId.map((orgId) => licenseService.updateSubscriptionOrgMemberCount(orgId)));
|
||||
await Promise.allSettled(uniqueOrgId.map((orgId) => licenseService.updateSubscriptionOrgMemberCount(orgId, tx)));
|
||||
|
||||
await convertPendingGroupAdditionsToGroupMemberships({
|
||||
userIds: [user.id],
|
||||
|
||||
@@ -131,7 +131,10 @@ export const identityAccessTokenServiceFactory = ({
|
||||
});
|
||||
if (!identityAccessToken) throw new UnauthorizedError();
|
||||
|
||||
const revokedToken = await identityAccessTokenDAL.deleteById(identityAccessToken.id);
|
||||
const revokedToken = await identityAccessTokenDAL.updateById(identityAccessToken.id, {
|
||||
isAccessTokenRevoked: true
|
||||
});
|
||||
|
||||
return { revokedToken };
|
||||
};
|
||||
|
||||
@@ -141,6 +144,10 @@ export const identityAccessTokenServiceFactory = ({
|
||||
isAccessTokenRevoked: false
|
||||
});
|
||||
if (!identityAccessToken) throw new UnauthorizedError();
|
||||
if (identityAccessToken.isAccessTokenRevoked)
|
||||
throw new UnauthorizedError({
|
||||
message: "Failed to authorize revoked access token"
|
||||
});
|
||||
|
||||
if (ipAddress && identityAccessToken) {
|
||||
checkIPAgainstBlocklist({
|
||||
|
||||
@@ -10,6 +10,103 @@ export type TIdentityProjectDALFactory = ReturnType<typeof identityProjectDALFac
|
||||
export const identityProjectDALFactory = (db: TDbClient) => {
|
||||
const identityProjectOrm = ormify(db, TableName.IdentityProjectMembership);
|
||||
|
||||
const findByIdentityId = async (identityId: string, tx?: Knex) => {
|
||||
try {
|
||||
const docs = await (tx || db.replicaNode())(TableName.IdentityProjectMembership)
|
||||
.where(`${TableName.IdentityProjectMembership}.identityId`, identityId)
|
||||
.join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`)
|
||||
.join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`)
|
||||
.join(
|
||||
TableName.IdentityProjectMembershipRole,
|
||||
`${TableName.IdentityProjectMembershipRole}.projectMembershipId`,
|
||||
`${TableName.IdentityProjectMembership}.id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.ProjectRoles,
|
||||
`${TableName.IdentityProjectMembershipRole}.customRoleId`,
|
||||
`${TableName.ProjectRoles}.id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.IdentityProjectAdditionalPrivilege,
|
||||
`${TableName.IdentityProjectMembership}.id`,
|
||||
`${TableName.IdentityProjectAdditionalPrivilege}.projectMembershipId`
|
||||
)
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.IdentityProjectMembership),
|
||||
db.ref("createdAt").withSchema(TableName.IdentityProjectMembership),
|
||||
db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership),
|
||||
db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity),
|
||||
db.ref("id").as("identityId").withSchema(TableName.Identity),
|
||||
db.ref("name").as("identityName").withSchema(TableName.Identity),
|
||||
db.ref("id").withSchema(TableName.IdentityProjectMembership),
|
||||
db.ref("role").withSchema(TableName.IdentityProjectMembershipRole),
|
||||
db.ref("id").withSchema(TableName.IdentityProjectMembershipRole).as("membershipRoleId"),
|
||||
db.ref("customRoleId").withSchema(TableName.IdentityProjectMembershipRole),
|
||||
db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"),
|
||||
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"),
|
||||
db.ref("temporaryMode").withSchema(TableName.IdentityProjectMembershipRole),
|
||||
db.ref("isTemporary").withSchema(TableName.IdentityProjectMembershipRole),
|
||||
db.ref("temporaryRange").withSchema(TableName.IdentityProjectMembershipRole),
|
||||
db.ref("temporaryAccessStartTime").withSchema(TableName.IdentityProjectMembershipRole),
|
||||
db.ref("temporaryAccessEndTime").withSchema(TableName.IdentityProjectMembershipRole),
|
||||
db.ref("projectId").withSchema(TableName.IdentityProjectMembership),
|
||||
db.ref("name").as("projectName").withSchema(TableName.Project)
|
||||
);
|
||||
|
||||
const members = sqlNestRelationships({
|
||||
data: docs,
|
||||
parentMapper: ({ identityName, identityAuthMethod, id, createdAt, updatedAt, projectId, projectName }) => ({
|
||||
id,
|
||||
identityId,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
identity: {
|
||||
id: identityId,
|
||||
name: identityName,
|
||||
authMethod: identityAuthMethod
|
||||
},
|
||||
project: {
|
||||
id: projectId,
|
||||
name: projectName
|
||||
}
|
||||
}),
|
||||
key: "id",
|
||||
childrenMapper: [
|
||||
{
|
||||
label: "roles" as const,
|
||||
key: "membershipRoleId",
|
||||
mapper: ({
|
||||
role,
|
||||
customRoleId,
|
||||
customRoleName,
|
||||
customRoleSlug,
|
||||
membershipRoleId,
|
||||
temporaryRange,
|
||||
temporaryMode,
|
||||
temporaryAccessEndTime,
|
||||
temporaryAccessStartTime,
|
||||
isTemporary
|
||||
}) => ({
|
||||
id: membershipRoleId,
|
||||
role,
|
||||
customRoleId,
|
||||
customRoleName,
|
||||
customRoleSlug,
|
||||
temporaryRange,
|
||||
temporaryMode,
|
||||
temporaryAccessEndTime,
|
||||
temporaryAccessStartTime,
|
||||
isTemporary
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
return members;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindByIdentityId" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByProjectId = async (projectId: string, filter: { identityId?: string } = {}, tx?: Knex) => {
|
||||
try {
|
||||
const docs = await (tx || db.replicaNode())(TableName.IdentityProjectMembership)
|
||||
@@ -105,5 +202,9 @@ export const identityProjectDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
return { ...identityProjectOrm, findByProjectId };
|
||||
return {
|
||||
...identityProjectOrm,
|
||||
findByIdentityId,
|
||||
findByProjectId
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
export type TIdentityTokenAuthDALFactory = ReturnType<typeof identityTokenAuthDALFactory>;
|
||||
|
||||
export const identityTokenAuthDALFactory = (db: TDbClient) => {
|
||||
const tokenAuthOrm = ormify(db, TableName.IdentityTokenAuth);
|
||||
return tokenAuthOrm;
|
||||
};
|
||||
@@ -0,0 +1,470 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import { IdentityAuthMethod, TableName } 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 { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
|
||||
|
||||
import { ActorType, AuthTokenType } from "../auth/auth-type";
|
||||
import { TIdentityDALFactory } from "../identity/identity-dal";
|
||||
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
|
||||
import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal";
|
||||
import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types";
|
||||
import { TIdentityTokenAuthDALFactory } from "./identity-token-auth-dal";
|
||||
import {
|
||||
TAttachTokenAuthDTO,
|
||||
TCreateTokenAuthTokenDTO,
|
||||
TGetTokenAuthDTO,
|
||||
TGetTokenAuthTokensDTO,
|
||||
TRevokeTokenAuthDTO,
|
||||
TRevokeTokenAuthTokenDTO,
|
||||
TUpdateTokenAuthDTO,
|
||||
TUpdateTokenAuthTokenDTO
|
||||
} from "./identity-token-auth-types";
|
||||
|
||||
type TIdentityTokenAuthServiceFactoryDep = {
|
||||
identityTokenAuthDAL: Pick<
|
||||
TIdentityTokenAuthDALFactory,
|
||||
"transaction" | "create" | "findOne" | "updateById" | "delete"
|
||||
>;
|
||||
identityDAL: Pick<TIdentityDALFactory, "updateById">;
|
||||
identityOrgMembershipDAL: Pick<TIdentityOrgDALFactory, "findOne">;
|
||||
identityAccessTokenDAL: Pick<
|
||||
TIdentityAccessTokenDALFactory,
|
||||
"create" | "find" | "update" | "findById" | "findOne" | "updateById"
|
||||
>;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
};
|
||||
|
||||
export type TIdentityTokenAuthServiceFactory = ReturnType<typeof identityTokenAuthServiceFactory>;
|
||||
|
||||
export const identityTokenAuthServiceFactory = ({
|
||||
identityTokenAuthDAL,
|
||||
identityDAL,
|
||||
identityOrgMembershipDAL,
|
||||
identityAccessTokenDAL,
|
||||
permissionService,
|
||||
licenseService
|
||||
}: TIdentityTokenAuthServiceFactoryDep) => {
|
||||
const attachTokenAuth = async ({
|
||||
identityId,
|
||||
accessTokenTTL,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TAttachTokenAuthDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
|
||||
if (identityMembershipOrg.identity.authMethod)
|
||||
throw new BadRequestError({
|
||||
message: "Failed to add Token Auth to already configured identity"
|
||||
});
|
||||
|
||||
if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) {
|
||||
throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" });
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityMembershipOrg.orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity);
|
||||
|
||||
const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
|
||||
const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => {
|
||||
if (
|
||||
!plan.ipAllowlisting &&
|
||||
accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" &&
|
||||
accessTokenTrustedIp.ipAddress !== "::/0"
|
||||
)
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range."
|
||||
});
|
||||
if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress))
|
||||
throw new BadRequestError({
|
||||
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
|
||||
});
|
||||
return extractIPDetails(accessTokenTrustedIp.ipAddress);
|
||||
});
|
||||
|
||||
const identityTokenAuth = await identityTokenAuthDAL.transaction(async (tx) => {
|
||||
const doc = await identityTokenAuthDAL.create(
|
||||
{
|
||||
identityId: identityMembershipOrg.identityId,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps)
|
||||
},
|
||||
tx
|
||||
);
|
||||
await identityDAL.updateById(
|
||||
identityMembershipOrg.identityId,
|
||||
{
|
||||
authMethod: IdentityAuthMethod.TOKEN_AUTH
|
||||
},
|
||||
tx
|
||||
);
|
||||
return doc;
|
||||
});
|
||||
return { ...identityTokenAuth, orgId: identityMembershipOrg.orgId };
|
||||
};
|
||||
|
||||
const updateTokenAuth = async ({
|
||||
identityId,
|
||||
accessTokenTTL,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TUpdateTokenAuthDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
|
||||
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.TOKEN_AUTH)
|
||||
throw new BadRequestError({
|
||||
message: "Failed to update Token Auth"
|
||||
});
|
||||
|
||||
const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId });
|
||||
|
||||
if (
|
||||
(accessTokenMaxTTL || identityTokenAuth.accessTokenMaxTTL) > 0 &&
|
||||
(accessTokenTTL || identityTokenAuth.accessTokenMaxTTL) >
|
||||
(accessTokenMaxTTL || identityTokenAuth.accessTokenMaxTTL)
|
||||
) {
|
||||
throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" });
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityMembershipOrg.orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity);
|
||||
|
||||
const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
|
||||
const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => {
|
||||
if (
|
||||
!plan.ipAllowlisting &&
|
||||
accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" &&
|
||||
accessTokenTrustedIp.ipAddress !== "::/0"
|
||||
)
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range."
|
||||
});
|
||||
if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress))
|
||||
throw new BadRequestError({
|
||||
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
|
||||
});
|
||||
return extractIPDetails(accessTokenTrustedIp.ipAddress);
|
||||
});
|
||||
|
||||
const updatedTokenAuth = await identityTokenAuthDAL.updateById(identityTokenAuth.id, {
|
||||
accessTokenMaxTTL,
|
||||
accessTokenTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps: reformattedAccessTokenTrustedIps
|
||||
? JSON.stringify(reformattedAccessTokenTrustedIps)
|
||||
: undefined
|
||||
});
|
||||
|
||||
return {
|
||||
...updatedTokenAuth,
|
||||
orgId: identityMembershipOrg.orgId
|
||||
};
|
||||
};
|
||||
|
||||
const getTokenAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetTokenAuthDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
|
||||
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.TOKEN_AUTH)
|
||||
throw new BadRequestError({
|
||||
message: "The identity does not have Token Auth attached"
|
||||
});
|
||||
|
||||
const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityMembershipOrg.orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity);
|
||||
|
||||
return { ...identityTokenAuth, orgId: identityMembershipOrg.orgId };
|
||||
};
|
||||
|
||||
const revokeIdentityTokenAuth = async ({
|
||||
identityId,
|
||||
actorId,
|
||||
actor,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TRevokeTokenAuthDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
|
||||
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.TOKEN_AUTH)
|
||||
throw new BadRequestError({
|
||||
message: "The identity does not have Token Auth"
|
||||
});
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityMembershipOrg.orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity);
|
||||
|
||||
const { permission: rolePermission } = await permissionService.getOrgPermission(
|
||||
ActorType.IDENTITY,
|
||||
identityMembershipOrg.identityId,
|
||||
identityMembershipOrg.orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
if (!hasPriviledge)
|
||||
throw new UnauthorizedError({
|
||||
message: "Failed to revoke Token Auth of identity with more privileged role"
|
||||
});
|
||||
|
||||
const revokedIdentityTokenAuth = await identityTokenAuthDAL.transaction(async (tx) => {
|
||||
const deletedTokenAuth = await identityTokenAuthDAL.delete({ identityId }, tx);
|
||||
await identityDAL.updateById(identityId, { authMethod: null }, tx);
|
||||
return { ...deletedTokenAuth?.[0], orgId: identityMembershipOrg.orgId };
|
||||
});
|
||||
return revokedIdentityTokenAuth;
|
||||
};
|
||||
|
||||
const createTokenAuthToken = async ({
|
||||
identityId,
|
||||
actorId,
|
||||
actor,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
name
|
||||
}: TCreateTokenAuthTokenDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
|
||||
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.TOKEN_AUTH)
|
||||
throw new BadRequestError({
|
||||
message: "The identity does not have Token Auth"
|
||||
});
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityMembershipOrg.orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity);
|
||||
|
||||
const { permission: rolePermission } = await permissionService.getOrgPermission(
|
||||
ActorType.IDENTITY,
|
||||
identityMembershipOrg.identityId,
|
||||
identityMembershipOrg.orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
if (!hasPriviledge)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to create token for identity with more privileged role"
|
||||
});
|
||||
|
||||
const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId });
|
||||
|
||||
const identityAccessToken = await identityTokenAuthDAL.transaction(async (tx) => {
|
||||
const newToken = await identityAccessTokenDAL.create(
|
||||
{
|
||||
identityId: identityTokenAuth.identityId,
|
||||
isAccessTokenRevoked: false,
|
||||
accessTokenTTL: identityTokenAuth.accessTokenTTL,
|
||||
accessTokenMaxTTL: identityTokenAuth.accessTokenMaxTTL,
|
||||
accessTokenNumUses: 0,
|
||||
accessTokenNumUsesLimit: identityTokenAuth.accessTokenNumUsesLimit,
|
||||
name
|
||||
},
|
||||
tx
|
||||
);
|
||||
return newToken;
|
||||
});
|
||||
|
||||
const appCfg = getConfig();
|
||||
const accessToken = jwt.sign(
|
||||
{
|
||||
identityId: identityTokenAuth.identityId,
|
||||
identityAccessTokenId: identityAccessToken.id,
|
||||
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
|
||||
} as TIdentityAccessTokenJwtPayload,
|
||||
appCfg.AUTH_SECRET,
|
||||
{
|
||||
expiresIn:
|
||||
Number(identityAccessToken.accessTokenMaxTTL) === 0
|
||||
? undefined
|
||||
: Number(identityAccessToken.accessTokenMaxTTL)
|
||||
}
|
||||
);
|
||||
|
||||
return { accessToken, identityTokenAuth, identityAccessToken, identityMembershipOrg };
|
||||
};
|
||||
|
||||
const getTokenAuthTokens = async ({
|
||||
identityId,
|
||||
offset = 0,
|
||||
limit = 20,
|
||||
actorId,
|
||||
actor,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TGetTokenAuthTokensDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
|
||||
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.TOKEN_AUTH)
|
||||
throw new BadRequestError({
|
||||
message: "The identity does not have Token Auth"
|
||||
});
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityMembershipOrg.orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity);
|
||||
|
||||
const tokens = await identityAccessTokenDAL.find(
|
||||
{
|
||||
identityId
|
||||
},
|
||||
{ offset, limit, sort: [["updatedAt", "desc"]] }
|
||||
);
|
||||
|
||||
return { tokens, identityMembershipOrg };
|
||||
};
|
||||
|
||||
const updateTokenAuthToken = async ({
|
||||
tokenId,
|
||||
name,
|
||||
actorId,
|
||||
actor,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TUpdateTokenAuthTokenDTO) => {
|
||||
const foundToken = await identityAccessTokenDAL.findById(tokenId);
|
||||
if (!foundToken) throw new NotFoundError({ message: "Failed to find token" });
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: foundToken.identityId });
|
||||
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
|
||||
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.TOKEN_AUTH)
|
||||
throw new BadRequestError({
|
||||
message: "The identity does not have Token Auth"
|
||||
});
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityMembershipOrg.orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity);
|
||||
|
||||
const { permission: rolePermission } = await permissionService.getOrgPermission(
|
||||
ActorType.IDENTITY,
|
||||
identityMembershipOrg.identityId,
|
||||
identityMembershipOrg.orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
if (!hasPriviledge)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to update token for identity with more privileged role"
|
||||
});
|
||||
|
||||
const [token] = await identityAccessTokenDAL.update(
|
||||
{
|
||||
identityId: foundToken.identityId,
|
||||
id: tokenId
|
||||
},
|
||||
{
|
||||
name
|
||||
}
|
||||
);
|
||||
|
||||
return { token, identityMembershipOrg };
|
||||
};
|
||||
|
||||
const revokeTokenAuthToken = async ({
|
||||
tokenId,
|
||||
actorId,
|
||||
actor,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TRevokeTokenAuthTokenDTO) => {
|
||||
const identityAccessToken = await identityAccessTokenDAL.findOne({
|
||||
[`${TableName.IdentityAccessToken}.id` as "id"]: tokenId,
|
||||
isAccessTokenRevoked: false
|
||||
});
|
||||
if (!identityAccessToken)
|
||||
throw new NotFoundError({
|
||||
message: "Failed to find token"
|
||||
});
|
||||
|
||||
const identityOrgMembership = await identityOrgMembershipDAL.findOne({
|
||||
identityId: identityAccessToken.identityId
|
||||
});
|
||||
|
||||
if (!identityOrgMembership) {
|
||||
throw new UnauthorizedError({ message: "Identity does not belong to any organization" });
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityOrgMembership.orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity);
|
||||
|
||||
const revokedToken = await identityAccessTokenDAL.updateById(identityAccessToken.id, {
|
||||
isAccessTokenRevoked: true
|
||||
});
|
||||
|
||||
return { revokedToken };
|
||||
};
|
||||
|
||||
return {
|
||||
attachTokenAuth,
|
||||
updateTokenAuth,
|
||||
getTokenAuth,
|
||||
revokeIdentityTokenAuth,
|
||||
createTokenAuthToken,
|
||||
getTokenAuthTokens,
|
||||
updateTokenAuthToken,
|
||||
revokeTokenAuthToken
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
export type TAttachTokenAuthDTO = {
|
||||
identityId: string;
|
||||
accessTokenTTL: number;
|
||||
accessTokenMaxTTL: number;
|
||||
accessTokenNumUsesLimit: number;
|
||||
accessTokenTrustedIps: { ipAddress: string }[];
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TUpdateTokenAuthDTO = {
|
||||
identityId: string;
|
||||
accessTokenTTL?: number;
|
||||
accessTokenMaxTTL?: number;
|
||||
accessTokenNumUsesLimit?: number;
|
||||
accessTokenTrustedIps?: { ipAddress: string }[];
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetTokenAuthDTO = {
|
||||
identityId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TRevokeTokenAuthDTO = {
|
||||
identityId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TCreateTokenAuthTokenDTO = {
|
||||
identityId: string;
|
||||
name?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TGetTokenAuthTokensDTO = {
|
||||
identityId: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TUpdateTokenAuthTokenDTO = {
|
||||
tokenId: string;
|
||||
name?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TRevokeTokenAuthTokenDTO = {
|
||||
tokenId: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
@@ -5,17 +5,25 @@ 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 { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { TOrgPermission } from "@app/lib/types";
|
||||
import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
|
||||
|
||||
import { ActorType } from "../auth/auth-type";
|
||||
import { TIdentityDALFactory } from "./identity-dal";
|
||||
import { TIdentityOrgDALFactory } from "./identity-org-dal";
|
||||
import { TCreateIdentityDTO, TDeleteIdentityDTO, TGetIdentityByIdDTO, TUpdateIdentityDTO } from "./identity-types";
|
||||
import {
|
||||
TCreateIdentityDTO,
|
||||
TDeleteIdentityDTO,
|
||||
TGetIdentityByIdDTO,
|
||||
TListProjectIdentitiesByIdentityIdDTO,
|
||||
TUpdateIdentityDTO
|
||||
} from "./identity-types";
|
||||
|
||||
type TIdentityServiceFactoryDep = {
|
||||
identityDAL: TIdentityDALFactory;
|
||||
identityOrgMembershipDAL: TIdentityOrgDALFactory;
|
||||
identityProjectDAL: Pick<TIdentityProjectDALFactory, "findByIdentityId">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRole">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan" | "updateSubscriptionOrgMemberCount">;
|
||||
};
|
||||
@@ -25,6 +33,7 @@ export type TIdentityServiceFactory = ReturnType<typeof identityServiceFactory>;
|
||||
export const identityServiceFactory = ({
|
||||
identityDAL,
|
||||
identityOrgMembershipDAL,
|
||||
identityProjectDAL,
|
||||
permissionService,
|
||||
licenseService
|
||||
}: TIdentityServiceFactoryDep) => {
|
||||
@@ -196,11 +205,35 @@ export const identityServiceFactory = ({
|
||||
return identityMemberships;
|
||||
};
|
||||
|
||||
const listProjectIdentitiesByIdentityId = async ({
|
||||
identityId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TListProjectIdentitiesByIdentityIdDTO) => {
|
||||
const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityOrgMembership) throw new NotFoundError({ message: `Failed to find identity with id ${identityId}` });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
identityOrgMembership.orgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity);
|
||||
|
||||
const identityMemberships = await identityProjectDAL.findByIdentityId(identityId);
|
||||
return identityMemberships;
|
||||
};
|
||||
|
||||
return {
|
||||
createIdentity,
|
||||
updateIdentity,
|
||||
deleteIdentity,
|
||||
listOrgIdentities,
|
||||
getIdentityById
|
||||
getIdentityById,
|
||||
listProjectIdentitiesByIdentityId
|
||||
};
|
||||
};
|
||||
|
||||
@@ -25,3 +25,7 @@ export interface TIdentityTrustedIp {
|
||||
type: IPType;
|
||||
prefix: number;
|
||||
}
|
||||
|
||||
export type TListProjectIdentitiesByIdentityIdDTO = {
|
||||
identityId: string;
|
||||
} & Omit<TOrgPermission, "orgId">;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { AuthMethod } from "../auth/auth-type";
|
||||
import { TOrgServiceFactory } from "../org/org-service";
|
||||
import { TUserDALFactory } from "../user/user-dal";
|
||||
import { TSuperAdminDALFactory } from "./super-admin-dal";
|
||||
import { TAdminSignUpDTO } from "./super-admin-types";
|
||||
import { LoginMethod, TAdminGetUsersDTO, TAdminSignUpDTO } from "./super-admin-types";
|
||||
|
||||
type TSuperAdminServiceFactoryDep = {
|
||||
serverCfgDAL: TSuperAdminDALFactory;
|
||||
@@ -79,7 +79,37 @@ export const superAdminServiceFactory = ({
|
||||
return newCfg;
|
||||
};
|
||||
|
||||
const updateServerCfg = async (data: TSuperAdminUpdate) => {
|
||||
const updateServerCfg = async (data: TSuperAdminUpdate, userId: string) => {
|
||||
if (data.enabledLoginMethods) {
|
||||
const superAdminUser = await userDAL.findById(userId);
|
||||
const loginMethodToAuthMethod = {
|
||||
[LoginMethod.EMAIL]: [AuthMethod.EMAIL],
|
||||
[LoginMethod.GOOGLE]: [AuthMethod.GOOGLE],
|
||||
[LoginMethod.GITLAB]: [AuthMethod.GITLAB],
|
||||
[LoginMethod.GITHUB]: [AuthMethod.GITHUB],
|
||||
[LoginMethod.LDAP]: [AuthMethod.LDAP],
|
||||
[LoginMethod.OIDC]: [AuthMethod.OIDC],
|
||||
[LoginMethod.SAML]: [
|
||||
AuthMethod.AZURE_SAML,
|
||||
AuthMethod.GOOGLE_SAML,
|
||||
AuthMethod.JUMPCLOUD_SAML,
|
||||
AuthMethod.KEYCLOAK_SAML,
|
||||
AuthMethod.OKTA_SAML
|
||||
]
|
||||
};
|
||||
|
||||
if (
|
||||
!data.enabledLoginMethods.some((loginMethod) =>
|
||||
loginMethodToAuthMethod[loginMethod as LoginMethod].some(
|
||||
(authMethod) => superAdminUser.authMethods?.includes(authMethod)
|
||||
)
|
||||
)
|
||||
) {
|
||||
throw new BadRequestError({
|
||||
message: "You must configure at least one auth method to prevent account lockout"
|
||||
});
|
||||
}
|
||||
}
|
||||
const updatedServerCfg = await serverCfgDAL.updateById(ADMIN_CONFIG_DB_UUID, data);
|
||||
|
||||
await keyStore.setItemWithExpiry(ADMIN_CONFIG_KEY, ADMIN_CONFIG_KEY_EXP, JSON.stringify(updatedServerCfg));
|
||||
@@ -167,7 +197,7 @@ export const superAdminServiceFactory = ({
|
||||
orgName: initialOrganizationName
|
||||
});
|
||||
|
||||
await updateServerCfg({ initialized: true });
|
||||
await updateServerCfg({ initialized: true }, userInfo.user.id);
|
||||
const token = await authService.generateUserTokens({
|
||||
user: userInfo.user,
|
||||
authMethod: AuthMethod.EMAIL,
|
||||
@@ -179,9 +209,25 @@ export const superAdminServiceFactory = ({
|
||||
return { token, user: userInfo, organization };
|
||||
};
|
||||
|
||||
const getUsers = ({ offset, limit, searchTerm }: TAdminGetUsersDTO) => {
|
||||
return userDAL.getUsersByFilter({
|
||||
limit,
|
||||
offset,
|
||||
searchTerm,
|
||||
sortBy: "username"
|
||||
});
|
||||
};
|
||||
|
||||
const deleteUser = async (userId: string) => {
|
||||
const user = await userDAL.deleteById(userId);
|
||||
return user;
|
||||
};
|
||||
|
||||
return {
|
||||
initServerCfg,
|
||||
updateServerCfg,
|
||||
adminSignUp
|
||||
adminSignUp,
|
||||
getUsers,
|
||||
deleteUser
|
||||
};
|
||||
};
|
||||
|
||||
@@ -15,3 +15,19 @@ export type TAdminSignUpDTO = {
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
};
|
||||
|
||||
export type TAdminGetUsersDTO = {
|
||||
offset: number;
|
||||
limit: number;
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
export enum LoginMethod {
|
||||
EMAIL = "email",
|
||||
GOOGLE = "google",
|
||||
GITHUB = "github",
|
||||
GITLAB = "gitlab",
|
||||
SAML = "saml",
|
||||
LDAP = "ldap",
|
||||
OIDC = "oidc"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { PlainClient } from "@team-plain/typescript-sdk";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { InternalServerError } from "@app/lib/errors";
|
||||
|
||||
import { TUserDALFactory } from "../user/user-dal";
|
||||
|
||||
type TUserEngagementServiceFactoryDep = {
|
||||
userDAL: Pick<TUserDALFactory, "findById">;
|
||||
};
|
||||
|
||||
export type TUserEngagementServiceFactory = ReturnType<typeof userEngagementServiceFactory>;
|
||||
|
||||
export const userEngagementServiceFactory = ({ userDAL }: TUserEngagementServiceFactoryDep) => {
|
||||
const createUserWish = async (userId: string, text: string) => {
|
||||
const user = await userDAL.findById(userId);
|
||||
const appCfg = getConfig();
|
||||
|
||||
if (!appCfg.PLAIN_API_KEY) {
|
||||
throw new InternalServerError({
|
||||
message: "Plain is not configured."
|
||||
});
|
||||
}
|
||||
|
||||
const client = new PlainClient({
|
||||
apiKey: appCfg.PLAIN_API_KEY
|
||||
});
|
||||
|
||||
const customerUpsertRes = await client.upsertCustomer({
|
||||
identifier: {
|
||||
emailAddress: user.email
|
||||
},
|
||||
onCreate: {
|
||||
fullName: `${user.firstName} ${user.lastName}`,
|
||||
shortName: user.firstName,
|
||||
email: {
|
||||
email: user.email as string,
|
||||
isVerified: user.isEmailVerified as boolean
|
||||
},
|
||||
|
||||
externalId: user.id
|
||||
},
|
||||
|
||||
onUpdate: {
|
||||
fullName: {
|
||||
value: `${user.firstName} ${user.lastName}`
|
||||
},
|
||||
shortName: {
|
||||
value: user.firstName
|
||||
},
|
||||
email: {
|
||||
email: user.email as string,
|
||||
isVerified: user.isEmailVerified as boolean
|
||||
},
|
||||
externalId: {
|
||||
value: user.id
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (customerUpsertRes.error) {
|
||||
throw new InternalServerError({ message: customerUpsertRes.error.message });
|
||||
}
|
||||
|
||||
const createThreadRes = await client.createThread({
|
||||
title: "Wish",
|
||||
customerIdentifier: {
|
||||
externalId: customerUpsertRes.data.customer.externalId
|
||||
},
|
||||
components: [
|
||||
{
|
||||
componentText: {
|
||||
text
|
||||
}
|
||||
}
|
||||
],
|
||||
labelTypeIds: appCfg.PLAIN_WISH_LABEL_IDS?.split(",")
|
||||
});
|
||||
|
||||
if (createThreadRes.error) {
|
||||
throw new InternalServerError({
|
||||
message: createThreadRes.error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
return {
|
||||
createUserWish
|
||||
};
|
||||
};
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
TUserActionsUpdate,
|
||||
TUserEncryptionKeys,
|
||||
TUserEncryptionKeysInsert,
|
||||
TUserEncryptionKeysUpdate
|
||||
TUserEncryptionKeysUpdate,
|
||||
TUsers
|
||||
} 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 TUserDALFactory = ReturnType<typeof userDALFactory>;
|
||||
|
||||
@@ -18,6 +19,39 @@ export const userDALFactory = (db: TDbClient) => {
|
||||
const userOrm = ormify(db, TableName.Users);
|
||||
const findUserByUsername = async (username: string, tx?: Knex) => userOrm.findOne({ username }, tx);
|
||||
|
||||
const getUsersByFilter = async ({
|
||||
limit,
|
||||
offset,
|
||||
searchTerm,
|
||||
sortBy
|
||||
}: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
searchTerm: string;
|
||||
sortBy?: keyof TUsers;
|
||||
}) => {
|
||||
try {
|
||||
let query = db.replicaNode()(TableName.Users).where("isGhost", "=", false);
|
||||
if (searchTerm) {
|
||||
query = query.where((qb) => {
|
||||
void qb
|
||||
.whereILike("email", `%${searchTerm}%`)
|
||||
.orWhereILike("firstName", `%${searchTerm}%`)
|
||||
.orWhereILike("lastName", `%${searchTerm}%`)
|
||||
.orWhereLike("username", `%${searchTerm}%`);
|
||||
});
|
||||
}
|
||||
|
||||
if (sortBy) {
|
||||
query = query.orderBy(sortBy);
|
||||
}
|
||||
|
||||
return await query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Users));
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Get users by filter" });
|
||||
}
|
||||
};
|
||||
|
||||
// USER ENCRYPTION FUNCTIONS
|
||||
// -------------------------
|
||||
const findUserEncKeyByUsername = async ({ username }: { username: string }) => {
|
||||
@@ -159,6 +193,7 @@ export const userDALFactory = (db: TDbClient) => {
|
||||
upsertUserEncryptionKey,
|
||||
createUserEncryption,
|
||||
findOneUserAction,
|
||||
createUserAction
|
||||
createUserAction,
|
||||
getUsersByFilter
|
||||
};
|
||||
};
|
||||
|
||||
@@ -201,7 +201,7 @@ export const userServiceFactory = ({
|
||||
return user;
|
||||
};
|
||||
|
||||
const deleteMe = async (userId: string) => {
|
||||
const deleteUser = async (userId: string) => {
|
||||
const user = await userDAL.deleteById(userId);
|
||||
return user;
|
||||
};
|
||||
@@ -301,7 +301,7 @@ export const userServiceFactory = ({
|
||||
toggleUserMfa,
|
||||
updateUserName,
|
||||
updateAuthMethods,
|
||||
deleteMe,
|
||||
deleteUser,
|
||||
getMe,
|
||||
createUserAction,
|
||||
getUserAction,
|
||||
|
||||
@@ -4,55 +4,63 @@ import { AxiosError } from "axios";
|
||||
import picomatch from "picomatch";
|
||||
|
||||
import { SecretKeyEncoding, TWebhooks } from "@app/db/schemas";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { decryptSymmetric, decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
|
||||
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
import { TWebhookDALFactory } from "./webhook-dal";
|
||||
import { WebhookType } from "./webhook-types";
|
||||
|
||||
const WEBHOOK_TRIGGER_TIMEOUT = 15 * 1000;
|
||||
export const triggerWebhookRequest = async (
|
||||
{ url, encryptedSecretKey, iv, tag, keyEncoding }: TWebhooks,
|
||||
data: Record<string, unknown>
|
||||
) => {
|
||||
const headers: Record<string, string> = {};
|
||||
const payload = { ...data, timestamp: Date.now() };
|
||||
const appCfg = getConfig();
|
||||
|
||||
export const decryptWebhookDetails = (webhook: TWebhooks) => {
|
||||
const { keyEncoding, iv, encryptedSecretKey, tag, urlCipherText, urlIV, urlTag, url } = webhook;
|
||||
|
||||
let decryptedSecretKey = "";
|
||||
let decryptedUrl = url;
|
||||
|
||||
if (encryptedSecretKey) {
|
||||
const encryptionKey = appCfg.ENCRYPTION_KEY;
|
||||
const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY;
|
||||
let secretKey;
|
||||
if (rootEncryptionKey && keyEncoding === SecretKeyEncoding.BASE64) {
|
||||
// case: encoding scheme is base64
|
||||
secretKey = decryptSymmetric({
|
||||
ciphertext: encryptedSecretKey,
|
||||
iv: iv as string,
|
||||
tag: tag as string,
|
||||
key: rootEncryptionKey
|
||||
});
|
||||
} else if (encryptionKey && keyEncoding === SecretKeyEncoding.UTF8) {
|
||||
// case: encoding scheme is utf8
|
||||
secretKey = decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: encryptedSecretKey,
|
||||
iv: iv as string,
|
||||
tag: tag as string,
|
||||
key: encryptionKey
|
||||
});
|
||||
}
|
||||
if (secretKey) {
|
||||
const webhookSign = crypto.createHmac("sha256", secretKey).update(JSON.stringify(payload)).digest("hex");
|
||||
headers["x-infisical-signature"] = `t=${payload.timestamp};${webhookSign}`;
|
||||
}
|
||||
decryptedSecretKey = infisicalSymmetricDecrypt({
|
||||
keyEncoding: keyEncoding as SecretKeyEncoding,
|
||||
ciphertext: encryptedSecretKey,
|
||||
iv: iv as string,
|
||||
tag: tag as string
|
||||
});
|
||||
}
|
||||
|
||||
if (urlCipherText) {
|
||||
decryptedUrl = infisicalSymmetricDecrypt({
|
||||
keyEncoding: keyEncoding as SecretKeyEncoding,
|
||||
ciphertext: urlCipherText,
|
||||
iv: urlIV as string,
|
||||
tag: urlTag as string
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
secretKey: decryptedSecretKey,
|
||||
url: decryptedUrl
|
||||
};
|
||||
};
|
||||
|
||||
export const triggerWebhookRequest = async (webhook: TWebhooks, data: Record<string, unknown>) => {
|
||||
const headers: Record<string, string> = {};
|
||||
const payload = { ...data, timestamp: Date.now() };
|
||||
const { secretKey, url } = decryptWebhookDetails(webhook);
|
||||
|
||||
if (secretKey) {
|
||||
const webhookSign = crypto.createHmac("sha256", secretKey).update(JSON.stringify(payload)).digest("hex");
|
||||
headers["x-infisical-signature"] = `t=${payload.timestamp};${webhookSign}`;
|
||||
}
|
||||
|
||||
const req = await request.post(url, payload, {
|
||||
headers,
|
||||
timeout: WEBHOOK_TRIGGER_TIMEOUT,
|
||||
signal: AbortSignal.timeout(WEBHOOK_TRIGGER_TIMEOUT)
|
||||
});
|
||||
|
||||
return req;
|
||||
};
|
||||
|
||||
@@ -60,15 +68,48 @@ export const getWebhookPayload = (
|
||||
eventName: string,
|
||||
workspaceId: string,
|
||||
environment: string,
|
||||
secretPath?: string
|
||||
) => ({
|
||||
event: eventName,
|
||||
project: {
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath
|
||||
secretPath?: string,
|
||||
type?: string | null
|
||||
) => {
|
||||
switch (type) {
|
||||
case WebhookType.SLACK:
|
||||
return {
|
||||
text: "A secret value has been added or modified.",
|
||||
attachments: [
|
||||
{
|
||||
color: "#E7F256",
|
||||
fields: [
|
||||
{
|
||||
title: "Workspace ID",
|
||||
value: workspaceId,
|
||||
short: false
|
||||
},
|
||||
{
|
||||
title: "Environment",
|
||||
value: environment,
|
||||
short: false
|
||||
},
|
||||
{
|
||||
title: "Secret Path",
|
||||
value: secretPath,
|
||||
short: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
case WebhookType.GENERAL:
|
||||
default:
|
||||
return {
|
||||
event: eventName,
|
||||
project: {
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export type TFnTriggerWebhookDTO = {
|
||||
projectId: string;
|
||||
@@ -95,9 +136,10 @@ export const fnTriggerWebhook = async ({
|
||||
logger.info("Secret webhook job started", { environment, secretPath, projectId });
|
||||
const webhooksTriggered = await Promise.allSettled(
|
||||
toBeTriggeredHooks.map((hook) =>
|
||||
triggerWebhookRequest(hook, getWebhookPayload("secrets.modified", projectId, environment, secretPath))
|
||||
triggerWebhookRequest(hook, getWebhookPayload("secrets.modified", projectId, environment, secretPath, hook.type))
|
||||
)
|
||||
);
|
||||
|
||||
// filter hooks by status
|
||||
const successWebhooks = webhooksTriggered
|
||||
.filter(({ status }) => status === "fulfilled")
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { SecretEncryptionAlgo, SecretKeyEncoding, TWebhooksInsert } from "@app/db/schemas";
|
||||
import { TWebhooksInsert } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { encryptSymmetric, encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
|
||||
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
import { TWebhookDALFactory } from "./webhook-dal";
|
||||
import { getWebhookPayload, triggerWebhookRequest } from "./webhook-fns";
|
||||
import { decryptWebhookDetails, getWebhookPayload, triggerWebhookRequest } from "./webhook-fns";
|
||||
import {
|
||||
TCreateWebhookDTO,
|
||||
TDeleteWebhookDTO,
|
||||
@@ -36,7 +35,8 @@ export const webhookServiceFactory = ({ webhookDAL, projectEnvDAL, permissionSer
|
||||
webhookUrl,
|
||||
environment,
|
||||
secretPath,
|
||||
webhookSecretKey
|
||||
webhookSecretKey,
|
||||
type
|
||||
}: TCreateWebhookDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
@@ -50,30 +50,29 @@ export const webhookServiceFactory = ({ webhookDAL, projectEnvDAL, permissionSer
|
||||
if (!env) throw new BadRequestError({ message: "Env not found" });
|
||||
|
||||
const insertDoc: TWebhooksInsert = {
|
||||
url: webhookUrl,
|
||||
url: "", // deprecated - we are moving away from plaintext URLs
|
||||
envId: env.id,
|
||||
isDisabled: false,
|
||||
secretPath: secretPath || "/"
|
||||
secretPath: secretPath || "/",
|
||||
type
|
||||
};
|
||||
|
||||
if (webhookSecretKey) {
|
||||
const appCfg = getConfig();
|
||||
const encryptionKey = appCfg.ENCRYPTION_KEY;
|
||||
const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY;
|
||||
if (rootEncryptionKey) {
|
||||
const { ciphertext, iv, tag } = encryptSymmetric(webhookSecretKey, rootEncryptionKey);
|
||||
insertDoc.encryptedSecretKey = ciphertext;
|
||||
insertDoc.iv = iv;
|
||||
insertDoc.tag = tag;
|
||||
insertDoc.algorithm = SecretEncryptionAlgo.AES_256_GCM;
|
||||
insertDoc.keyEncoding = SecretKeyEncoding.BASE64;
|
||||
} else if (encryptionKey) {
|
||||
const { ciphertext, iv, tag } = encryptSymmetric128BitHexKeyUTF8(webhookSecretKey, encryptionKey);
|
||||
insertDoc.encryptedSecretKey = ciphertext;
|
||||
insertDoc.iv = iv;
|
||||
insertDoc.tag = tag;
|
||||
insertDoc.algorithm = SecretEncryptionAlgo.AES_256_GCM;
|
||||
insertDoc.keyEncoding = SecretKeyEncoding.UTF8;
|
||||
}
|
||||
const { ciphertext, iv, tag, algorithm, encoding } = infisicalSymmetricEncypt(webhookSecretKey);
|
||||
insertDoc.encryptedSecretKey = ciphertext;
|
||||
insertDoc.iv = iv;
|
||||
insertDoc.tag = tag;
|
||||
insertDoc.algorithm = algorithm;
|
||||
insertDoc.keyEncoding = encoding;
|
||||
}
|
||||
|
||||
if (webhookUrl) {
|
||||
const { ciphertext, iv, tag, algorithm, encoding } = infisicalSymmetricEncypt(webhookUrl);
|
||||
insertDoc.urlCipherText = ciphertext;
|
||||
insertDoc.urlIV = iv;
|
||||
insertDoc.urlTag = tag;
|
||||
insertDoc.algorithm = algorithm;
|
||||
insertDoc.keyEncoding = encoding;
|
||||
}
|
||||
|
||||
const webhook = await webhookDAL.create(insertDoc);
|
||||
@@ -131,7 +130,7 @@ export const webhookServiceFactory = ({ webhookDAL, projectEnvDAL, permissionSer
|
||||
try {
|
||||
await triggerWebhookRequest(
|
||||
webhook,
|
||||
getWebhookPayload("test", webhook.projectId, webhook.environment.slug, webhook.secretPath)
|
||||
getWebhookPayload("test", webhook.projectId, webhook.environment.slug, webhook.secretPath, webhook.type)
|
||||
);
|
||||
} catch (err) {
|
||||
webhookError = (err as Error).message;
|
||||
@@ -162,7 +161,14 @@ export const webhookServiceFactory = ({ webhookDAL, projectEnvDAL, permissionSer
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks);
|
||||
|
||||
return webhookDAL.findAllWebhooks(projectId, environment, secretPath);
|
||||
const webhooks = await webhookDAL.findAllWebhooks(projectId, environment, secretPath);
|
||||
return webhooks.map((w) => {
|
||||
const { url } = decryptWebhookDetails(w);
|
||||
return {
|
||||
...w,
|
||||
url
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,7 @@ export type TCreateWebhookDTO = {
|
||||
secretPath?: string;
|
||||
webhookUrl: string;
|
||||
webhookSecretKey?: string;
|
||||
type: string;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TUpdateWebhookDTO = {
|
||||
@@ -24,3 +25,8 @@ export type TListWebhookDTO = {
|
||||
environment?: string;
|
||||
secretPath?: string;
|
||||
} & TProjectPermission;
|
||||
|
||||
export enum WebhookType {
|
||||
GENERAL = "general",
|
||||
SLACK = "slack"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user