mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 00:52:09 +00:00
Add org-scoped auth to org-level endpoints, add migration file for org enableAuth field
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
import { TAuthTokens, TAuthTokenSessions } from "@app/db/schemas";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { UnauthorizedError } from "@app/lib/errors";
|
||||
|
||||
import { AuthModeJwtTokenPayload } from "../auth/auth-type";
|
||||
import { TUserDALFactory } from "../user/user-dal";
|
||||
import { TTokenDALFactory } from "./auth-token-dal";
|
||||
import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenForUserDTO } from "./auth-token-types";
|
||||
|
||||
type TAuthTokenServiceFactoryDep = {
|
||||
tokenDAL: TTokenDALFactory;
|
||||
userDAL: Pick<TUserDALFactory, "findById">;
|
||||
};
|
||||
export type TAuthTokenServiceFactory = ReturnType<typeof tokenServiceFactory>;
|
||||
|
||||
export const getTokenConfig = (tokenType: TokenType) => {
|
||||
// generate random token based on specified token use-case
|
||||
// type [type]
|
||||
switch (tokenType) {
|
||||
case TokenType.TOKEN_EMAIL_CONFIRMATION: {
|
||||
// generate random 6-digit code
|
||||
const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1));
|
||||
const expiresAt = new Date(new Date().getTime() + 86400000);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
case TokenType.TOKEN_EMAIL_MFA: {
|
||||
// generate random 6-digit code
|
||||
const token = String(crypto.randomInt(10 ** 5, 10 ** 6 - 1));
|
||||
const triesLeft = 5;
|
||||
const expiresAt = new Date(new Date().getTime() + 300000);
|
||||
return { token, triesLeft, expiresAt };
|
||||
}
|
||||
case TokenType.TOKEN_EMAIL_ORG_INVITATION: {
|
||||
// generate random hex
|
||||
const token = crypto.randomBytes(16).toString("hex");
|
||||
const expiresAt = new Date(new Date().getTime() + 259200000);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
case TokenType.TOKEN_EMAIL_PASSWORD_RESET: {
|
||||
// generate random hex
|
||||
const token = crypto.randomBytes(16).toString("hex");
|
||||
const expiresAt = new Date(new Date().getTime() + 86400000);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
default: {
|
||||
const token = crypto.randomBytes(16).toString("hex");
|
||||
const expiresAt = new Date();
|
||||
return { token, expiresAt };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const tokenServiceFactory = ({ tokenDAL, userDAL }: TAuthTokenServiceFactoryDep) => {
|
||||
const createTokenForUser = async ({ type, userId, orgId }: TCreateTokenForUserDTO) => {
|
||||
const { token, ...tkCfg } = getTokenConfig(type);
|
||||
const appCfg = getConfig();
|
||||
const tokenHash = await bcrypt.hash(token, appCfg.SALT_ROUNDS);
|
||||
await tokenDAL.transaction(async (tx) => {
|
||||
await tokenDAL.delete({ userId, type, orgId: orgId || null }, tx);
|
||||
const newToken = await tokenDAL.create(
|
||||
{
|
||||
tokenHash,
|
||||
expiresAt: tkCfg.expiresAt,
|
||||
type,
|
||||
userId,
|
||||
orgId,
|
||||
triesLeft: tkCfg?.triesLeft
|
||||
},
|
||||
tx
|
||||
);
|
||||
return newToken;
|
||||
});
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
const validateTokenForUser = async ({
|
||||
type,
|
||||
userId,
|
||||
code,
|
||||
orgId
|
||||
}: TValidateTokenForUserDTO): Promise<TAuthTokens | undefined> => {
|
||||
const token = await tokenDAL.findOne({ type, userId, orgId: orgId || null });
|
||||
// validate token
|
||||
if (!token) throw new Error("Failed to find token");
|
||||
if (token?.expiresAt && new Date(token.expiresAt) < new Date()) {
|
||||
await tokenDAL.delete({ type, userId, orgId });
|
||||
throw new Error("Token expired. Please try again");
|
||||
}
|
||||
|
||||
const isValidToken = await bcrypt.compare(code, token.tokenHash);
|
||||
if (!isValidToken) {
|
||||
if (token?.triesLeft) {
|
||||
if (token.triesLeft === 1) {
|
||||
await tokenDAL.deleteTokenForUser({ type, userId, orgId: orgId || null });
|
||||
} else {
|
||||
await tokenDAL.decrementTriesField({ type, userId, orgId: orgId || null });
|
||||
}
|
||||
}
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
|
||||
const deletedToken = await tokenDAL.delete({ type, userId, orgId: orgId || null });
|
||||
return deletedToken?.[0];
|
||||
};
|
||||
|
||||
const getUserTokenSession = async ({
|
||||
userId,
|
||||
ip,
|
||||
userAgent
|
||||
}: TIssueAuthTokenDTO): Promise<TAuthTokenSessions | undefined> => {
|
||||
let session = await tokenDAL.findOneTokenSession({ userId, ip, userAgent });
|
||||
if (!session) {
|
||||
session = await tokenDAL.insertTokenSession(userId, ip, userAgent);
|
||||
}
|
||||
return session;
|
||||
};
|
||||
|
||||
const clearTokenSessionById = async (userId: string, sessionId: string): Promise<TAuthTokenSessions | undefined> =>
|
||||
tokenDAL.incrementTokenSessionVersion(userId, sessionId);
|
||||
|
||||
const getUserTokenSessionById = async (id: string, userId: string) => tokenDAL.findOneTokenSession({ id, userId });
|
||||
|
||||
const getTokenSessionByUser = async (userId: string) => tokenDAL.findTokenSessions({ userId });
|
||||
|
||||
const revokeAllMySessions = async (userId: string) => tokenDAL.deleteTokenSession({ userId });
|
||||
|
||||
// to parse jwt identity in inject identity plugin
|
||||
const fnValidateJwtIdentity = async (token: AuthModeJwtTokenPayload) => {
|
||||
const session = await tokenDAL.findOneTokenSession({
|
||||
id: token.tokenVersionId,
|
||||
userId: token.userId
|
||||
});
|
||||
if (!session) throw new UnauthorizedError({ name: "Session not found" });
|
||||
if (token.accessVersion !== session.accessVersion) throw new UnauthorizedError({ name: "Stale session" });
|
||||
|
||||
const user = await userDAL.findById(session.userId);
|
||||
if (!user || !user.isAccepted) throw new UnauthorizedError({ name: "Token user not found" });
|
||||
|
||||
return { user, tokenVersionId: token.tokenVersionId, orgId: token.organizationId };
|
||||
};
|
||||
|
||||
return {
|
||||
createTokenForUser,
|
||||
validateTokenForUser,
|
||||
getUserTokenSession,
|
||||
clearTokenSessionById,
|
||||
getTokenSessionByUser,
|
||||
revokeAllMySessions,
|
||||
fnValidateJwtIdentity,
|
||||
getUserTokenSessionById
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
|
||||
|
||||
import { AuthModeProviderJwtTokenPayload, AuthModeProviderSignUpTokenPayload, AuthTokenType } from "./auth-type";
|
||||
|
||||
export const validateProviderAuthToken = (providerToken: string, email: string) => {
|
||||
if (!providerToken) throw new UnauthorizedError();
|
||||
const appCfg = getConfig();
|
||||
const decodedToken = jwt.verify(providerToken, appCfg.AUTH_SECRET) as AuthModeProviderJwtTokenPayload;
|
||||
|
||||
if (decodedToken.authTokenType !== AuthTokenType.PROVIDER_TOKEN) throw new UnauthorizedError();
|
||||
if (decodedToken.email !== email) throw new Error("Invalid auth credentials");
|
||||
|
||||
if (decodedToken.organizationId) {
|
||||
return { orgId: decodedToken.organizationId };
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
export const validateSignUpAuthorization = (token: string, userId: string, validate = true) => {
|
||||
const appCfg = getConfig();
|
||||
const [AUTH_TOKEN_TYPE, AUTH_TOKEN_VALUE] = <[string, string]>token?.split(" ", 2) ?? [null, null];
|
||||
if (AUTH_TOKEN_TYPE === null) {
|
||||
throw new BadRequestError({ message: "Missing Authorization Header in the request header." });
|
||||
}
|
||||
if (AUTH_TOKEN_TYPE.toLowerCase() !== "bearer") {
|
||||
throw new BadRequestError({
|
||||
message: `The provided authentication type '${AUTH_TOKEN_TYPE}' is not supported.`
|
||||
});
|
||||
}
|
||||
if (AUTH_TOKEN_VALUE === null) {
|
||||
throw new BadRequestError({
|
||||
message: "Missing Authorization Body in the request header"
|
||||
});
|
||||
}
|
||||
|
||||
const decodedToken = jwt.verify(AUTH_TOKEN_VALUE, appCfg.AUTH_SECRET) as AuthModeProviderSignUpTokenPayload;
|
||||
if (!validate) return decodedToken;
|
||||
|
||||
if (decodedToken.authTokenType !== AuthTokenType.SIGNUP_TOKEN) throw new UnauthorizedError();
|
||||
if (decodedToken.userId !== userId) throw new UnauthorizedError();
|
||||
};
|
||||
@@ -0,0 +1,322 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
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 { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
|
||||
import { TokenType } from "../auth-token/auth-token-types";
|
||||
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||
import { TUserDALFactory } from "../user/user-dal";
|
||||
import { validateProviderAuthToken } from "./auth-fns";
|
||||
import {
|
||||
TLoginClientProofDTO,
|
||||
TLoginGenServerPublicKeyDTO,
|
||||
TOauthLoginDTO,
|
||||
TVerifyMfaTokenDTO
|
||||
} from "./auth-login-type";
|
||||
import { AuthMethod, AuthTokenType } from "./auth-type";
|
||||
|
||||
type TAuthLoginServiceFactoryDep = {
|
||||
userDAL: TUserDALFactory;
|
||||
tokenService: TAuthTokenServiceFactory;
|
||||
smtpService: TSmtpService;
|
||||
};
|
||||
|
||||
export type TAuthLoginFactory = ReturnType<typeof authLoginServiceFactory>;
|
||||
export const authLoginServiceFactory = ({ userDAL, tokenService, smtpService }: TAuthLoginServiceFactoryDep) => {
|
||||
/*
|
||||
* Private
|
||||
* Not exported. This is to update user device list
|
||||
* If new device is found. Will be saved and a mail will be send
|
||||
*/
|
||||
const updateUserDeviceSession = async (user: TUsers, ip: string, userAgent: string) => {
|
||||
const devices = await UserDeviceSchema.parseAsync(user.devices || []);
|
||||
const isDeviceSeen = devices.some((device) => device.ip === ip && device.userAgent === userAgent);
|
||||
|
||||
if (!isDeviceSeen) {
|
||||
const newDeviceList = devices.concat([{ ip, userAgent }]);
|
||||
await userDAL.updateById(user.id, { devices: JSON.stringify(newDeviceList) });
|
||||
await smtpService.sendMail({
|
||||
template: SmtpTemplates.NewDeviceJoin,
|
||||
subjectLine: "Successful login from new device",
|
||||
recipients: [user.email],
|
||||
substitutions: {
|
||||
email: user.email,
|
||||
timestamp: new Date().toString(),
|
||||
ip,
|
||||
userAgent
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Private
|
||||
* Send mfa code via email
|
||||
* */
|
||||
const sendUserMfaCode = async ({
|
||||
userId,
|
||||
email
|
||||
}: {
|
||||
userId: string;
|
||||
email: string;
|
||||
}) => {
|
||||
const code = await tokenService.createTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_MFA,
|
||||
userId
|
||||
});
|
||||
|
||||
await smtpService.sendMail({
|
||||
template: SmtpTemplates.EmailMfa,
|
||||
subjectLine: "Infisical MFA code",
|
||||
recipients: [email],
|
||||
substitutions: {
|
||||
code
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
* Check user device and send mail if new device
|
||||
* generate the auth and refresh token. fn shared by mfa verification and login verification with mfa disabled
|
||||
*/
|
||||
const generateUserTokens = async ({
|
||||
user,
|
||||
ip,
|
||||
userAgent,
|
||||
organizationId
|
||||
}: {
|
||||
user: TUsers;
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
organizationId?: string;
|
||||
}) => {
|
||||
const cfg = getConfig();
|
||||
await updateUserDeviceSession(user, ip, userAgent);
|
||||
const tokenSession = await tokenService.getUserTokenSession({
|
||||
userAgent,
|
||||
ip,
|
||||
userId: user.id
|
||||
});
|
||||
if (!tokenSession) throw new Error("Failed to create token");
|
||||
const accessToken = jwt.sign(
|
||||
{
|
||||
authTokenType: AuthTokenType.ACCESS_TOKEN,
|
||||
userId: user.id,
|
||||
tokenVersionId: tokenSession.id,
|
||||
accessVersion: tokenSession.accessVersion,
|
||||
organizationId
|
||||
},
|
||||
cfg.AUTH_SECRET,
|
||||
{ expiresIn: cfg.JWT_AUTH_LIFETIME }
|
||||
);
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{
|
||||
authTokenType: AuthTokenType.REFRESH_TOKEN,
|
||||
userId: user.id,
|
||||
tokenVersionId: tokenSession.id,
|
||||
refreshVersion: tokenSession.refreshVersion,
|
||||
organizationId
|
||||
},
|
||||
cfg.AUTH_SECRET,
|
||||
{ expiresIn: cfg.JWT_REFRESH_LIFETIME }
|
||||
);
|
||||
|
||||
return { access: accessToken, refresh: refreshToken };
|
||||
};
|
||||
|
||||
/*
|
||||
* Step 1 of login. To get server public key in exchange of client public key
|
||||
*/
|
||||
const loginGenServerPublicKey = async ({
|
||||
email,
|
||||
providerAuthToken,
|
||||
clientPublicKey
|
||||
}: TLoginGenServerPublicKeyDTO) => {
|
||||
const userEnc = await userDAL.findUserEncKeyByEmail(email);
|
||||
if (!userEnc || (userEnc && !userEnc.isAccepted)) {
|
||||
throw new Error("Failed to find user");
|
||||
}
|
||||
if (!userEnc.authMethods?.includes(AuthMethod.EMAIL)) {
|
||||
validateProviderAuthToken(providerAuthToken as string, email);
|
||||
}
|
||||
|
||||
const serverSrpKey = await generateSrpServerKey(userEnc.salt, userEnc.verifier);
|
||||
const userEncKeys = await userDAL.updateUserEncryptionByUserId(userEnc.userId, {
|
||||
clientPublicKey,
|
||||
serverPrivateKey: serverSrpKey.privateKey
|
||||
});
|
||||
if (!userEncKeys) throw new Error("Failed to update encryption key");
|
||||
return { salt: userEncKeys.salt, serverPublicKey: serverSrpKey.pubKey };
|
||||
};
|
||||
|
||||
/*
|
||||
* Step 2 of login. Pass the client proof and with multi factor setup handle the required steps
|
||||
*/
|
||||
const loginExchangeClientProof = async ({
|
||||
email,
|
||||
clientProof,
|
||||
providerAuthToken,
|
||||
ip,
|
||||
userAgent
|
||||
}: TLoginClientProofDTO) => {
|
||||
const userEnc = await userDAL.findUserEncKeyByEmail(email);
|
||||
if (!userEnc) throw new Error("Failed to find user");
|
||||
const cfg = getConfig();
|
||||
|
||||
let organizationId;
|
||||
if (!userEnc.authMethods?.includes(AuthMethod.EMAIL)) {
|
||||
const { orgId } = validateProviderAuthToken(providerAuthToken as string, email);
|
||||
organizationId = orgId;
|
||||
}
|
||||
|
||||
if (!userEnc.serverPrivateKey || !userEnc.clientPublicKey) throw new Error("Failed to authenticate. Try again?");
|
||||
const isValidClientProof = await srpCheckClientProof(
|
||||
userEnc.salt,
|
||||
userEnc.verifier,
|
||||
userEnc.serverPrivateKey,
|
||||
userEnc.clientPublicKey,
|
||||
clientProof
|
||||
);
|
||||
if (!isValidClientProof) throw new Error("Failed to authenticate. Try again?");
|
||||
|
||||
await userDAL.updateUserEncryptionByUserId(userEnc.userId, {
|
||||
serverPrivateKey: null,
|
||||
clientPublicKey: null
|
||||
});
|
||||
// send multi factor auth token if they it enabled
|
||||
if (userEnc.isMfaEnabled) {
|
||||
const mfaToken = jwt.sign({
|
||||
authTokenType: AuthTokenType.MFA_TOKEN,
|
||||
userId: userEnc.userId,
|
||||
organizationId
|
||||
}, cfg.AUTH_SECRET, {
|
||||
expiresIn: cfg.JWT_MFA_LIFETIME
|
||||
});
|
||||
|
||||
await sendUserMfaCode({
|
||||
userId: userEnc.userId,
|
||||
email: userEnc.email
|
||||
});
|
||||
|
||||
return { isMfaEnabled: true, token: mfaToken } as const;
|
||||
}
|
||||
|
||||
const token = await generateUserTokens({
|
||||
user: {
|
||||
...userEnc,
|
||||
id: userEnc.userId
|
||||
},
|
||||
ip,
|
||||
userAgent,
|
||||
organizationId
|
||||
});
|
||||
|
||||
return { token, isMfaEnabled: false, user: userEnc } as const;
|
||||
};
|
||||
|
||||
/*
|
||||
* Multi factor authentication re-send code, Get user id from token
|
||||
* saved in frontend
|
||||
*/
|
||||
const resendMfaToken = async (userId: string) => {
|
||||
const user = await userDAL.findById(userId);
|
||||
if (!user) return;
|
||||
await sendUserMfaCode({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
* Multi factor authentication verification of code
|
||||
* Third step of login in which user completes with mfa
|
||||
* */
|
||||
const verifyMfaToken = async ({ userId, mfaToken, ip, userAgent, orgId }: TVerifyMfaTokenDTO) => {
|
||||
await tokenService.validateTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_MFA,
|
||||
userId,
|
||||
code: mfaToken
|
||||
});
|
||||
const userEnc = await userDAL.findUserEncKeyByUserId(userId);
|
||||
if (!userEnc) throw new Error("Failed to authenticate user");
|
||||
|
||||
const token = await generateUserTokens({
|
||||
user: {
|
||||
...userEnc,
|
||||
id: userEnc.userId
|
||||
},
|
||||
ip,
|
||||
userAgent,
|
||||
organizationId: orgId
|
||||
});
|
||||
|
||||
return { token, user: userEnc };
|
||||
};
|
||||
/*
|
||||
* OAuth2 login for google,github, and other oauth2 provider
|
||||
* */
|
||||
const oauth2Login = async ({
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
authMethod,
|
||||
callbackPort,
|
||||
isSignupAllowed
|
||||
}: TOauthLoginDTO) => {
|
||||
let user = await userDAL.findUserByEmail(email);
|
||||
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] });
|
||||
}
|
||||
const isLinkingRequired = !user?.authMethods?.includes(authMethod);
|
||||
const isUserCompleted = user.isAccepted;
|
||||
const providerAuthToken = jwt.sign(
|
||||
{
|
||||
authTokenType: AuthTokenType.PROVIDER_TOKEN,
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
authMethod,
|
||||
isUserCompleted,
|
||||
isLinkingRequired,
|
||||
...(callbackPort
|
||||
? {
|
||||
callbackPort
|
||||
}
|
||||
: {})
|
||||
},
|
||||
appCfg.AUTH_SECRET,
|
||||
{
|
||||
expiresIn: appCfg.JWT_PROVIDER_AUTH_LIFETIME
|
||||
}
|
||||
);
|
||||
|
||||
return { isUserCompleted, providerAuthToken };
|
||||
};
|
||||
|
||||
/*
|
||||
* logout user by incrementing the version by 1 meaning any old session will become invalid
|
||||
* as there number is behind
|
||||
* */
|
||||
const logout = async (userId: string, sessionId: string) => {
|
||||
await tokenService.clearTokenSessionById(userId, sessionId);
|
||||
};
|
||||
|
||||
return {
|
||||
loginGenServerPublicKey,
|
||||
loginExchangeClientProof,
|
||||
logout,
|
||||
oauth2Login,
|
||||
resendMfaToken,
|
||||
verifyMfaToken,
|
||||
generateUserTokens
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { AuthMethod } from "./auth-type";
|
||||
|
||||
export type TLoginGenServerPublicKeyDTO = {
|
||||
email: string;
|
||||
clientPublicKey: string;
|
||||
providerAuthToken?: string;
|
||||
};
|
||||
|
||||
export type TLoginClientProofDTO = {
|
||||
email: string;
|
||||
clientProof: string;
|
||||
providerAuthToken?: string;
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
};
|
||||
|
||||
export type TVerifyMfaTokenDTO = {
|
||||
userId: string;
|
||||
mfaToken: string;
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
orgId?: string;
|
||||
};
|
||||
|
||||
export type TOauthLoginDTO = {
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
authMethod: AuthMethod;
|
||||
callbackPort?: string;
|
||||
isSignupAllowed?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
export enum AuthMethod {
|
||||
EMAIL = "email",
|
||||
GOOGLE = "google",
|
||||
GITHUB = "github",
|
||||
GITLAB = "gitlab",
|
||||
OKTA_SAML = "okta-saml",
|
||||
AZURE_SAML = "azure-saml",
|
||||
JUMPCLOUD_SAML = "jumpcloud-saml"
|
||||
}
|
||||
|
||||
export enum AuthTokenType {
|
||||
ACCESS_TOKEN = "accessToken",
|
||||
REFRESH_TOKEN = "refreshToken",
|
||||
SIGNUP_TOKEN = "signupToken", // TODO: remove in favor of claim
|
||||
MFA_TOKEN = "mfaToken", // TODO: remove in favor of claim
|
||||
PROVIDER_TOKEN = "providerToken", // TODO: remove in favor of claim
|
||||
API_KEY = "apiKey",
|
||||
SERVICE_ACCESS_TOKEN = "serviceAccessToken",
|
||||
SERVICE_REFRESH_TOKEN = "serviceRefreshToken",
|
||||
IDENTITY_ACCESS_TOKEN = "identityAccessToken"
|
||||
}
|
||||
|
||||
export enum AuthMode {
|
||||
JWT = "jwt",
|
||||
SERVICE_TOKEN = "serviceToken",
|
||||
API_KEY = "apiKey",
|
||||
IDENTITY_ACCESS_TOKEN = "identityAccessToken"
|
||||
}
|
||||
|
||||
export enum ActorType { // would extend to AWS, Azure, ...
|
||||
USER = "user", // userIdentity
|
||||
SERVICE = "service",
|
||||
IDENTITY = "identity",
|
||||
Machine = "machine"
|
||||
}
|
||||
|
||||
export type AuthModeJwtTokenPayload = {
|
||||
authTokenType: AuthTokenType.ACCESS_TOKEN;
|
||||
userId: string;
|
||||
tokenVersionId: string;
|
||||
accessVersion: number;
|
||||
organizationId?: string;
|
||||
};
|
||||
|
||||
export type AuthModeMfaJwtTokenPayload = {
|
||||
authTokenType: AuthTokenType.MFA_TOKEN;
|
||||
userId: string;
|
||||
organizationId?: string;
|
||||
};
|
||||
|
||||
export type AuthModeRefreshJwtTokenPayload = {
|
||||
authTokenType: AuthTokenType.REFRESH_TOKEN;
|
||||
userId: string;
|
||||
tokenVersionId: string;
|
||||
refreshVersion: number;
|
||||
organizationId?: string;
|
||||
};
|
||||
|
||||
export type AuthModeProviderJwtTokenPayload = {
|
||||
authTokenType: AuthTokenType.PROVIDER_TOKEN;
|
||||
email: string;
|
||||
organizationId?: string;
|
||||
};
|
||||
|
||||
export type AuthModeProviderSignUpTokenPayload = {
|
||||
authTokenType: AuthTokenType.SIGNUP_TOKEN;
|
||||
userId: string;
|
||||
};
|
||||
@@ -0,0 +1,435 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import bcrypt from "bcrypt";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import { IdentityAuthMethod } 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, UnauthorizedError } from "@app/lib/errors";
|
||||
import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } 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 { TIdentityUaClientSecretDALFactory } from "./identity-ua-client-secret-dal";
|
||||
import { TIdentityUaDALFactory } from "./identity-ua-dal";
|
||||
import {
|
||||
TAttachUaDTO,
|
||||
TCreateUaClientSecretDTO,
|
||||
TGetUaClientSecretsDTO,
|
||||
TGetUaDTO,
|
||||
TRevokeUaClientSecretDTO,
|
||||
TUpdateUaDTO
|
||||
} from "./identity-ua-types";
|
||||
|
||||
type TIdentityUaServiceFactoryDep = {
|
||||
identityUaDAL: TIdentityUaDALFactory;
|
||||
identityUaClientSecretDAL: TIdentityUaClientSecretDALFactory;
|
||||
identityAccessTokenDAL: TIdentityAccessTokenDALFactory;
|
||||
identityOrgMembershipDAL: TIdentityOrgDALFactory;
|
||||
identityDAL: Pick<TIdentityDALFactory, "updateById">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
};
|
||||
|
||||
export type TIdentityUaServiceFactory = ReturnType<typeof identityUaServiceFactory>;
|
||||
|
||||
export const identityUaServiceFactory = ({
|
||||
identityUaDAL,
|
||||
identityUaClientSecretDAL,
|
||||
identityAccessTokenDAL,
|
||||
identityOrgMembershipDAL,
|
||||
identityDAL,
|
||||
permissionService,
|
||||
licenseService
|
||||
}: TIdentityUaServiceFactoryDep) => {
|
||||
const login = async (clientId: string, clientSecret: string, ip: string) => {
|
||||
const identityUa = await identityUaDAL.findOne({ clientId });
|
||||
if (!identityUa) throw new UnauthorizedError();
|
||||
|
||||
checkIPAgainstBlocklist({
|
||||
ipAddress: ip,
|
||||
trustedIps: identityUa.clientSecretTrustedIps as TIp[]
|
||||
});
|
||||
const clientSecrtInfo = await identityUaClientSecretDAL.find({
|
||||
identityUAId: identityUa.id,
|
||||
isClientSecretRevoked: false
|
||||
});
|
||||
|
||||
const validClientSecretInfo = clientSecrtInfo.find(({ clientSecretHash }) =>
|
||||
bcrypt.compareSync(clientSecret, clientSecretHash)
|
||||
);
|
||||
if (!validClientSecretInfo) throw new UnauthorizedError();
|
||||
|
||||
const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo;
|
||||
if (clientSecretTTL > 0) {
|
||||
const clientSecretCreated = new Date(validClientSecretInfo.createdAt);
|
||||
const ttlInMilliseconds = clientSecretTTL * 1000;
|
||||
const currentDate = new Date();
|
||||
const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds);
|
||||
|
||||
if (currentDate > expirationTime) {
|
||||
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
|
||||
isClientSecretRevoked: true
|
||||
});
|
||||
|
||||
throw new UnauthorizedError({
|
||||
message: "Failed to authenticate identity credentials due to expired client secret"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (clientSecretNumUsesLimit > 0 && clientSecretNumUses === clientSecretNumUsesLimit) {
|
||||
// number of times client secret can be used for
|
||||
// a login operation reached
|
||||
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
|
||||
isClientSecretRevoked: true
|
||||
});
|
||||
throw new UnauthorizedError({
|
||||
message: "Failed to authenticate identity credentials due to client secret number of uses limit reached"
|
||||
});
|
||||
}
|
||||
|
||||
const identityAccessToken = await identityUaDAL.transaction(async (tx) => {
|
||||
const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo.id, tx);
|
||||
const newToken = await identityAccessTokenDAL.create(
|
||||
{
|
||||
identityId: identityUa.identityId,
|
||||
isAccessTokenRevoked: false,
|
||||
identityUAClientSecretId: uaClientSecretDoc.id,
|
||||
accessTokenTTL: identityUa.accessTokenTTL,
|
||||
accessTokenMaxTTL: identityUa.accessTokenMaxTTL,
|
||||
accessTokenNumUses: 0,
|
||||
accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit
|
||||
},
|
||||
tx
|
||||
);
|
||||
return newToken;
|
||||
});
|
||||
|
||||
const appCfg = getConfig();
|
||||
const accessToken = jwt.sign(
|
||||
{
|
||||
identityId: identityUa.identityId,
|
||||
clientSecretId: validClientSecretInfo.id,
|
||||
identityAccessTokenId: identityAccessToken.id,
|
||||
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
|
||||
} as TIdentityAccessTokenJwtPayload,
|
||||
appCfg.AUTH_SECRET,
|
||||
{
|
||||
expiresIn: identityAccessToken.accessTokenMaxTTL === 0 ? undefined : identityAccessToken.accessTokenMaxTTL
|
||||
}
|
||||
);
|
||||
return { accessToken, identityUa, validClientSecretInfo, identityAccessToken };
|
||||
};
|
||||
|
||||
const attachUa = async ({
|
||||
accessTokenMaxTTL,
|
||||
identityId,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTTL,
|
||||
accessTokenTrustedIps,
|
||||
clientSecretTrustedIps,
|
||||
actorId,
|
||||
actor,
|
||||
actorOrgScope
|
||||
}: TAttachUaDTO) => {
|
||||
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 universal 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, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity);
|
||||
|
||||
const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
|
||||
const reformattedClientSecretTrustedIps = clientSecretTrustedIps.map((clientSecretTrustedIp) => {
|
||||
if (
|
||||
!plan.ipAllowlisting &&
|
||||
clientSecretTrustedIp.ipAddress !== "0.0.0.0/0" &&
|
||||
clientSecretTrustedIp.ipAddress !== "::/0"
|
||||
)
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range."
|
||||
});
|
||||
if (!isValidIpOrCidr(clientSecretTrustedIp.ipAddress))
|
||||
throw new BadRequestError({
|
||||
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
|
||||
});
|
||||
return extractIPDetails(clientSecretTrustedIp.ipAddress);
|
||||
});
|
||||
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 service 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 identityUa = await identityUaDAL.transaction(async (tx) => {
|
||||
const doc = await identityUaDAL.create(
|
||||
{
|
||||
identityId: identityMembershipOrg.identityId,
|
||||
clientId: crypto.randomUUID(),
|
||||
clientSecretTrustedIps: JSON.stringify(reformattedClientSecretTrustedIps),
|
||||
accessTokenMaxTTL,
|
||||
accessTokenTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps)
|
||||
},
|
||||
tx
|
||||
);
|
||||
await identityDAL.updateById(
|
||||
identityMembershipOrg.identityId,
|
||||
{
|
||||
authMethod: IdentityAuthMethod.Univeral
|
||||
},
|
||||
tx
|
||||
);
|
||||
return doc;
|
||||
});
|
||||
return { ...identityUa, orgId: identityMembershipOrg.orgId };
|
||||
};
|
||||
|
||||
const updateUa = async ({
|
||||
accessTokenMaxTTL,
|
||||
identityId,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTTL,
|
||||
accessTokenTrustedIps,
|
||||
clientSecretTrustedIps,
|
||||
actorId,
|
||||
actor,
|
||||
actorOrgScope
|
||||
}: TUpdateUaDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
|
||||
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral)
|
||||
throw new BadRequestError({
|
||||
message: "Failed to updated universal auth"
|
||||
});
|
||||
|
||||
const uaIdentityAuth = await identityUaDAL.findOne({ identityId });
|
||||
|
||||
if (
|
||||
(accessTokenMaxTTL || uaIdentityAuth.accessTokenMaxTTL) > 0 &&
|
||||
(accessTokenTTL || uaIdentityAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || uaIdentityAuth.accessTokenMaxTTL)
|
||||
) {
|
||||
throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" });
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, identityMembershipOrg.orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity);
|
||||
|
||||
const plan = await licenseService.getPlan(identityMembershipOrg.orgId);
|
||||
const reformattedClientSecretTrustedIps = clientSecretTrustedIps?.map((clientSecretTrustedIp) => {
|
||||
if (
|
||||
!plan.ipAllowlisting &&
|
||||
clientSecretTrustedIp.ipAddress !== "0.0.0.0/0" &&
|
||||
clientSecretTrustedIp.ipAddress !== "::/0"
|
||||
)
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Failed to add IP access range to service token due to plan restriction. Upgrade plan to add IP access range."
|
||||
});
|
||||
if (!isValidIpOrCidr(clientSecretTrustedIp.ipAddress))
|
||||
throw new BadRequestError({
|
||||
message: "The IP is not a valid IPv4, IPv6, or CIDR block"
|
||||
});
|
||||
return extractIPDetails(clientSecretTrustedIp.ipAddress);
|
||||
});
|
||||
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 service 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 updatedUaAuth = await identityUaDAL.updateById(uaIdentityAuth.id, {
|
||||
clientSecretTrustedIps: reformattedClientSecretTrustedIps
|
||||
? JSON.stringify(reformattedClientSecretTrustedIps)
|
||||
: undefined,
|
||||
accessTokenMaxTTL,
|
||||
accessTokenTTL,
|
||||
accessTokenNumUsesLimit,
|
||||
accessTokenTrustedIps: reformattedAccessTokenTrustedIps
|
||||
? JSON.stringify(reformattedAccessTokenTrustedIps)
|
||||
: undefined
|
||||
});
|
||||
return { ...updatedUaAuth, orgId: identityMembershipOrg.orgId };
|
||||
};
|
||||
|
||||
const getIdentityUa = async ({ identityId, actorId, actor, actorOrgScope }: TGetUaDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
|
||||
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral)
|
||||
throw new BadRequestError({
|
||||
message: "The identity does not have universal auth"
|
||||
});
|
||||
|
||||
const uaIdentityAuth = await identityUaDAL.findOne({ identityId });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, identityMembershipOrg.orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity);
|
||||
return { ...uaIdentityAuth, orgId: identityMembershipOrg.orgId };
|
||||
};
|
||||
|
||||
const createUaClientSecret = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgScope,
|
||||
identityId,
|
||||
ttl,
|
||||
description,
|
||||
numUsesLimit
|
||||
}: TCreateUaClientSecretDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
|
||||
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral)
|
||||
throw new BadRequestError({
|
||||
message: "The identity does not have universal auth"
|
||||
});
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, identityMembershipOrg.orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity);
|
||||
|
||||
const { permission: rolePermission } = await permissionService.getOrgPermission(
|
||||
ActorType.IDENTITY,
|
||||
identityMembershipOrg.identityId,
|
||||
identityMembershipOrg.orgId,
|
||||
actorOrgScope
|
||||
);
|
||||
const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
if (!hasPriviledge)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to add identity to project with more privileged role"
|
||||
});
|
||||
|
||||
const appCfg = getConfig();
|
||||
const clientSecret = crypto.randomBytes(32).toString("hex");
|
||||
const clientSecretHash = await bcrypt.hash(clientSecret, appCfg.SALT_ROUNDS);
|
||||
const identityUniversalAuth = await identityUaDAL.findOne({
|
||||
identityId
|
||||
});
|
||||
|
||||
const identityUaClientSecret = await identityUaClientSecretDAL.create({
|
||||
identityUAId: identityUniversalAuth.id,
|
||||
description,
|
||||
clientSecretPrefix: clientSecret.slice(0, 4),
|
||||
clientSecretHash,
|
||||
clientSecretNumUses: 0,
|
||||
clientSecretNumUsesLimit: numUsesLimit,
|
||||
clientSecretTTL: ttl,
|
||||
isClientSecretRevoked: false
|
||||
});
|
||||
return {
|
||||
clientSecret,
|
||||
clientSecretData: identityUaClientSecret,
|
||||
uaAuth: identityUniversalAuth,
|
||||
orgId: identityMembershipOrg.orgId
|
||||
};
|
||||
};
|
||||
|
||||
const getUaClientSecrets = async ({ actor, actorId, actorOrgScope, identityId }: TGetUaClientSecretsDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
|
||||
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral)
|
||||
throw new BadRequestError({
|
||||
message: "The identity does not have universal auth"
|
||||
});
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, identityMembershipOrg.orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity);
|
||||
|
||||
const { permission: rolePermission } = await permissionService.getOrgPermission(
|
||||
ActorType.IDENTITY,
|
||||
identityMembershipOrg.identityId,
|
||||
identityMembershipOrg.orgId,
|
||||
actorOrgScope
|
||||
);
|
||||
const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
if (!hasPriviledge)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to add identity to project with more privileged role"
|
||||
});
|
||||
|
||||
const identityUniversalAuth = await identityUaDAL.findOne({
|
||||
identityId
|
||||
});
|
||||
|
||||
const clientSecrets = await identityUaClientSecretDAL.find({
|
||||
identityUAId: identityUniversalAuth.id,
|
||||
isClientSecretRevoked: false
|
||||
});
|
||||
return { clientSecrets, orgId: identityMembershipOrg.orgId };
|
||||
};
|
||||
|
||||
const revokeUaClientSecret = async ({ identityId, actorId, actor, actorOrgScope, clientSecretId }: TRevokeUaClientSecretDTO) => {
|
||||
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
|
||||
if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" });
|
||||
if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.Univeral)
|
||||
throw new BadRequestError({
|
||||
message: "The identity does not have universal auth"
|
||||
});
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, identityMembershipOrg.orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity);
|
||||
|
||||
const { permission: rolePermission } = await permissionService.getOrgPermission(
|
||||
ActorType.IDENTITY,
|
||||
identityMembershipOrg.identityId,
|
||||
identityMembershipOrg.orgId,
|
||||
actorOrgScope
|
||||
);
|
||||
const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
if (!hasPriviledge)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to add identity to project with more privileged role"
|
||||
});
|
||||
|
||||
const clientSecret = await identityUaClientSecretDAL.updateById(clientSecretId, {
|
||||
isClientSecretRevoked: true
|
||||
});
|
||||
return { ...clientSecret, identityId, orgId: identityMembershipOrg.orgId };
|
||||
};
|
||||
|
||||
return {
|
||||
login,
|
||||
attachUa,
|
||||
updateUa,
|
||||
getIdentityUa,
|
||||
createUaClientSecret,
|
||||
getUaClientSecrets,
|
||||
revokeUaClientSecret
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas";
|
||||
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 { TOrgPermission } from "@app/lib/types";
|
||||
|
||||
import { ActorType } from "../auth/auth-type";
|
||||
import { TIdentityDALFactory } from "./identity-dal";
|
||||
import { TIdentityOrgDALFactory } from "./identity-org-dal";
|
||||
import { TCreateIdentityDTO, TDeleteIdentityDTO, TUpdateIdentityDTO } from "./identity-types";
|
||||
|
||||
type TIdentityServiceFactoryDep = {
|
||||
identityDAL: TIdentityDALFactory;
|
||||
identityOrgMembershipDAL: TIdentityOrgDALFactory;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRole">;
|
||||
};
|
||||
|
||||
export type TIdentityServiceFactory = ReturnType<typeof identityServiceFactory>;
|
||||
|
||||
export const identityServiceFactory = ({
|
||||
identityDAL,
|
||||
identityOrgMembershipDAL,
|
||||
permissionService
|
||||
}: TIdentityServiceFactoryDep) => {
|
||||
const createIdentity = async ({ name, role, actor, orgId, actorId, actorOrgScope }: TCreateIdentityDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity);
|
||||
|
||||
const { permission: rolePermission, role: customRole } = await permissionService.getOrgPermissionByRole(
|
||||
role,
|
||||
orgId
|
||||
);
|
||||
const isCustomRole = Boolean(customRole);
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
if (!hasRequiredPriviledges) throw new BadRequestError({ message: "Failed to create a more privileged identity" });
|
||||
|
||||
const identity = await identityDAL.transaction(async (tx) => {
|
||||
const newIdentity = await identityDAL.create({ name }, tx);
|
||||
await identityOrgMembershipDAL.create(
|
||||
{
|
||||
identityId: newIdentity.id,
|
||||
orgId,
|
||||
role: isCustomRole ? OrgMembershipRole.Custom : role,
|
||||
roleId: customRole?.id
|
||||
},
|
||||
tx
|
||||
);
|
||||
return newIdentity;
|
||||
});
|
||||
|
||||
return identity;
|
||||
};
|
||||
|
||||
const updateIdentity = async ({ id, role, name, actor, actorId, actorOrgScope }: TUpdateIdentityDTO) => {
|
||||
const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: id });
|
||||
if (!identityOrgMembership) throw new BadRequestError({ message: `Failed to find identity with id ${id}` });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, identityOrgMembership.orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity);
|
||||
|
||||
const { permission: identityRolePermission } = await permissionService.getOrgPermission(
|
||||
ActorType.IDENTITY,
|
||||
id,
|
||||
identityOrgMembership.orgId,
|
||||
actorOrgScope
|
||||
);
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" });
|
||||
|
||||
let customRole: TOrgRoles | undefined;
|
||||
if (role) {
|
||||
const { permission: rolePermission, role: customOrgRole } = await permissionService.getOrgPermissionByRole(
|
||||
role,
|
||||
identityOrgMembership.orgId
|
||||
);
|
||||
|
||||
const isCustomRole = Boolean(customOrgRole);
|
||||
const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
if (!hasRequiredNewRolePermission)
|
||||
throw new BadRequestError({ message: "Failed to create a more privileged identity" });
|
||||
if (isCustomRole) customRole = customOrgRole;
|
||||
}
|
||||
|
||||
const identity = await identityDAL.transaction(async (tx) => {
|
||||
const newIdentity = name ? await identityDAL.updateById(id, { name }, tx) : await identityDAL.findById(id, tx);
|
||||
if (role) {
|
||||
await identityOrgMembershipDAL.update(
|
||||
{ identityId: id },
|
||||
{
|
||||
role: customRole ? OrgMembershipRole.Custom : role,
|
||||
roleId: customRole?.id
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
return newIdentity;
|
||||
});
|
||||
|
||||
return { ...identity, orgId: identityOrgMembership.orgId };
|
||||
};
|
||||
|
||||
const deleteIdentity = async ({ actorId, actor, actorOrgScope, id }: TDeleteIdentityDTO) => {
|
||||
const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: id });
|
||||
if (!identityOrgMembership) throw new BadRequestError({ message: `Failed to find identity with id ${id}` });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, identityOrgMembership.orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity);
|
||||
const { permission: identityRolePermission } = await permissionService.getOrgPermission(
|
||||
ActorType.IDENTITY,
|
||||
id,
|
||||
identityOrgMembership.orgId
|
||||
);
|
||||
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission);
|
||||
if (!hasRequiredPriviledges)
|
||||
throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" });
|
||||
|
||||
const deletedIdentity = await identityDAL.deleteById(id);
|
||||
return { ...deletedIdentity, orgId: identityOrgMembership.orgId };
|
||||
};
|
||||
|
||||
const listOrgIdentities = async ({ orgId, actor, actorId, actorOrgScope }: TOrgPermission) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity);
|
||||
|
||||
const identityMemberhips = await identityOrgMembershipDAL.findByOrgId(orgId);
|
||||
return identityMemberhips;
|
||||
};
|
||||
|
||||
return {
|
||||
createIdentity,
|
||||
updateIdentity,
|
||||
deleteIdentity,
|
||||
listOrgIdentities
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import {
|
||||
TableName,
|
||||
TOrganizations,
|
||||
TOrganizationsInsert,
|
||||
TOrgMemberships,
|
||||
TOrgMembershipsInsert,
|
||||
TOrgMembershipsUpdate,
|
||||
TUserEncryptionKeys
|
||||
} from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { buildFindFilter, selectAllTableCols, TFindFilter, TFindOpt, withTransaction, ormify } from "@app/lib/knex";
|
||||
|
||||
export type TOrgDALFactory = ReturnType<typeof orgDALFactory>;
|
||||
|
||||
export const orgDALFactory = (db: TDbClient) => {
|
||||
const orgOrm = ormify(db, TableName.Organization);
|
||||
|
||||
const findOrgById = async (orgId: string) => {
|
||||
try {
|
||||
const org = await db(TableName.Organization).where({ id: orgId }).first();
|
||||
return org;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find org by id" });
|
||||
}
|
||||
};
|
||||
|
||||
// special query
|
||||
const findAllOrgsByUserId = async (userId: string): Promise<TOrganizations[]> => {
|
||||
try {
|
||||
const org = await db(TableName.OrgMembership)
|
||||
.where({ userId })
|
||||
.join(TableName.Organization, `${TableName.OrgMembership}.orgId`, `${TableName.Organization}.id`)
|
||||
.select(selectAllTableCols(TableName.Organization));
|
||||
return org;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find all org by user id" });
|
||||
}
|
||||
};
|
||||
|
||||
const findOrgByProjectId = async (projectId: string): Promise<TOrganizations> => {
|
||||
try {
|
||||
const [org] = await db(TableName.Project)
|
||||
.where({ [`${TableName.Project}.id` as "id"]: projectId })
|
||||
.join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`)
|
||||
.select(selectAllTableCols(TableName.Organization));
|
||||
|
||||
return org;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find org by project id" });
|
||||
}
|
||||
};
|
||||
|
||||
// special query
|
||||
const findAllOrgMembers = async (orgId: 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)
|
||||
);
|
||||
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 create = async (dto: TOrganizationsInsert, tx?: Knex) => {
|
||||
try {
|
||||
const [organization] = await (tx || db)(TableName.Organization).insert(dto).returning("*");
|
||||
return organization;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Create organization" });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteById = async (orgId: string, tx?: Knex) => {
|
||||
try {
|
||||
const [org] = await (tx || db)(TableName.Organization).where({ id: orgId }).delete().returning("*");
|
||||
return org;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Update organization" });
|
||||
}
|
||||
};
|
||||
|
||||
const updateById = async (orgId: string, data: Partial<TOrganizations>) => {
|
||||
try {
|
||||
const [org] = await db(TableName.Organization)
|
||||
.where({ id: orgId })
|
||||
.update({ ...data })
|
||||
.returning("*");
|
||||
return org;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Update organization" });
|
||||
}
|
||||
};
|
||||
|
||||
// MEMBERSHIP OPERATIONS
|
||||
// --------------------
|
||||
// const orgMembershipOrm = ormify(db, TableName.OrgMembership);
|
||||
|
||||
const createMembership = async (data: TOrgMembershipsInsert, tx?: Knex) => {
|
||||
try {
|
||||
const [membership] = await (tx || db)(TableName.OrgMembership).insert(data).returning("*");
|
||||
return membership;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Create org membership" });
|
||||
}
|
||||
};
|
||||
|
||||
const updateMembershipById = async (id: string, data: TOrgMembershipsUpdate, tx?: Knex) => {
|
||||
try {
|
||||
const [membership] = await (tx || db)(TableName.OrgMembership).where({ id }).update(data).returning("*");
|
||||
return membership;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Update org membership" });
|
||||
}
|
||||
};
|
||||
|
||||
const updateMembership = async (filter: Partial<TOrgMemberships>, data: TOrgMembershipsUpdate, tx?: Knex) => {
|
||||
try {
|
||||
const membership = await (tx || db)(TableName.OrgMembership).where(filter).update(data).returning("*");
|
||||
return membership;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Update org memberships" });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMembershipById = async (id: string, orgId: string, tx?: Knex) => {
|
||||
try {
|
||||
const [membership] = await (tx || db)(TableName.OrgMembership).where({ id, orgId }).delete().returning("*");
|
||||
return membership;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Delete org membership" });
|
||||
}
|
||||
};
|
||||
|
||||
const findMembership = async (
|
||||
filter: TFindFilter<TOrgMemberships>,
|
||||
{ offset, limit, sort, tx }: TFindOpt<TOrgMemberships> = {}
|
||||
) => {
|
||||
try {
|
||||
const query = (tx || db)(TableName.OrgMembership)
|
||||
// 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));
|
||||
if (limit) void query.limit(limit);
|
||||
if (offset) void query.offset(offset);
|
||||
if (sort) {
|
||||
void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls })));
|
||||
}
|
||||
const res = await query;
|
||||
return res;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find one" });
|
||||
}
|
||||
};
|
||||
|
||||
return withTransaction(db, {
|
||||
...orgOrm,
|
||||
findOrgByProjectId,
|
||||
findAllOrgMembers,
|
||||
findOrgById,
|
||||
findAllOrgsByUserId,
|
||||
create,
|
||||
updateById,
|
||||
deleteById,
|
||||
findMembership,
|
||||
createMembership,
|
||||
updateMembershipById,
|
||||
deleteMembershipById,
|
||||
updateMembership
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import { packRules } from "@casl/ability/extra";
|
||||
|
||||
import { TOrgRolesInsert, TOrgRolesUpdate } from "@app/db/schemas";
|
||||
import {
|
||||
orgAdminPermissions,
|
||||
orgMemberPermissions,
|
||||
orgNoAccessPermissions,
|
||||
OrgPermissionActions,
|
||||
OrgPermissionSubjects
|
||||
} from "@app/ee/services/permission/org-permission";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
|
||||
import { TOrgRoleDALFactory } from "./org-role-dal";
|
||||
|
||||
type TOrgRoleServiceFactoryDep = {
|
||||
orgRoleDAL: TOrgRoleDALFactory;
|
||||
permissionService: TPermissionServiceFactory;
|
||||
};
|
||||
|
||||
export type TOrgRoleServiceFactory = ReturnType<typeof orgRoleServiceFactory>;
|
||||
|
||||
export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRoleServiceFactoryDep) => {
|
||||
const createRole = async (userId: string, orgId: string, data: Omit<TOrgRolesInsert, "orgId">, actorOrgScope?: string) => {
|
||||
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Role);
|
||||
const existingRole = await orgRoleDAL.findOne({ slug: data.slug, orgId });
|
||||
if (existingRole) throw new BadRequestError({ name: "Create Role", message: "Duplicate role" });
|
||||
const role = await orgRoleDAL.create({
|
||||
...data,
|
||||
orgId,
|
||||
permissions: JSON.stringify(data.permissions)
|
||||
});
|
||||
return role;
|
||||
};
|
||||
|
||||
const updateRole = async (userId: string, orgId: string, roleId: string, data: Omit<TOrgRolesUpdate, "orgId">, actorOrgScope?: string) => {
|
||||
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Role);
|
||||
if (data?.slug) {
|
||||
const existingRole = await orgRoleDAL.findOne({ slug: data.slug, orgId });
|
||||
if (existingRole && existingRole.id !== roleId)
|
||||
throw new BadRequestError({ name: "Update Role", message: "Duplicate role" });
|
||||
}
|
||||
const [updatedRole] = await orgRoleDAL.update(
|
||||
{ id: roleId, orgId },
|
||||
{ ...data, permissions: data.permissions ? JSON.stringify(data.permissions) : undefined }
|
||||
);
|
||||
if (!updateRole) throw new BadRequestError({ message: "Role not found", name: "Update role" });
|
||||
return updatedRole;
|
||||
};
|
||||
|
||||
const deleteRole = async (userId: string, orgId: string, roleId: string, actorOrgScope?: string) => {
|
||||
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Role);
|
||||
const [deletedRole] = await orgRoleDAL.delete({ id: roleId, orgId });
|
||||
if (!deleteRole) throw new BadRequestError({ message: "Role not found", name: "Update role" });
|
||||
|
||||
return deletedRole;
|
||||
};
|
||||
|
||||
const listRoles = async (userId: string, orgId: string, actorOrgScope?: string) => {
|
||||
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Role);
|
||||
const customRoles = await orgRoleDAL.find({ orgId });
|
||||
const roles = [
|
||||
{
|
||||
id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid
|
||||
orgId,
|
||||
name: "Admin",
|
||||
slug: "admin",
|
||||
description: "Complete administration access over the organization",
|
||||
permissions: packRules(orgAdminPermissions.rules),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response
|
||||
orgId,
|
||||
name: "Member",
|
||||
slug: "member",
|
||||
description: "Non-administrative role in an organization",
|
||||
permissions: packRules(orgMemberPermissions.rules),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
{
|
||||
id: "b10d49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response
|
||||
orgId,
|
||||
name: "No Access",
|
||||
slug: "no-access",
|
||||
description: "No access to any resources in the organization",
|
||||
permissions: packRules(orgNoAccessPermissions.rules),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
},
|
||||
...(customRoles || []).map(({ permissions, ...data }) => ({
|
||||
...data,
|
||||
permissions
|
||||
}))
|
||||
];
|
||||
|
||||
return roles;
|
||||
};
|
||||
|
||||
const getUserPermission = async (userId: string, orgId: string, actorOrgScope?: string) => {
|
||||
const { permission, membership } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgScope);
|
||||
return { permissions: packRules(permission.rules), membership };
|
||||
};
|
||||
|
||||
return { createRole, updateRole, deleteRole, listRoles, getUserPermission };
|
||||
};
|
||||
@@ -0,0 +1,457 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
import { OrgMembershipRole, OrgMembershipStatus } from "@app/db/schemas";
|
||||
import { TProjects } from "@app/db/schemas/projects";
|
||||
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 { TSamlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { generateAsymmetricKeyPair } from "@app/lib/crypto";
|
||||
import { generateSymmetricKey, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { isDisposableEmail } from "@app/lib/validator";
|
||||
|
||||
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 { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
|
||||
import { TUserDALFactory } from "../user/user-dal";
|
||||
import { TIncidentContactsDALFactory } from "./incident-contacts-dal";
|
||||
import { TOrgBotDALFactory } from "./org-bot-dal";
|
||||
import { TOrgDALFactory } from "./org-dal";
|
||||
import { TOrgRoleDALFactory } from "./org-role-dal";
|
||||
import {
|
||||
TDeleteOrgMembershipDTO,
|
||||
TFindAllWorkspacesDTO,
|
||||
TInviteUserToOrgDTO,
|
||||
TUpdateOrgMembershipDTO,
|
||||
TVerifyUserToOrgDTO
|
||||
} from "./org-types";
|
||||
|
||||
type TOrgServiceFactoryDep = {
|
||||
orgDAL: TOrgDALFactory;
|
||||
orgBotDAL: TOrgBotDALFactory;
|
||||
orgRoleDAL: TOrgRoleDALFactory;
|
||||
userDAL: TUserDALFactory;
|
||||
projectDAL: TProjectDALFactory;
|
||||
incidentContactDAL: TIncidentContactsDALFactory;
|
||||
samlConfigDAL: Pick<TSamlConfigDALFactory, "findOne">;
|
||||
smtpService: TSmtpService;
|
||||
tokenService: TAuthTokenServiceFactory;
|
||||
permissionService: TPermissionServiceFactory;
|
||||
licenseService: Pick<
|
||||
TLicenseServiceFactory,
|
||||
"getPlan" | "updateSubscriptionOrgMemberCount" | "generateOrgCustomerId" | "removeOrgCustomer"
|
||||
>;
|
||||
};
|
||||
|
||||
export type TOrgServiceFactory = ReturnType<typeof orgServiceFactory>;
|
||||
|
||||
export const orgServiceFactory = ({
|
||||
orgDAL,
|
||||
userDAL,
|
||||
orgRoleDAL,
|
||||
incidentContactDAL,
|
||||
permissionService,
|
||||
smtpService,
|
||||
projectDAL,
|
||||
tokenService,
|
||||
orgBotDAL,
|
||||
licenseService,
|
||||
samlConfigDAL
|
||||
}: TOrgServiceFactoryDep) => {
|
||||
/*
|
||||
* Get organization details by the organization id
|
||||
* */
|
||||
const findOrganizationById = async (userId: string, orgId: string, actorOrgScope?: string) => {
|
||||
await permissionService.getUserOrgPermission(userId, orgId, actorOrgScope);
|
||||
const org = await orgDAL.findOrgById(orgId);
|
||||
if (!org) throw new BadRequestError({ name: "Org not found", message: "Organization not found" });
|
||||
return org;
|
||||
};
|
||||
/*
|
||||
* Get all organization a user part of
|
||||
* */
|
||||
const findAllOrganizationOfUser = async (userId: string) => {
|
||||
const orgs = await orgDAL.findAllOrgsByUserId(userId);
|
||||
return orgs;
|
||||
};
|
||||
/*
|
||||
* Get all workspace members
|
||||
* */
|
||||
const findAllOrgMembers = async (userId: string, orgId: string, actorOrgScope?: string) => {
|
||||
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member);
|
||||
|
||||
const members = await orgDAL.findAllOrgMembers(orgId);
|
||||
return members;
|
||||
};
|
||||
|
||||
const findAllWorkspaces = async ({ actor, actorId, actorOrgScope, orgId }: TFindAllWorkspacesDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Workspace);
|
||||
|
||||
const organizationWorkspaceIds = new Set((await projectDAL.find({ orgId })).map((workspace) => workspace.id));
|
||||
|
||||
let workspaces: (TProjects & { organization: string } & {
|
||||
environments: {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
}[];
|
||||
})[];
|
||||
|
||||
if (actor === ActorType.USER) {
|
||||
workspaces = await projectDAL.findAllProjects(actorId);
|
||||
} else if (actor === ActorType.IDENTITY) {
|
||||
workspaces = await projectDAL.findAllProjectsByIdentity(actorId);
|
||||
} else {
|
||||
throw new BadRequestError({ message: "Invalid actor type" });
|
||||
}
|
||||
|
||||
return workspaces.filter((workspace) => organizationWorkspaceIds.has(workspace.id));
|
||||
};
|
||||
|
||||
/*
|
||||
* Update organization settings
|
||||
* */
|
||||
const updateOrgName = async (userId: string, orgId: string, name: string, actorOrgScope?: string) => {
|
||||
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Settings);
|
||||
const org = await orgDAL.updateById(orgId, { name });
|
||||
if (!org) throw new BadRequestError({ name: "Org not found", message: "Organization not found" });
|
||||
return org;
|
||||
};
|
||||
/*
|
||||
* Create organization
|
||||
* */
|
||||
const createOrganization = async (userId: string, userEmail: string, orgName: string) => {
|
||||
const { privateKey, publicKey } = generateAsymmetricKeyPair();
|
||||
const key = generateSymmetricKey();
|
||||
const {
|
||||
ciphertext: encryptedPrivateKey,
|
||||
iv: privateKeyIV,
|
||||
tag: privateKeyTag,
|
||||
encoding: privateKeyKeyEncoding,
|
||||
algorithm: privateKeyAlgorithm
|
||||
} = infisicalSymmetricEncypt(privateKey);
|
||||
const {
|
||||
ciphertext: encryptedSymmetricKey,
|
||||
iv: symmetricKeyIV,
|
||||
tag: symmetricKeyTag,
|
||||
encoding: symmetricKeyKeyEncoding,
|
||||
algorithm: symmetricKeyAlgorithm
|
||||
} = infisicalSymmetricEncypt(key);
|
||||
|
||||
const customerId = await licenseService.generateOrgCustomerId(orgName, userEmail);
|
||||
const organization = await orgDAL.transaction(async (tx) => {
|
||||
// akhilmhdh: for now this is auto created. in future we can input from user and for previous users just modifiy
|
||||
const org = await orgDAL.create(
|
||||
{ name: orgName, customerId, slug: slugify(`${orgName}-${alphaNumericNanoId(4)}`) },
|
||||
tx
|
||||
);
|
||||
await orgDAL.createMembership(
|
||||
{
|
||||
userId,
|
||||
orgId: org.id,
|
||||
role: OrgMembershipRole.Admin,
|
||||
status: OrgMembershipStatus.Accepted
|
||||
},
|
||||
tx
|
||||
);
|
||||
await orgBotDAL.create(
|
||||
{
|
||||
name: org.name,
|
||||
publicKey,
|
||||
privateKeyIV,
|
||||
encryptedPrivateKey,
|
||||
symmetricKeyIV,
|
||||
symmetricKeyTag,
|
||||
encryptedSymmetricKey,
|
||||
symmetricKeyAlgorithm,
|
||||
orgId: org.id,
|
||||
privateKeyTag,
|
||||
privateKeyAlgorithm,
|
||||
privateKeyKeyEncoding,
|
||||
symmetricKeyKeyEncoding
|
||||
},
|
||||
tx
|
||||
);
|
||||
return org;
|
||||
});
|
||||
|
||||
return organization;
|
||||
};
|
||||
|
||||
/*
|
||||
* Delete organization by id
|
||||
* */
|
||||
const deleteOrganizationById = async (userId: string, orgId: string, actorOrgScope?: string) => {
|
||||
const { membership } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgScope);
|
||||
if ((membership.role as OrgMembershipRole) !== OrgMembershipRole.Admin)
|
||||
throw new UnauthorizedError({ name: "Delete org by id", message: "Not an admin" });
|
||||
|
||||
const organization = await orgDAL.deleteById(orgId);
|
||||
if (organization.customerId) {
|
||||
await licenseService.removeOrgCustomer(organization.customerId);
|
||||
}
|
||||
return organization;
|
||||
};
|
||||
/*
|
||||
* Org membership management
|
||||
* Not another service because it has close ties with how an org works doesn't make sense to seperate them
|
||||
* */
|
||||
const updateOrgMembership = async ({ role, orgId, userId, membershipId, actorOrgScope }: TUpdateOrgMembershipDTO) => {
|
||||
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Member);
|
||||
|
||||
const isCustomRole = !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole);
|
||||
if (isCustomRole) {
|
||||
const customRole = await orgRoleDAL.findOne({ slug: role, orgId });
|
||||
if (!customRole) throw new BadRequestError({ name: "Update membership", message: "Role not found" });
|
||||
|
||||
const plan = await licenseService.getPlan(orgId);
|
||||
if (!plan?.rbac)
|
||||
throw new BadRequestError({
|
||||
message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member."
|
||||
});
|
||||
|
||||
const [membership] = await orgDAL.updateMembership(
|
||||
{ id: membershipId, orgId },
|
||||
{
|
||||
role: OrgMembershipRole.Custom,
|
||||
roleId: customRole.id
|
||||
}
|
||||
);
|
||||
return membership;
|
||||
}
|
||||
|
||||
const [membership] = await orgDAL.updateMembership({ id: membershipId, orgId }, { role, roleId: null });
|
||||
return membership;
|
||||
};
|
||||
/*
|
||||
* Invite user to organization
|
||||
*/
|
||||
const inviteUserToOrganization = async ({ orgId, userId, inviteeEmail, actorOrgScope }: TInviteUserToOrgDTO) => {
|
||||
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member);
|
||||
|
||||
const samlCfg = await samlConfigDAL.findOne({ orgId });
|
||||
if (samlCfg && samlCfg.isActive) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to invite member due to SAML SSO configured for organization"
|
||||
});
|
||||
}
|
||||
const plan = await licenseService.getPlan(orgId);
|
||||
if (plan.memberLimit !== null && plan.membersUsed >= plan.memberLimit) {
|
||||
// case: limit imposed on number of members allowed
|
||||
// case: number of members used exceeds the number of members allowed
|
||||
throw new BadRequestError({
|
||||
message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members."
|
||||
});
|
||||
}
|
||||
const invitee = await orgDAL.transaction(async (tx) => {
|
||||
const inviteeUser = await userDAL.findUserByEmail(inviteeEmail, tx);
|
||||
if (inviteeUser) {
|
||||
// if user already exist means its already part of infisical
|
||||
// Thus the signup flow is not needed anymore
|
||||
const [inviteeMembership] = await orgDAL.findMembership({ orgId, userId: inviteeUser.id }, { tx });
|
||||
if (inviteeMembership && inviteeMembership.status === OrgMembershipStatus.Accepted) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to invite an existing member of org",
|
||||
name: "Invite user to org"
|
||||
});
|
||||
}
|
||||
|
||||
if (!inviteeMembership) {
|
||||
await orgDAL.createMembership(
|
||||
{
|
||||
userId: inviteeUser.id,
|
||||
inviteEmail: inviteeEmail,
|
||||
orgId,
|
||||
role: OrgMembershipRole.Member,
|
||||
status: OrgMembershipStatus.Invited
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
return inviteeUser;
|
||||
}
|
||||
const isEmailInvalid = await isDisposableEmail(inviteeEmail);
|
||||
if (isEmailInvalid) {
|
||||
throw new BadRequestError({
|
||||
message: "Provided a disposable email",
|
||||
name: "Org invite"
|
||||
});
|
||||
}
|
||||
// not invited before
|
||||
const user = await userDAL.create(
|
||||
{
|
||||
email: inviteeEmail,
|
||||
isAccepted: false,
|
||||
authMethods: [AuthMethod.EMAIL]
|
||||
},
|
||||
tx
|
||||
);
|
||||
await orgDAL.createMembership(
|
||||
{
|
||||
inviteEmail: inviteeEmail,
|
||||
orgId,
|
||||
userId: user.id,
|
||||
role: OrgMembershipRole.Member,
|
||||
status: OrgMembershipStatus.Invited
|
||||
},
|
||||
tx
|
||||
);
|
||||
return user;
|
||||
});
|
||||
|
||||
const token = await tokenService.createTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_ORG_INVITATION,
|
||||
userId: invitee.id,
|
||||
orgId
|
||||
});
|
||||
|
||||
const org = await orgDAL.findOrgById(orgId);
|
||||
const user = await userDAL.findById(userId);
|
||||
const appCfg = getConfig();
|
||||
await smtpService.sendMail({
|
||||
template: SmtpTemplates.OrgInvite,
|
||||
subjectLine: "Infisical organization invitation",
|
||||
recipients: [inviteeEmail],
|
||||
substitutions: {
|
||||
inviterFirstName: user.firstName,
|
||||
inviterEmail: user.email,
|
||||
organizationName: org?.name,
|
||||
email: inviteeEmail,
|
||||
organizationId: org?.id.toString(),
|
||||
token,
|
||||
callback_url: `${appCfg.SITE_URL}/signupinvite`
|
||||
}
|
||||
});
|
||||
|
||||
await licenseService.updateSubscriptionOrgMemberCount(orgId);
|
||||
if (!appCfg.isSmtpConfigured) {
|
||||
return `${appCfg.SITE_URL}/signupinvite?token=${token}&to=${inviteeEmail}&organization_id=${org?.id}`;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Organization invitation step 2: Verify that code [code] was sent to email [email] as part of
|
||||
* magic link and issue a temporary signup token for user to complete setting up their account
|
||||
*/
|
||||
const verifyUserToOrg = async ({ orgId, email, code }: TVerifyUserToOrgDTO) => {
|
||||
const user = await userDAL.findUserByEmail(email);
|
||||
if (!user) {
|
||||
throw new BadRequestError({ message: "Invalid request", name: "Verify user to org" });
|
||||
}
|
||||
const [orgMembership] = await orgDAL.findMembership({
|
||||
userId: user.id,
|
||||
status: OrgMembershipStatus.Invited,
|
||||
orgId
|
||||
});
|
||||
if (!orgMembership)
|
||||
throw new BadRequestError({
|
||||
message: "Failed to find invitation",
|
||||
name: "Verify user to org"
|
||||
});
|
||||
|
||||
await tokenService.validateTokenForUser({
|
||||
type: TokenType.TOKEN_EMAIL_ORG_INVITATION,
|
||||
userId: user.id,
|
||||
orgId: orgMembership.orgId,
|
||||
code
|
||||
});
|
||||
|
||||
if (user.isAccepted) {
|
||||
// this means user has already completed signup process
|
||||
// isAccepted is set true when keys are exchanged
|
||||
await orgDAL.updateMembershipById(orgMembership.id, {
|
||||
orgId,
|
||||
status: OrgMembershipStatus.Accepted
|
||||
});
|
||||
await licenseService.updateSubscriptionOrgMemberCount(orgId);
|
||||
return { user };
|
||||
}
|
||||
|
||||
const appCfg = getConfig();
|
||||
const token = jwt.sign(
|
||||
{
|
||||
authTokenType: AuthTokenType.SIGNUP_TOKEN,
|
||||
userId: user.id
|
||||
},
|
||||
appCfg.AUTH_SECRET,
|
||||
{
|
||||
expiresIn: appCfg.JWT_SIGNUP_LIFETIME
|
||||
}
|
||||
);
|
||||
|
||||
return { token, user };
|
||||
};
|
||||
|
||||
const deleteOrgMembership = async ({ orgId, userId, membershipId, actorOrgScope }: TDeleteOrgMembershipDTO) => {
|
||||
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Member);
|
||||
|
||||
const membership = await orgDAL.deleteMembershipById(membershipId, orgId);
|
||||
|
||||
await licenseService.updateSubscriptionOrgMemberCount(orgId);
|
||||
return membership;
|
||||
};
|
||||
|
||||
/*
|
||||
* CRUD operations of incident contacts
|
||||
* */
|
||||
const findIncidentContacts = async (userId: string, orgId: string, actorOrgScope?: string) => {
|
||||
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.IncidentAccount);
|
||||
const incidentContacts = await incidentContactDAL.findByOrgId(orgId);
|
||||
return incidentContacts;
|
||||
};
|
||||
|
||||
const createIncidentContact = async (userId: string, orgId: string, email: string, actorOrgScope?: string) => {
|
||||
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.IncidentAccount);
|
||||
const doesIncidentContactExist = await incidentContactDAL.findOne(orgId, { email });
|
||||
if (doesIncidentContactExist) {
|
||||
throw new BadRequestError({
|
||||
message: "Incident contact already exist",
|
||||
name: "Incident contact exist"
|
||||
});
|
||||
}
|
||||
|
||||
const incidentContact = await incidentContactDAL.create(orgId, email);
|
||||
return incidentContact;
|
||||
};
|
||||
|
||||
const deleteIncidentContact = async (userId: string, orgId: string, id: string, actorOrgScope?: string) => {
|
||||
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.IncidentAccount);
|
||||
|
||||
const incidentContact = await incidentContactDAL.deleteById(id, orgId);
|
||||
return incidentContact;
|
||||
};
|
||||
|
||||
return {
|
||||
findOrganizationById,
|
||||
findAllOrgMembers,
|
||||
findAllOrganizationOfUser,
|
||||
inviteUserToOrganization,
|
||||
verifyUserToOrg,
|
||||
updateOrgName,
|
||||
createOrganization,
|
||||
deleteOrganizationById,
|
||||
deleteOrgMembership,
|
||||
findAllWorkspaces,
|
||||
updateOrgMembership,
|
||||
// incident contacts
|
||||
findIncidentContacts,
|
||||
createIncidentContact,
|
||||
deleteIncidentContact
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ActorType } from "../auth/auth-type";
|
||||
|
||||
export type TUpdateOrgMembershipDTO = {
|
||||
userId: string;
|
||||
orgId: string;
|
||||
membershipId: string;
|
||||
role: string;
|
||||
actorOrgScope?: string;
|
||||
};
|
||||
|
||||
export type TDeleteOrgMembershipDTO = {
|
||||
userId: string;
|
||||
orgId: string;
|
||||
membershipId: string;
|
||||
actorOrgScope?: string;
|
||||
};
|
||||
|
||||
export type TInviteUserToOrgDTO = {
|
||||
userId: string;
|
||||
orgId: string;
|
||||
actorOrgScope?: string;
|
||||
inviteeEmail: string;
|
||||
};
|
||||
|
||||
export type TVerifyUserToOrgDTO = {
|
||||
email: string;
|
||||
orgId: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export type TFindAllWorkspacesDTO = {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorOrgScope?: string;
|
||||
orgId: string;
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import slugify from "@sindresorhus/slugify";
|
||||
|
||||
import { ProjectMembershipRole } 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 { getConfig } from "@app/lib/config/env";
|
||||
import { createSecretBlindIndex } from "@app/lib/crypto";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-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 { TProjectDALFactory } from "./project-dal";
|
||||
import { TCreateProjectDTO, TDeleteProjectDTO, TGetProjectDTO } from "./project-types";
|
||||
|
||||
export const DEFAULT_PROJECT_ENVS = [
|
||||
{ name: "Development", slug: "dev" },
|
||||
{ name: "Staging", slug: "staging" },
|
||||
{ name: "Production", slug: "prod" }
|
||||
];
|
||||
|
||||
type TProjectServiceFactoryDep = {
|
||||
projectDAL: TProjectDALFactory;
|
||||
folderDAL: Pick<TSecretFolderDALFactory, "insertMany">;
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "insertMany">;
|
||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create">;
|
||||
secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "create">;
|
||||
permissionService: TPermissionServiceFactory;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
};
|
||||
|
||||
export type TProjectServiceFactory = ReturnType<typeof projectServiceFactory>;
|
||||
|
||||
export const projectServiceFactory = ({
|
||||
projectDAL,
|
||||
permissionService,
|
||||
folderDAL,
|
||||
secretBlindIndexDAL,
|
||||
projectMembershipDAL,
|
||||
projectEnvDAL,
|
||||
licenseService
|
||||
}: TProjectServiceFactoryDep) => {
|
||||
/*
|
||||
* Create workspace. Make user the admin
|
||||
* */
|
||||
const createProject = async ({ orgId, actor, actorId, actorOrgScope, workspaceName }: TCreateProjectDTO) => {
|
||||
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorOrgScope);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace);
|
||||
|
||||
const appCfg = getConfig();
|
||||
const blindIndex = createSecretBlindIndex(appCfg.ROOT_ENCRYPTION_KEY, appCfg.ENCRYPTION_KEY);
|
||||
|
||||
const plan = await licenseService.getPlan(orgId);
|
||||
if (plan.workspaceLimit !== null && plan.workspacesUsed >= plan.workspaceLimit) {
|
||||
// case: limit imposed on number of workspaces allowed
|
||||
// case: number of workspaces used exceeds the number of workspaces allowed
|
||||
throw new BadRequestError({
|
||||
message: "Failed to create workspace due to plan limit reached. Upgrade plan to add more workspaces."
|
||||
});
|
||||
}
|
||||
|
||||
const newProject = projectDAL.transaction(async (tx) => {
|
||||
const project = await projectDAL.create(
|
||||
{ name: workspaceName, orgId, slug: slugify(`${workspaceName}-${alphaNumericNanoId(4)}`) },
|
||||
tx
|
||||
);
|
||||
// set user as admin member for proeject
|
||||
await projectMembershipDAL.create(
|
||||
{
|
||||
userId: actorId,
|
||||
role: ProjectMembershipRole.Admin,
|
||||
projectId: project.id
|
||||
},
|
||||
tx
|
||||
);
|
||||
// generate the blind index for project
|
||||
await secretBlindIndexDAL.create(
|
||||
{
|
||||
projectId: project.id,
|
||||
keyEncoding: blindIndex.keyEncoding,
|
||||
saltIV: blindIndex.iv,
|
||||
saltTag: blindIndex.tag,
|
||||
algorithm: blindIndex.algorithm,
|
||||
encryptedSaltCipherText: blindIndex.ciphertext
|
||||
},
|
||||
tx
|
||||
);
|
||||
// set default environments and root folder for provided environments
|
||||
const envs = await projectEnvDAL.insertMany(
|
||||
DEFAULT_PROJECT_ENVS.map((el, i) => ({ ...el, projectId: project.id, position: i + 1 })),
|
||||
tx
|
||||
);
|
||||
await folderDAL.insertMany(
|
||||
envs.map(({ id }) => ({ name: ROOT_FOLDER_NAME, envId: id, version: 1 })),
|
||||
tx
|
||||
);
|
||||
// _id for backward compat
|
||||
return { ...project, environments: envs, _id: project.id };
|
||||
});
|
||||
|
||||
return newProject;
|
||||
};
|
||||
|
||||
const deleteProject = async ({ actor, actorId, projectId }: TDeleteProjectDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project);
|
||||
|
||||
const deletedProject = await projectDAL.deleteById(projectId);
|
||||
return deletedProject;
|
||||
};
|
||||
|
||||
const getProjects = async (actorId: string) => {
|
||||
const workspaces = await projectDAL.findAllProjects(actorId);
|
||||
return workspaces;
|
||||
};
|
||||
|
||||
const getAProject = async ({ actorId, projectId, actor }: TGetProjectDTO) => {
|
||||
await permissionService.getProjectPermission(actor, actorId, projectId);
|
||||
return projectDAL.findProjectById(projectId);
|
||||
};
|
||||
|
||||
const toggleAutoCapitalization = async ({
|
||||
projectId,
|
||||
actor,
|
||||
actorId,
|
||||
autoCapitalization
|
||||
}: TGetProjectDTO & { autoCapitalization: boolean }) => {
|
||||
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings);
|
||||
|
||||
const updatedProject = await projectDAL.updateById(projectId, { autoCapitalization });
|
||||
return updatedProject;
|
||||
};
|
||||
|
||||
const updateName = async ({ projectId, actor, actorId, name }: TGetProjectDTO & { name: string }) => {
|
||||
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings);
|
||||
|
||||
const updatedProject = await projectDAL.updateById(projectId, { name });
|
||||
return updatedProject;
|
||||
};
|
||||
|
||||
return {
|
||||
createProject,
|
||||
deleteProject,
|
||||
getProjects,
|
||||
getAProject,
|
||||
toggleAutoCapitalization,
|
||||
updateName
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ActorType } from "../auth/auth-type";
|
||||
|
||||
export type TCreateProjectDTO = {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorOrgScope?: string;
|
||||
orgId: string;
|
||||
workspaceName: string;
|
||||
};
|
||||
|
||||
export type TDeleteProjectDTO = {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type TGetProjectDTO = {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
projectId: string;
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import { TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
|
||||
import { TAuthLoginFactory } from "../auth/auth-login-service";
|
||||
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";
|
||||
|
||||
type TSuperAdminServiceFactoryDep = {
|
||||
serverCfgDAL: TSuperAdminDALFactory;
|
||||
userDAL: TUserDALFactory;
|
||||
authService: Pick<TAuthLoginFactory, "generateUserTokens">;
|
||||
orgService: Pick<TOrgServiceFactory, "createOrganization">;
|
||||
};
|
||||
|
||||
export type TSuperAdminServiceFactory = ReturnType<typeof superAdminServiceFactory>;
|
||||
|
||||
let serverCfg: Readonly<TSuperAdmin>;
|
||||
export const getServerCfg = () => {
|
||||
if (!serverCfg) throw new BadRequestError({ name: "Get server cfg", message: "Server cfg not initialized" });
|
||||
return serverCfg;
|
||||
};
|
||||
|
||||
export const superAdminServiceFactory = ({
|
||||
serverCfgDAL,
|
||||
userDAL,
|
||||
authService,
|
||||
orgService
|
||||
}: TSuperAdminServiceFactoryDep) => {
|
||||
const initServerCfg = async () => {
|
||||
serverCfg = await serverCfgDAL.findOne({});
|
||||
if (!serverCfg) {
|
||||
const newCfg = await serverCfgDAL.create({ initialized: false, allowSignUp: true });
|
||||
serverCfg = newCfg;
|
||||
return newCfg;
|
||||
}
|
||||
return serverCfg;
|
||||
};
|
||||
|
||||
const updateServerCfg = async (data: TSuperAdminUpdate) => {
|
||||
const cfg = await serverCfgDAL.updateById(serverCfg.id, data);
|
||||
serverCfg = cfg;
|
||||
Object.freeze(serverCfg);
|
||||
return cfg;
|
||||
};
|
||||
|
||||
const adminSignUp = async ({
|
||||
lastName,
|
||||
firstName,
|
||||
salt,
|
||||
email,
|
||||
verifier,
|
||||
publicKey,
|
||||
protectedKey,
|
||||
protectedKeyIV,
|
||||
protectedKeyTag,
|
||||
encryptedPrivateKey,
|
||||
encryptedPrivateKeyIV,
|
||||
encryptedPrivateKeyTag,
|
||||
ip,
|
||||
userAgent
|
||||
}: TAdminSignUpDTO) => {
|
||||
const existingUser = await userDAL.findOne({ email });
|
||||
if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exist" });
|
||||
|
||||
const userInfo = await userDAL.transaction(async (tx) => {
|
||||
const newUser = await userDAL.create(
|
||||
{
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
superAdmin: true,
|
||||
isAccepted: true,
|
||||
authMethods: [AuthMethod.EMAIL]
|
||||
},
|
||||
tx
|
||||
);
|
||||
const userEnc = await userDAL.createUserEncryption(
|
||||
{
|
||||
salt,
|
||||
encryptionVersion: 2,
|
||||
protectedKey,
|
||||
protectedKeyIV,
|
||||
protectedKeyTag,
|
||||
publicKey,
|
||||
encryptedPrivateKey,
|
||||
iv: encryptedPrivateKeyIV,
|
||||
tag: encryptedPrivateKeyTag,
|
||||
verifier,
|
||||
userId: newUser.id
|
||||
},
|
||||
tx
|
||||
);
|
||||
return { user: newUser, enc: userEnc };
|
||||
});
|
||||
await orgService.createOrganization(userInfo.user.id, userInfo.user.email, "Admin Org");
|
||||
|
||||
await updateServerCfg({ initialized: true });
|
||||
const token = await authService.generateUserTokens({
|
||||
user: userInfo.user,
|
||||
ip,
|
||||
userAgent,
|
||||
organizationId: undefined
|
||||
});
|
||||
// TODO(akhilmhdh-pg): telemetry service
|
||||
return { token, user: userInfo };
|
||||
};
|
||||
|
||||
return {
|
||||
initServerCfg,
|
||||
updateServerCfg,
|
||||
adminSignUp
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user