Compare commits
11 Commits
fix/import
...
role-conce
Author | SHA1 | Date | |
---|---|---|---|
d5c0abbc3b | |||
b359f4278e | |||
29d76c1deb | |||
6ba1012f5b | |||
b64d4e57c4 | |||
bd860e6c5a | |||
3731459e99 | |||
dc055c11ab | |||
22878a035b | |||
7127f6d1e1 | |||
ce26a06129 |
@ -1,23 +0,0 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { SecretSharingAccessType } from "@app/lib/types";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn(TableName.SecretSharing, "accessType");
|
||||
if (!hasColumn) {
|
||||
await knex.schema.table(TableName.SecretSharing, (table) => {
|
||||
table.string("accessType").notNullable().defaultTo(SecretSharingAccessType.Anyone);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn(TableName.SecretSharing, "accessType");
|
||||
if (hasColumn) {
|
||||
await knex.schema.table(TableName.SecretSharing, (table) => {
|
||||
table.dropColumn("accessType");
|
||||
});
|
||||
}
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn(TableName.SecretApprovalRequest, "bypassReason");
|
||||
if (!hasColumn) {
|
||||
await knex.schema.table(TableName.SecretApprovalRequest, (table) => {
|
||||
table.string("bypassReason").nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasColumn = await knex.schema.hasColumn(TableName.SecretApprovalRequest, "bypassReason");
|
||||
if (hasColumn) {
|
||||
await knex.schema.table(TableName.SecretApprovalRequest, (table) => {
|
||||
table.dropColumn("bypassReason");
|
||||
});
|
||||
}
|
||||
}
|
@ -15,7 +15,6 @@ export const SecretApprovalRequestsSchema = z.object({
|
||||
conflicts: z.unknown().nullable().optional(),
|
||||
slug: z.string(),
|
||||
folderId: z.string().uuid(),
|
||||
bypassReason: z.string().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
isReplicated: z.boolean().nullable().optional(),
|
||||
|
@ -5,8 +5,6 @@
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretSharingAccessType } from "@app/lib/types";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const SecretSharingSchema = z.object({
|
||||
@ -18,7 +16,6 @@ export const SecretSharingSchema = z.object({
|
||||
expiresAt: z.date(),
|
||||
userId: z.string().uuid().nullable().optional(),
|
||||
orgId: z.string().uuid().nullable().optional(),
|
||||
accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
expiresAfterViews: z.number().nullable().optional()
|
||||
|
@ -52,6 +52,36 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:organizationId/roles/:roleId",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
organizationId: z.string().trim(),
|
||||
roleId: z.string().trim()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
role: OrgRolesSchema
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const role = await server.services.orgRole.getRole(
|
||||
req.permission.id,
|
||||
req.params.organizationId,
|
||||
req.params.roleId,
|
||||
req.permission.authMethod,
|
||||
req.permission.orgId
|
||||
);
|
||||
return { role };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/:organizationId/roles/:roleId",
|
||||
@ -69,7 +99,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
|
||||
.trim()
|
||||
.optional()
|
||||
.refine(
|
||||
(val) => typeof val === "undefined" || Object.keys(OrgMembershipRole).includes(val),
|
||||
(val) => typeof val !== "undefined" && !Object.keys(OrgMembershipRole).includes(val),
|
||||
"Please choose a different slug, the slug you have entered is reserved."
|
||||
)
|
||||
.refine((val) => typeof val === "undefined" || slugify(val) === val, {
|
||||
|
@ -117,9 +117,6 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
|
||||
params: z.object({
|
||||
id: z.string()
|
||||
}),
|
||||
body: z.object({
|
||||
bypassReason: z.string().optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
approval: SecretApprovalRequestsSchema
|
||||
@ -133,8 +130,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
|
||||
actor: req.permission.type,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
approvalId: req.params.id,
|
||||
bypassReason: req.body.bypassReason
|
||||
approvalId: req.params.id
|
||||
});
|
||||
return { approval };
|
||||
}
|
||||
|
@ -94,7 +94,6 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
tx.ref("projectId").withSchema(TableName.Environment),
|
||||
tx.ref("slug").withSchema(TableName.Environment).as("environment"),
|
||||
tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"),
|
||||
tx.ref("envId").withSchema(TableName.SecretApprovalPolicy).as("policyEnvId"),
|
||||
tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"),
|
||||
tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals")
|
||||
);
|
||||
@ -131,8 +130,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
name: el.policyName,
|
||||
approvals: el.policyApprovals,
|
||||
secretPath: el.policySecretPath,
|
||||
enforcementLevel: el.policyEnforcementLevel,
|
||||
envId: el.policyEnvId
|
||||
enforcementLevel: el.policyEnforcementLevel
|
||||
}
|
||||
}),
|
||||
childrenMapper: [
|
||||
|
@ -7,7 +7,6 @@ import {
|
||||
SecretType,
|
||||
TSecretApprovalRequestsSecretsInsert
|
||||
} from "@app/db/schemas";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
|
||||
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { groupBy, pick, unique } from "@app/lib/fn";
|
||||
@ -16,7 +15,6 @@ import { EnforcementLevel } from "@app/lib/types";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
||||
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
|
||||
import { TSecretDALFactory } from "@app/services/secret/secret-dal";
|
||||
import {
|
||||
fnSecretBlindIndexCheck,
|
||||
@ -33,8 +31,6 @@ import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version
|
||||
import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal";
|
||||
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
|
||||
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
||||
import { TUserDALFactory } from "@app/services/user/user-dal";
|
||||
|
||||
import { TPermissionServiceFactory } from "../permission/permission-service";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission";
|
||||
@ -67,11 +63,8 @@ type TSecretApprovalRequestServiceFactoryDep = {
|
||||
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
|
||||
secretVersionDAL: Pick<TSecretVersionDALFactory, "findLatestVersionMany" | "insertMany">;
|
||||
secretVersionTagDAL: Pick<TSecretVersionTagDALFactory, "insertMany">;
|
||||
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus" | "findProjectById">;
|
||||
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus">;
|
||||
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "removeSecretReminder">;
|
||||
smtpService: Pick<TSmtpService, "sendMail">;
|
||||
userDAL: Pick<TUserDALFactory, "find" | "findOne">;
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
|
||||
};
|
||||
|
||||
export type TSecretApprovalRequestServiceFactory = ReturnType<typeof secretApprovalRequestServiceFactory>;
|
||||
@ -90,10 +83,7 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
snapshotService,
|
||||
secretVersionDAL,
|
||||
secretQueueService,
|
||||
projectBotService,
|
||||
smtpService,
|
||||
userDAL,
|
||||
projectEnvDAL
|
||||
projectBotService
|
||||
}: TSecretApprovalRequestServiceFactoryDep) => {
|
||||
const requestCount = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod }: TApprovalRequestCountDTO) => {
|
||||
if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" });
|
||||
@ -268,8 +258,7 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
bypassReason
|
||||
actorAuthMethod
|
||||
}: TMergeSecretApprovalRequestDTO) => {
|
||||
const secretApprovalRequest = await secretApprovalRequestDAL.findById(approvalId);
|
||||
if (!secretApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" });
|
||||
@ -481,8 +470,7 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
conflicts: JSON.stringify(conflicts),
|
||||
hasMerged: true,
|
||||
status: RequestState.Closed,
|
||||
statusChangedByUserId: actorId,
|
||||
bypassReason
|
||||
statusChangedByUserId: actorId
|
||||
},
|
||||
tx
|
||||
);
|
||||
@ -501,35 +489,6 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
actorId,
|
||||
actor
|
||||
});
|
||||
|
||||
if (isSoftEnforcement) {
|
||||
const cfg = getConfig();
|
||||
const project = await projectDAL.findProjectById(projectId);
|
||||
const env = await projectEnvDAL.findOne({ id: policy.envId });
|
||||
const requestedByUser = await userDAL.findOne({ id: actorId });
|
||||
const approverUsers = await userDAL.find({
|
||||
$in: {
|
||||
id: policy.approvers.map((approver: { userId: string }) => approver.userId)
|
||||
}
|
||||
});
|
||||
|
||||
await smtpService.sendMail({
|
||||
recipients: approverUsers.filter((approver) => approver.email).map((approver) => approver.email!),
|
||||
subjectLine: "Infisical Secret Change Policy Bypassed",
|
||||
|
||||
substitutions: {
|
||||
projectName: project.name,
|
||||
requesterFullName: `${requestedByUser.firstName} ${requestedByUser.lastName}`,
|
||||
requesterEmail: requestedByUser.email,
|
||||
bypassReason,
|
||||
secretPath: policy.secretPath,
|
||||
environment: env.name,
|
||||
approvalUrl: `${cfg.SITE_URL}/project/${project.id}/approval`
|
||||
},
|
||||
template: SmtpTemplates.AccessSecretRequestBypassed
|
||||
});
|
||||
}
|
||||
|
||||
return mergeStatus;
|
||||
};
|
||||
|
||||
|
@ -39,7 +39,6 @@ export type TGenerateSecretApprovalRequestDTO = {
|
||||
|
||||
export type TMergeSecretApprovalRequestDTO = {
|
||||
approvalId: string;
|
||||
bypassReason?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TStatusChangeDTO = {
|
||||
|
@ -47,8 +47,3 @@ export enum EnforcementLevel {
|
||||
Hard = "hard",
|
||||
Soft = "soft"
|
||||
}
|
||||
|
||||
export enum SecretSharingAccessType {
|
||||
Anyone = "anyone",
|
||||
Organization = "organization"
|
||||
}
|
||||
|
@ -737,8 +737,7 @@ export const registerRoutes = async (
|
||||
|
||||
const secretSharingService = secretSharingServiceFactory({
|
||||
permissionService,
|
||||
secretSharingDAL,
|
||||
orgDAL
|
||||
secretSharingDAL
|
||||
});
|
||||
|
||||
const secretApprovalRequestService = secretApprovalRequestServiceFactory({
|
||||
@ -755,10 +754,7 @@ export const registerRoutes = async (
|
||||
secretApprovalRequestDAL,
|
||||
snapshotService,
|
||||
secretVersionTagDAL,
|
||||
secretQueueService,
|
||||
smtpService,
|
||||
userDAL,
|
||||
projectEnvDAL
|
||||
secretQueueService
|
||||
});
|
||||
|
||||
const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretSharingSchema } from "@app/db/schemas";
|
||||
import { SecretSharingAccessType } from "@app/lib/types";
|
||||
import {
|
||||
publicEndpointLimit,
|
||||
publicSecretShareCreationLimit,
|
||||
@ -56,18 +55,14 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
||||
iv: true,
|
||||
tag: true,
|
||||
expiresAt: true,
|
||||
expiresAfterViews: true,
|
||||
accessType: true
|
||||
}).extend({
|
||||
orgName: z.string().optional()
|
||||
expiresAfterViews: true
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const sharedSecret = await req.server.services.secretSharing.getActiveSharedSecretByIdAndHashedHex(
|
||||
req.params.id,
|
||||
req.query.hashedHex,
|
||||
req.permission?.orgId
|
||||
req.query.hashedHex
|
||||
);
|
||||
if (!sharedSecret) return undefined;
|
||||
return {
|
||||
@ -75,9 +70,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
||||
iv: sharedSecret.iv,
|
||||
tag: sharedSecret.tag,
|
||||
expiresAt: sharedSecret.expiresAt,
|
||||
expiresAfterViews: sharedSecret.expiresAfterViews,
|
||||
accessType: sharedSecret.accessType,
|
||||
orgName: sharedSecret.orgName
|
||||
expiresAfterViews: sharedSecret.expiresAfterViews
|
||||
};
|
||||
}
|
||||
});
|
||||
@ -111,8 +104,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
||||
tag,
|
||||
hashedHex,
|
||||
expiresAt: new Date(expiresAt),
|
||||
expiresAfterViews,
|
||||
accessType: SecretSharingAccessType.Anyone
|
||||
expiresAfterViews
|
||||
});
|
||||
return { id: sharedSecret.id };
|
||||
}
|
||||
@ -131,8 +123,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
||||
tag: z.string(),
|
||||
hashedHex: z.string(),
|
||||
expiresAt: z.string(),
|
||||
expiresAfterViews: z.number(),
|
||||
accessType: z.nativeEnum(SecretSharingAccessType).default(SecretSharingAccessType.Organization)
|
||||
expiresAfterViews: z.number()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@ -154,8 +145,7 @@ export const registerSecretSharingRouter = async (server: FastifyZodProvider) =>
|
||||
tag,
|
||||
hashedHex,
|
||||
expiresAt: new Date(expiresAt),
|
||||
expiresAfterViews,
|
||||
accessType: req.body.accessType
|
||||
expiresAfterViews
|
||||
});
|
||||
return { id: sharedSecret.id };
|
||||
}
|
||||
|
@ -42,6 +42,61 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol
|
||||
return role;
|
||||
};
|
||||
|
||||
const getRole = async (
|
||||
userId: string,
|
||||
orgId: string,
|
||||
roleId: string,
|
||||
actorAuthMethod: ActorAuthMethod,
|
||||
actorOrgId: string | undefined
|
||||
) => {
|
||||
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Role);
|
||||
|
||||
switch (roleId) {
|
||||
case "b11b49a9-09a9-4443-916a-4246f9ff2c69": {
|
||||
return {
|
||||
id: roleId,
|
||||
orgId,
|
||||
name: "Admin",
|
||||
slug: "admin",
|
||||
description: "Complete administration access over the organization",
|
||||
permissions: packRules(orgAdminPermissions.rules),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
}
|
||||
case "b11b49a9-09a9-4443-916a-4246f9ff2c70": {
|
||||
return {
|
||||
id: roleId,
|
||||
orgId,
|
||||
name: "Member",
|
||||
slug: "member",
|
||||
description: "Non-administrative role in an organization",
|
||||
permissions: packRules(orgMemberPermissions.rules),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
}
|
||||
case "b10d49a9-09a9-4443-916a-4246f9ff2c72": {
|
||||
return {
|
||||
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()
|
||||
};
|
||||
}
|
||||
default: {
|
||||
const role = await orgRoleDAL.findOne({ id: roleId, orgId });
|
||||
if (!role) throw new BadRequestError({ message: "Role not found", name: "Get role" });
|
||||
return role;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateRole = async (
|
||||
userId: string,
|
||||
orgId: string,
|
||||
@ -144,5 +199,5 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol
|
||||
return { permissions: packRules(permission.rules), membership };
|
||||
};
|
||||
|
||||
return { createRole, updateRole, deleteRole, listRoles, getUserPermission };
|
||||
return { createRole, getRole, updateRole, deleteRole, listRoles, getUserPermission };
|
||||
};
|
||||
|
@ -90,7 +90,7 @@ export const fnSecretsFromImports = async ({
|
||||
const secretsFromdeeperImportGroupedByFolderId = groupBy(secretsFromDeeperImports, (i) => i.importFolderId);
|
||||
|
||||
const secrets = allowedImports.map(({ importPath, importEnv, id, folderId }, i) => {
|
||||
const sourceImportFolder = importedFolderGroupBySourceImport?.[`${importEnv.id}-${importPath}`]?.[0];
|
||||
const sourceImportFolder = importedFolderGroupBySourceImport[`${importEnv.id}-${importPath}`][0];
|
||||
const folderDeeperImportSecrets =
|
||||
secretsFromdeeperImportGroupedByFolderId?.[sourceImportFolder?.id || ""]?.[0]?.secrets || [];
|
||||
|
||||
|
@ -1,8 +1,6 @@
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { SecretSharingAccessType } from "@app/lib/types";
|
||||
|
||||
import { TOrgDALFactory } from "../org/org-dal";
|
||||
import { TSecretSharingDALFactory } from "./secret-sharing-dal";
|
||||
import {
|
||||
TCreatePublicSharedSecretDTO,
|
||||
@ -14,15 +12,13 @@ import {
|
||||
type TSecretSharingServiceFactoryDep = {
|
||||
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
|
||||
secretSharingDAL: TSecretSharingDALFactory;
|
||||
orgDAL: TOrgDALFactory;
|
||||
};
|
||||
|
||||
export type TSecretSharingServiceFactory = ReturnType<typeof secretSharingServiceFactory>;
|
||||
|
||||
export const secretSharingServiceFactory = ({
|
||||
permissionService,
|
||||
secretSharingDAL,
|
||||
orgDAL
|
||||
secretSharingDAL
|
||||
}: TSecretSharingServiceFactoryDep) => {
|
||||
const createSharedSecret = async (createSharedSecretInput: TCreateSharedSecretDTO) => {
|
||||
const {
|
||||
@ -34,7 +30,6 @@ export const secretSharingServiceFactory = ({
|
||||
encryptedValue,
|
||||
iv,
|
||||
tag,
|
||||
accessType,
|
||||
hashedHex,
|
||||
expiresAt,
|
||||
expiresAfterViews
|
||||
@ -67,14 +62,13 @@ export const secretSharingServiceFactory = ({
|
||||
expiresAt,
|
||||
expiresAfterViews,
|
||||
userId: actorId,
|
||||
orgId,
|
||||
accessType
|
||||
orgId
|
||||
});
|
||||
return { id: newSharedSecret.id };
|
||||
};
|
||||
|
||||
const createPublicSharedSecret = async (createSharedSecretInput: TCreatePublicSharedSecretDTO) => {
|
||||
const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews, accessType } = createSharedSecretInput;
|
||||
const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = createSharedSecretInput;
|
||||
if (new Date(expiresAt) < new Date()) {
|
||||
throw new BadRequestError({ message: "Expiration date cannot be in the past" });
|
||||
}
|
||||
@ -98,8 +92,7 @@ export const secretSharingServiceFactory = ({
|
||||
tag,
|
||||
hashedHex,
|
||||
expiresAt,
|
||||
expiresAfterViews,
|
||||
accessType
|
||||
expiresAfterViews
|
||||
});
|
||||
return { id: newSharedSecret.id };
|
||||
};
|
||||
@ -112,21 +105,9 @@ export const secretSharingServiceFactory = ({
|
||||
return userSharedSecrets;
|
||||
};
|
||||
|
||||
const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string, orgId?: string) => {
|
||||
const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string) => {
|
||||
const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex });
|
||||
if (!sharedSecret) return;
|
||||
|
||||
const orgName = sharedSecret.orgId ? (await orgDAL.findOrgById(sharedSecret.orgId))?.name : "";
|
||||
// Support organization level access for secret sharing
|
||||
if (sharedSecret.accessType === SecretSharingAccessType.Organization && orgId !== sharedSecret.orgId) {
|
||||
return {
|
||||
...sharedSecret,
|
||||
encryptedValue: "",
|
||||
iv: "",
|
||||
tag: "",
|
||||
orgName
|
||||
};
|
||||
}
|
||||
if (sharedSecret.expiresAt && sharedSecret.expiresAt < new Date()) {
|
||||
return;
|
||||
}
|
||||
@ -137,10 +118,7 @@ export const secretSharingServiceFactory = ({
|
||||
}
|
||||
await secretSharingDAL.updateById(sharedSecretId, { $decr: { expiresAfterViews: 1 } });
|
||||
}
|
||||
if (sharedSecret.accessType === SecretSharingAccessType.Organization && orgId === sharedSecret.orgId) {
|
||||
return { ...sharedSecret, orgName };
|
||||
}
|
||||
return { ...sharedSecret, orgName: undefined };
|
||||
return sharedSecret;
|
||||
};
|
||||
|
||||
const deleteSharedSecretById = async (deleteSharedSecretInput: TDeleteSharedSecretDTO) => {
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { SecretSharingAccessType } from "@app/lib/types";
|
||||
|
||||
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||
|
||||
export type TSharedSecretPermission = {
|
||||
@ -8,7 +6,6 @@ export type TSharedSecretPermission = {
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
actorOrgId: string;
|
||||
orgId: string;
|
||||
accessType?: SecretSharingAccessType;
|
||||
};
|
||||
|
||||
export type TCreatePublicSharedSecretDTO = {
|
||||
@ -18,7 +15,6 @@ export type TCreatePublicSharedSecretDTO = {
|
||||
hashedHex: string;
|
||||
expiresAt: Date;
|
||||
expiresAfterViews: number;
|
||||
accessType: SecretSharingAccessType;
|
||||
};
|
||||
|
||||
export type TCreateSharedSecretDTO = TSharedSecretPermission & TCreatePublicSharedSecretDTO;
|
||||
|
@ -23,7 +23,6 @@ export enum SmtpTemplates {
|
||||
EmailMfa = "emailMfa.handlebars",
|
||||
UnlockAccount = "unlockAccount.handlebars",
|
||||
AccessApprovalRequest = "accessApprovalRequest.handlebars",
|
||||
AccessSecretRequestBypassed = "accessSecretRequestBypassed.handlebars",
|
||||
HistoricalSecretList = "historicalSecretLeakIncident.handlebars",
|
||||
NewDeviceJoin = "newDevice.handlebars",
|
||||
OrgInvite = "organizationInvitation.handlebars",
|
||||
|
@ -1,28 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<title>Secret Approval Request Policy Bypassed</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Infisical</h1>
|
||||
<h2>Secret Approval Request Bypassed</h2>
|
||||
<p>A secret approval request has been bypassed in the project "{{projectName}}".</p>
|
||||
|
||||
<p>
|
||||
{{requesterFullName}} ({{requesterEmail}}) has merged
|
||||
a secret to environment {{environment}} at secret path {{secretPath}}
|
||||
without obtaining the required approvals.
|
||||
</p>
|
||||
<p>
|
||||
The following reason was provided for bypassing the policy:
|
||||
<em>{{bypassReason}}</em>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To review this action, please visit the request panel
|
||||
<a href="{{approvalUrl}}">here</a>.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
@ -6,7 +6,7 @@ description: "Learn how to request access to sensitive resources in Infisical."
|
||||
In certain situations, developers need to expand their access to a certain new project or a sensitive environment. For those use cases, it is helpful to utilize Infisical's **Access Requests** functionality.
|
||||
|
||||
This functionality works in the following way:
|
||||
1. A project administrator sets up an access policy that assigns access managers (also known as eligible approvers) to a certain sensitive folder or environment.
|
||||
1. A project administrator sets up a policy that assigns access managers (also known as eligible approvers) to a certain sensitive folder or environment.
|
||||

|
||||

|
||||
|
||||
@ -14,14 +14,9 @@ This functionality works in the following way:
|
||||

|
||||

|
||||
|
||||
4. An eligible approver can approve or reject the access request.
|
||||
{/*  */}
|
||||

|
||||
3. An eligible approver can approve or reject the access request.
|
||||

|
||||
|
||||
<Info>
|
||||
If the access request matches with a policy that has a **Soft** enforcement level, the requester may bypass the policy and get access to the resource without full approval.
|
||||
</Info>
|
||||
|
||||
5. As soon as the request is approved, developer is able to access the sought resources.
|
||||
4. As soon as the request is approved, developer is able to access the sought resources.
|
||||

|
||||
|
||||
|
@ -18,26 +18,16 @@ In a similar way, to solve the above-mentioned issues, Infisical provides a feat
|
||||
|
||||
### Setting a policy
|
||||
|
||||
First, you would need to create a set of policies for a certain environment. In the example below, a generic change policy for a production environment is shown. In this case, any user who submits a change to `prod` would first have to get an approval by a predefined approver (or multiple approvers).
|
||||
First, you would need to create a set of policies for a certain environment. In the example below, a generic policy for a production environment is shown. In this case, any user who submits a change to `prod` would first have to get an approval by a predefined approver (or multiple approvers).
|
||||
|
||||

|
||||
|
||||
### Policy enforcement levels
|
||||
|
||||
The enforcement level determines how strict the policy is. A **Hard** enforcement level means that any change that matches the policy will need full approval prior merging. A **Soft** enforcement level allows for break glass functionality on the request. If a change request is bypassed, the approvers will be notified via email.
|
||||
|
||||
### Example of creating a change policy
|
||||
|
||||
When creating a policy, you can choose the type of policy you want to create. In this case, we will be creating a `Change Policy`. Other types of policies include `Access Policy` that creates policies for **[Access Requests](/documentation/platform/access-controls/access-requests)**.
|
||||
|
||||

|
||||
|
||||
### Example of updating secrets with Approval workflows
|
||||
|
||||
When a user submits a change to an enviropnment that is under a particular policy, a corresponsing change request will go to a predefined approver (or multiple approvers).
|
||||
|
||||

|
||||
|
||||
Approvers are notified by email and/or Slack as soon as the request is initiated. In the Infisical Dashboard, they will be able to `approve` and `merge` (or `deny`) a request for a change in a particular environment. After that, depending on the workflows setup, the change will be automatically propagated to the right applications (e.g., using [Infisical Kubernetes Operator](https://infisical.com/docs/integrations/platforms/kubernetes)).
|
||||
An approver is notified by email and/or Slack as soon as the request is initiated. In the Infisical Dashboard, they will be able to `approve` and `merge` (or `deny`) a request for a change in a particular environment. After that, depending on the workflows setup, the change will be automatically propagated to the right applications (e.g., using [Infisical Kubernetes Operator](https://infisical.com/docs/integrations/platforms/kubernetes)).
|
||||
|
||||

|
@ -21,8 +21,7 @@ With its zero-knowledge architecture, secrets shared via Infisical remain unread
|
||||
zero knowledge architecture.
|
||||
</Note>
|
||||
|
||||
3. Click on the **Share Secret** button. Set the secret, its expiration time and specify if the secret can be viewed only once. It expires as soon as any of the conditions are met.
|
||||
Also, specify if the secret can be accessed by anyone or only people within your organization.
|
||||
3. Click on the **Share Secret** button. Set the secret, its expiration time as well as the number of views allowed. It expires as soon as any of the conditions are met.
|
||||
|
||||

|
||||
|
||||
|
Before Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 79 KiB |
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 114 KiB |
Before Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 130 KiB |
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 106 KiB |
Before Width: | Height: | Size: 67 KiB |
Before Width: | Height: | Size: 330 KiB |
@ -217,14 +217,7 @@
|
||||
"pages": [
|
||||
"self-hosting/overview",
|
||||
{
|
||||
"group": "Native installation methods",
|
||||
"pages": [
|
||||
"self-hosting/deployment-options/native/standalone-binary",
|
||||
"self-hosting/deployment-options/native/high-availability"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Containerized installation methods",
|
||||
"group": "Installation methods",
|
||||
"pages": [
|
||||
"self-hosting/deployment-options/standalone-infisical",
|
||||
"self-hosting/deployment-options/docker-swarm",
|
||||
|
@ -1,520 +0,0 @@
|
||||
---
|
||||
title: "Automatically deploy Infisical with High Availability"
|
||||
sidebarTitle: "High Availability"
|
||||
---
|
||||
|
||||
|
||||
# Self-Hosting Infisical with a native High Availability (HA) deployment
|
||||
|
||||
This page describes the Infisical architecture designed to provide high availability (HA) and how to deploy Infisical with high availability. The high availability deployment is designed to ensure that Infisical services are always available and can handle service failures gracefully, without causing service disruptions.
|
||||
|
||||
<Info>
|
||||
This deployment option is currently only available for Debian-based nodes (e.g., Ubuntu, Debian).
|
||||
We plan on adding support for other operating systems in the future.
|
||||
</Info>
|
||||
|
||||
## High availability architecture
|
||||
| Service | Nodes | Configuration | GCP | AWS |
|
||||
|----------------------------------|----------------|------------------------------|---------------|--------------|
|
||||
| External load balancer$^1$ | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5n.xlarge |
|
||||
| Internal load balancer$^2$ | 1 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5n.xlarge |
|
||||
| Etcd cluster$^3$ | 3 | 4 vCPU, 3.6 GB memory | n1-highcpu-4 | c5n.xlarge |
|
||||
| PostgreSQL$^4$ | 3 | 2 vCPU, 7.5 GB memory | n1-standard-2 | m5.large |
|
||||
| Sentinel$^4$ | 3 | 2 vCPU, 7.5 GB memory | n1-standard-2 | m5.large |
|
||||
| Redis$^4$ | 3 | 2 vCPU, 7.5 GB memory | n1-standard-2 | m5.large |
|
||||
| Infisical Core | 3 | 8 vCPU, 7.2 GB memory | n1-highcpu-8 | c5.2xlarge |
|
||||
|
||||
**Footnotes:**
|
||||
1. External load balancer: If you wish to have multiple instances of the internal load balancer, you will need to use an external load balancer to distribute incoming traffic across multiple internal load balancers.
|
||||
Using multiple internal load balancers is recommended for high-traffic environments. In the following guide we will use a single internal load balancer, as external load balancing falls outside the scope of this guide.
|
||||
2. Internal load balancer: The internal load balancer (a HAProxy instance) is used to distribute incoming traffic across multiple Infisical Core instances, Postgres nodes, and Redis nodes. The internal load balancer exposes a set of ports _(80 for Infiscial, 5000 for Read/Write postgres, 5001 for Read-only postgres, and 6379 for Redis)_. Where these ports route to is determained by the internal load balancer based on the availability and health of the service nodes.
|
||||
The internal load balancer is only accessible from within the same network, and is not exposed to the public internet.
|
||||
3. Etcd cluster: Etcd is a distributed key-value store used to store and distribute data between the PostgreSQL nodes. Etcd is dependent on high disk I/O performance, therefore it is highly recommended to use highly performant SSD disks for the Etcd nodes, with _at least_ 80GB of disk space.
|
||||
4. The Redis and PostgreSQL nodes will automatically be configured for high availability and used in your Infisical Core instances. However, you can optionally choose to bring your own database (BYOD), and skip these nodes. See more on how to [provide your own databases](#provide-your-own-databases).
|
||||
|
||||
<Info>
|
||||
For all services that require multiple nodes, it is recommended to deploy them across multiple availability zones (AZs) to ensure high availability and fault tolerance. This will help prevent service disruptions in the event of an AZ failure.
|
||||
</Info>
|
||||
|
||||

|
||||
The image above shows how a high availability deployment of Infisical is structured. In this example, an external load balancer is used to distribute incoming traffic across multiple internal load balancers. The internal load balancers. The external load balancer isn't required, and it will require additional configuration to set up.
|
||||
|
||||
### Fault Tolerance
|
||||
This setup provides N+1 redundancy, meaning it can tolerate the failure of any single node without service interruption.
|
||||
|
||||
## Ansible
|
||||
### What is Ansible
|
||||
Ansible is an open-source automation tool that simplifies application deployment, configuration management, and task automation.
|
||||
At Infisical, we use Ansible to automate the deployment of Infisical services. The Ansible roles are designed to make it easy to deploy Infisical services in a high availability environment.
|
||||
|
||||
### Installing Ansible
|
||||
<Steps>
|
||||
<Step title="Install using the pipx Python package manager">
|
||||
```bash
|
||||
pipx install --include-deps ansible
|
||||
```
|
||||
</Step>
|
||||
<Step title="Verify the installation">
|
||||
```bash
|
||||
ansible --version
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
### Understanding Ansible Concepts
|
||||
|
||||
* Inventory _(inventory.ini)_: A file that lists your target hosts.
|
||||
* Playbook _(playbook.yml)_: YAML file containing a set of tasks to be executed on hosts.
|
||||
* Roles: Reusable units of organization for playbooks. Roles are used to group tasks together in a structured and reusable manner.
|
||||
|
||||
|
||||
### Basic Ansible Commands
|
||||
Running a playbook with with an invetory file:
|
||||
```bash
|
||||
ansible-playbook -i inventory.ini playbook.yml
|
||||
```
|
||||
|
||||
This is how you would run the playbook containing the roles for setting up Infisical in a high availability environment.
|
||||
|
||||
### Installing the Infisical High Availability Deployment Ansible Role
|
||||
The Infisical Ansible role is available on Ansible Galaxy. You can install the role by running the following command:
|
||||
```bash
|
||||
ansible-galaxy collection install infisical.infisical_core_ha_deployment
|
||||
```
|
||||
|
||||
|
||||
## Set up components
|
||||
1. External load balancer (optional, and not covered in this guide)
|
||||
2. [Configure Etcd cluster](#configure-etcd-cluster)
|
||||
3. [Configure PostgreSQL database](#configure-postgresql-database)
|
||||
4. [Configure Redis/Sentinel](#configure-redis-and-sentinel)
|
||||
5. [Configure Infisical Core](#configure-infisical-core)
|
||||
|
||||
|
||||
The servers start on the same 52.1.0.0/24 private network range, and can connect to each other freely on these addresses.
|
||||
|
||||
The following list includes descriptions of each server and its assigned IP:
|
||||
|
||||
52.1.0.1: External Load Balancer
|
||||
52.1.0.2: Internal Load Balancer
|
||||
52.1.0.3: Etcd 1
|
||||
52.1.0.4: Etcd 2
|
||||
52.1.0.5: Etcd 3
|
||||
52.1.0.6: PostgreSQL 1
|
||||
52.1.0.7: PostgreSQL 2
|
||||
52.1.0.8: PostgreSQL 3
|
||||
52.1.0.9: Redis 1
|
||||
52.1.0.10: Redis 2
|
||||
52.1.0.11: Redis 3
|
||||
52.1.0.12: Sentinel 1
|
||||
52.1.0.13: Sentinel 2
|
||||
52.1.0.14: Sentinel 3
|
||||
52.1.0.15: Infisical Core 1
|
||||
52.1.0.16: Infisical Core 2
|
||||
52.1.0.17: Infisical Core 3
|
||||
|
||||
|
||||
|
||||
### Configure Etcd cluster
|
||||
|
||||
Configuring the ETCD cluster is the first step in setting up a high availability deployment of Infisical.
|
||||
The ETCD cluster is used to store and distribute data between the PostgreSQL nodes. The ETCD cluster is a distributed key-value store that is highly available and fault-tolerant.
|
||||
|
||||
```yaml example.playbook.yml
|
||||
- hosts: all
|
||||
gather_facts: true
|
||||
|
||||
- name: Set up etcd cluster
|
||||
hosts: etcd
|
||||
become: true
|
||||
collections:
|
||||
- infisical.infisical_core_ha_deployment
|
||||
roles:
|
||||
- role: etcd
|
||||
```
|
||||
|
||||
```ini example.inventory.ini
|
||||
[etcd]
|
||||
etcd1 ansible_host=52.1.0.3
|
||||
etcd2 ansible_host=52.1.0.4
|
||||
etcd3 ansible_host=52.1.0.5
|
||||
|
||||
[etcd:vars]
|
||||
ansible_user=ubuntu
|
||||
ansible_ssh_private_key_file=./ssh-key.pem
|
||||
ansible_ssh_common_args='-o StrictHostKeyChecking=no'
|
||||
```
|
||||
|
||||
### Configure PostgreSQL database
|
||||
|
||||
The Postgres role takes a set of parameters that are used to configure your PostgreSQL database.
|
||||
|
||||
Make sure to set the following variables in your playbook.yml file:
|
||||
- `postgres_super_user_password`: The password for the 'postgres' database user.
|
||||
- `postgres_db_name`: The name of the database that will be created on the leader node and replicated to the secondary nodes.
|
||||
- `postgres_user`: The name of the user that will be created on the leader node and replicated to the secondary nodes.
|
||||
- `postgres_user_password`: The password for the user that will be created on the leader node and replicated to the secondary nodes.
|
||||
- `etcd_hosts`: The list of etcd hosts that the PostgreSQL nodes will use to communicate with etcd. By default you want to keep this value set to `"{{ groups['etcd'] }}"`
|
||||
|
||||
```yaml example.playbook.yml
|
||||
- hosts: all
|
||||
gather_facts: true
|
||||
|
||||
- name: Set up PostgreSQL with Patroni
|
||||
hosts: postgres
|
||||
become: true
|
||||
collections:
|
||||
- infisical.infisical_core_ha_deployment
|
||||
roles:
|
||||
- role: postgres
|
||||
vars:
|
||||
postgres_super_user_password: "your-super-user-password"
|
||||
postgres_user: infisical-user
|
||||
postgres_user_password: "your-password"
|
||||
postgres_db_name: infisical-db
|
||||
|
||||
etcd_hosts: "{{ groups['etcd'] }}"
|
||||
```
|
||||
|
||||
```ini example.inventory.ini
|
||||
[postgres]
|
||||
postgres1 ansible_host=52.1.0.6
|
||||
postgres2 ansible_host=52.1.0.7
|
||||
postgres3 ansible_host=52.1.0.8
|
||||
```
|
||||
|
||||
### Configure Redis and Sentinel
|
||||
|
||||
The Redis role takes a single variable as input, which is the redis password.
|
||||
The Sentinel and Redis hosts will run the same role, therefore we are running the task for both the sentinel and redis hosts, `hosts: redis:sentinel`.
|
||||
|
||||
- `redis_password`: The password that will be set for the Redis instance.
|
||||
|
||||
```yaml example.playbook.yml
|
||||
- hosts: all
|
||||
gather_facts: true
|
||||
|
||||
- name: Setup Redis and Sentinel
|
||||
hosts: redis:sentinel
|
||||
become: true
|
||||
collections:
|
||||
- infisical.infisical_core_ha_deployment
|
||||
roles:
|
||||
- role: redis
|
||||
vars:
|
||||
redis_password: "REDIS_PASSWORD"
|
||||
```
|
||||
|
||||
```ini example.inventory.ini
|
||||
[redis]
|
||||
redis1 ansible_host=52.1.0.9
|
||||
redis2 ansible_host=52.1.0.10
|
||||
redis3 ansible_host=52.1.0.11
|
||||
|
||||
[sentinel]
|
||||
sentinel1 ansible_host=52.1.0.12
|
||||
sentinel2 ansible_host=52.1.0.13
|
||||
sentinel3 ansible_host=52.1.0.14
|
||||
```
|
||||
|
||||
### Configure Internal Load Balancer
|
||||
|
||||
The internal load balancer used is HAProxy. HAProxy will expose a set of ports as listed below. Each port will route to a different service based on the availability and health of the service nodes.
|
||||
|
||||
- Port 80: Infisical Core
|
||||
- Port 5000: Read/Write PostgreSQL
|
||||
- Port 5001: Read-only PostgreSQL
|
||||
- Port 6379: Redis
|
||||
- Port 7000: HAProxy monitoring
|
||||
These ports will need to be exposed on your network to become accessible from the outside world.
|
||||
|
||||
The HAProxy configuration file is generated by the Infisical Core role, and is located at `/etc/haproxy/haproxy.cfg` on your internal load balancer node.
|
||||
|
||||
The HAProxy setup comes with a monitoring panel. You have to set the username/password combination for the monitoring panel by setting the `stats_user` and `stats_password` variables in the HAProxy role.
|
||||
|
||||
|
||||
Once the HAProxy role has fully executed, you can monitor your HA setup by navigating to `http://52.1.0.2:7000/haproxy?stats` in your browser.
|
||||
|
||||
```ini example.inventory.ini
|
||||
[haproxy]
|
||||
internal_lb ansible_host=52.1.0.2
|
||||
```
|
||||
|
||||
```yaml example.playbook.yml
|
||||
- name: Set up HAProxy
|
||||
hosts: haproxy
|
||||
become: true
|
||||
collections:
|
||||
- infisical.infisical_core_ha_deployment
|
||||
roles:
|
||||
- role: haproxy
|
||||
vars:
|
||||
stats_user: "stats-username"
|
||||
stats_password: "stats-password!"
|
||||
|
||||
postgres_servers: "{{ groups['postgres'] }}"
|
||||
infisical_servers: "{{ groups['infisical'] }}"
|
||||
redis_servers: "{{ groups['redis'] }}"
|
||||
```
|
||||
|
||||
|
||||
|
||||
### Configure Infisical Core
|
||||
|
||||
The Infisical Core role will set up your actual Infisical instances.
|
||||
|
||||
The `env_vars` variable is used to set the environment variables that Infisical will use. The minimum required environment variables are `ENCRYPTION_KEY` and `AUTH_SECRET`. You can find a list of all available environment variables [here](/docs/self-hosting/configuration/envars#general-platform).
|
||||
The `DB_CONNECTION_URI` and `REDIS_URL` variables will automatically be set if you're running the full playbook. However, you can choose to set them yourself, and skip the Postgres, etcd, redis/sentinel roles entirely.
|
||||
|
||||
<Info>
|
||||
If you later need to add new environment varibles to your Infisical deployments, it's important you add the variables to **all** your Infisical nodes.<br/>
|
||||
You can find the environment file for Infisical at `/etc/infisical/environment`.<br/>
|
||||
After editing the environment file, you need to reload the Infisical service by doing `systemctl restart infisical`.
|
||||
</Info>
|
||||
|
||||
```yaml example.playbook.yml
|
||||
- hosts: all
|
||||
gather_facts: true
|
||||
|
||||
- name: Setup Infisical
|
||||
hosts: infisical
|
||||
become: true
|
||||
collections:
|
||||
- infisical.infisical_core_ha_deployment
|
||||
roles:
|
||||
- role: infisical
|
||||
env_vars:
|
||||
ENCRYPTION_KEY: "YOUR_ENCRYPTION_KEY" # openssl rand -hex 16
|
||||
AUTH_SECRET: "YOUR_AUTH_SECRET" # openssl rand -base64 32
|
||||
```
|
||||
|
||||
```ini example.inventory.ini
|
||||
[infisical]
|
||||
infisical1 ansible_host=52.1.0.15
|
||||
infisical2 ansible_host=52.1.0.16
|
||||
infisical3 ansible_host=52.1.0.17
|
||||
```
|
||||
|
||||
## Provide your own databases
|
||||
Bringing your own database is an option using the Infisical Core deployment role.
|
||||
By bringing your own database, you're able to skip the Etcd, Postgres, and Redis/Sentinel roles entirely.
|
||||
|
||||
To bring your own database, you need to set the `DB_CONNECTION_URI` and `REDIS_URL` environment variables in the Infisical Core role.
|
||||
|
||||
```yaml example.playbook.yml
|
||||
- hosts: all
|
||||
gather_facts: true
|
||||
|
||||
- name: Setup Infisical
|
||||
hosts: infisical
|
||||
become: true
|
||||
collections:
|
||||
- infisical.infisical_core_ha_deployment
|
||||
roles:
|
||||
- role: infisical
|
||||
env_vars:
|
||||
ENCRYPTION_KEY: "YOUR_ENCRYPTION_KEY" # openssl rand -hex 16
|
||||
AUTH_SECRET: "YOUR_AUTH_SECRET" # openssl rand -base64 32
|
||||
DB_CONNECTION_URI: "postgres://user:password@localhost:5432/infisical"
|
||||
REDIS_URL: "redis://localhost:6379"
|
||||
```
|
||||
|
||||
```ini example.inventory.ini
|
||||
[infisical]
|
||||
infisical1 ansible_host=52.1.0.15
|
||||
infisical2 ansible_host=52.1.0.16
|
||||
infisical3 ansible_host=52.1.0.17
|
||||
```
|
||||
|
||||
## Full deployment example
|
||||
To make it easier to get started, we've provided a full deployment example that you can use to deploy Infisical in a high availability environment.
|
||||
|
||||
- This deployment does not use an external load balancer.
|
||||
- You **must** change the environment variables defined in the `playbook.yml` example.
|
||||
- You have update the IP addresses in the `inventory.ini` file to match your own network configuration.
|
||||
- You need to set the SSH key and ssh user in the `inventory.ini` file.
|
||||
|
||||
<Steps>
|
||||
<Step title="Install Ansible">
|
||||
Install Ansible using the pipx Python package manager.
|
||||
```bash
|
||||
pipx install --include-deps ansible
|
||||
```
|
||||
|
||||
</Step>
|
||||
<Step title="Install the Infisical deployment Ansible Role">
|
||||
Install the Infisical deployment role from Ansible Galaxy.
|
||||
```bash
|
||||
ansible-galaxy collection install infisical.infisical_core_ha_deployment
|
||||
```
|
||||
</Step>
|
||||
<Step title="Setup your hosts">
|
||||
|
||||
Create an `inventory.ini` file, and define your hosts and their IP addresses. You can use the example below as a template, and update the IP addresses to match your own network configuration.
|
||||
Make sure to set the SSH key and ssh user in the `inventory.ini` file. Please see the example below.
|
||||
|
||||
```ini example.inventory.ini
|
||||
[etcd]
|
||||
etcd1 ansible_host=52.1.0.3
|
||||
etcd2 ansible_host=52.1.0.4
|
||||
etcd3 ansible_host=52.1.0.5
|
||||
|
||||
[postgres]
|
||||
postgres1 ansible_host=52.1.0.6
|
||||
postgres2 ansible_host=52.1.0.7
|
||||
postgres3 ansible_host=52.1.0.8
|
||||
|
||||
[infisical]
|
||||
infisical1 ansible_host=52.1.0.15
|
||||
infisical2 ansible_host=52.1.0.16
|
||||
infisical3 ansible_host=52.1.0.17
|
||||
|
||||
[redis]
|
||||
redis1 ansible_host=52.1.0.9
|
||||
redis2 ansible_host=52.1.0.10
|
||||
redis3 ansible_host=52.1.0.11
|
||||
|
||||
[sentinel]
|
||||
sentinel1 ansible_host=52.1.0.12
|
||||
sentinel2 ansible_host=52.1.0.13
|
||||
sentinel3 ansible_host=52.1.0.14
|
||||
|
||||
[haproxy]
|
||||
internal_lb ansible_host=52.1.0.2
|
||||
|
||||
; This can be defined individually for each host, or globally for all hosts.
|
||||
; In this case the credentials are the same for all hosts, so we define them globally as seen below ([all:vars]).
|
||||
[all:vars]
|
||||
ansible_user=ubuntu
|
||||
ansible_ssh_private_key_file=./your-ssh-key.pem
|
||||
ansible_ssh_common_args='-o StrictHostKeyChecking=no'
|
||||
```
|
||||
</Step>
|
||||
<Step title="Setup your Ansible playbook">
|
||||
The Ansible playbook is where you define which roles/tasks to execute on which hosts.
|
||||
|
||||
```yaml example.playbook.yml
|
||||
---
|
||||
# Important, we must gather facts from all hosts prior to running the roles to ensure we have all the information we need.
|
||||
- hosts: all
|
||||
gather_facts: true
|
||||
|
||||
- name: Set up etcd cluster
|
||||
hosts: etcd
|
||||
become: true
|
||||
collections:
|
||||
- infisical.infisical_core_ha_deployment
|
||||
roles:
|
||||
- role: etcd
|
||||
|
||||
- name: Set up PostgreSQL with Patroni
|
||||
hosts: postgres
|
||||
become: true
|
||||
collections:
|
||||
- infisical.infisical_core_ha_deployment
|
||||
roles:
|
||||
- role: postgres
|
||||
vars:
|
||||
postgres_super_user_password: "<ENTER_SUPERUSER_PASSWORD>" # Password for the 'postgres' database user
|
||||
|
||||
# A database with these credentials will be created on the leader node, and replicated to the secondary nodes.
|
||||
postgres_db_name: <ENTER_DB_NAME>
|
||||
postgres_user: <ENTER_DB_USER>
|
||||
postgres_user_password: <ENTER_DB_USER_PASSWORD>
|
||||
|
||||
etcd_hosts: "{{ groups['etcd'] }}"
|
||||
|
||||
- name: Setup Redis and Sentinel
|
||||
hosts: redis:sentinel
|
||||
become: true
|
||||
collections:
|
||||
- infisical.infisical_core_ha_deployment
|
||||
roles:
|
||||
- role: redis
|
||||
vars:
|
||||
redis_password: "<ENTER_REDIS_PASSWORD>"
|
||||
|
||||
- name: Set up HAProxy
|
||||
hosts: haproxy
|
||||
become: true
|
||||
collections:
|
||||
- infisical.infisical_core_ha_deployment
|
||||
roles:
|
||||
- role: haproxy
|
||||
vars:
|
||||
stats_user: "<ENTER_HAPROXY_STATS_USERNAME>"
|
||||
stats_password: "<ENTER_HAPROXY_STATS_PASSWORD>"
|
||||
|
||||
postgres_servers: "{{ groups['postgres'] }}"
|
||||
infisical_servers: "{{ groups['infisical'] }}"
|
||||
redis_servers: "{{ groups['redis'] }}"
|
||||
- name: Setup Infisical
|
||||
hosts: infisical
|
||||
become: true
|
||||
collections:
|
||||
- infisical.infisical_core_ha_deployment
|
||||
roles:
|
||||
- role: infisical
|
||||
env_vars:
|
||||
ENCRYPTION_KEY: "YOUR_ENCRYPTION_KEY" # openssl rand -hex 16
|
||||
AUTH_SECRET: "YOUR_AUTH_SECRET" # openssl rand -base64 32
|
||||
```
|
||||
</Step>
|
||||
<Step title="Run the Ansible playbook">
|
||||
After creating the `playbook.yml` and `inventory.ini` files, you can run the playbook using the following command
|
||||
```bash
|
||||
ansible-playbook -i inventory.ini playbook.yml
|
||||
```
|
||||
|
||||
This step may take upwards of 10 minutes to complete, depending on the number of nodes and the network speed.
|
||||
Once the playbook has completed, you should have a fully deployed high availability Infisical environment.
|
||||
|
||||
To access Infisical, you can try navigating to `http://52.1.0.2`, in order to view your newly deployed Infisical instance.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
## Post-deployment steps
|
||||
After deploying Infisical in a high availability environment, you should perform the following post-deployment steps:
|
||||
- Check your deployment to ensure that all services are running as expected. You can use the HAProxy monitoring panel to check the status of your services (http://52.1.0.2:7000/haproxy?stats)
|
||||
- Attempt to access the Infisical Core instances to ensure that they are accessible from the internal load balancer. (http://52.1.0.2)
|
||||
|
||||
A HAProxy stats page indicating success will look like the image below
|
||||

|
||||
|
||||
|
||||
## Security Considerations
|
||||
### Network Security
|
||||
Secure the network that your instances run on. While this falls outside the scope of Infisical deployment, it's crucial for overall security.
|
||||
AWS-specific recommendations:
|
||||
|
||||
Use Virtual Private Cloud (VPC) to isolate your infrastructure.
|
||||
Configure security groups to restrict inbound and outbound traffic.
|
||||
Use Network Access Control Lists (NACLs) for additional network-level security.
|
||||
|
||||
<Note>
|
||||
Please take note that the Infisical team cannot provide infrastructure support for **free self-hosted** deployments.<br/>If you need help with infrastructure, we recommend upgrading to a [paid plan](https://infisical.com/pricing) which includes infrastructure support.
|
||||
|
||||
You can also join our community [Slack](https://infisical.com/slack) for help and support from the community.
|
||||
</Note>
|
||||
|
||||
|
||||
### Troubleshooting
|
||||
<Accordion title="Ansible: Failed to set permissions on the temporary files Ansible needs to create when becoming an unprivileged user">
|
||||
If you encounter this issue, please update your ansible config (`ansible.cfg`) file with the following configuration:
|
||||
```ini
|
||||
[defaults]
|
||||
allow_world_readable_tmpfiles = true
|
||||
```
|
||||
|
||||
You can read more about the solution [here](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/sh_shell.html#parameter-world_readable_temp)
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="I'm unable to connect to access the Infisical instance on the web">
|
||||
This issue can be caused by a number of reasons, mostly realted to the network configuration. Here are a few things you can check:
|
||||
1. Ensure that the firewall is not blocking the connection. You can check this by running `ufw status`. Ensure that port 80 is open.
|
||||
2. If you're using a cloud provider like AWS or GCP, ensure that the security group allows traffic on port 80.
|
||||
3. Ensure that the HAProxy service is running. You can check this by running `systemctl status haproxy`.
|
||||
4. Ensure that the Infisical service is running. You can check this by running `systemctl status infisical`.
|
||||
</Accordion>
|
@ -1,203 +0,0 @@
|
||||
---
|
||||
title: "Standalone"
|
||||
description: "Learn how to deploy Infisical in a standalone environment."
|
||||
---
|
||||
|
||||
# Self-Hosting Infisical with Standalone Infisical
|
||||
|
||||
Deploying Infisical in a standalone environment is a great way to get started with Infisical without having to use containers. This guide will walk you through the process of deploying Infisical in a standalone environment.
|
||||
This is one of the easiest ways to deploy Infisical. It is a single executable, currently only supported on Debian-based systems.
|
||||
|
||||
The standalone deployment implements the "bring your own database" (BYOD) approach. This means that you will need to provide your own databases (specifically Postgres and Redis) for the Infisical services to use. The standalone deployment does not include any databases.
|
||||
|
||||
If you wish to streamline the deployment process, we recommend using the Ansible role for Infisical. The Ansible role automates the deployment process and includes the databases:
|
||||
- [Automated Deployment](https://google.com)
|
||||
- [Automated Deployment with high availability (HA)](https://google.com)
|
||||
|
||||
|
||||
## Prerequisites
|
||||
- A server running a Debian-based operating system (e.g., Ubuntu, Debian)
|
||||
- A Postgres database
|
||||
- A Redis database
|
||||
|
||||
## Installing Infisical
|
||||
Installing Infisical is as simple as running a single command. You can install Infisical by running the following command:
|
||||
|
||||
```bash
|
||||
$ curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-core/cfg/setup/bash.deb.sh' | sudo bash && sudo apt-get install -y infisical-core
|
||||
```
|
||||
|
||||
## Running Infisical
|
||||
Running Infisical and serving it to the web has a few steps. Below are the steps to get you started with running Infisical in a standalone environment.
|
||||
* Setup environment variables
|
||||
* Running Postgres migrations
|
||||
* Create system daemon
|
||||
* Exposing Infisical to the internet
|
||||
|
||||
|
||||
<Steps>
|
||||
<Step title="Setup environment variables">
|
||||
To use Infisical you'll need to configure the environment variables beforehand. You can acheive this by creating an environment file to be used by Infisical.
|
||||
|
||||
|
||||
#### Create environment file
|
||||
```bash
|
||||
$ mkdir -p /etc/infisical && touch /etc/infisical/environment
|
||||
```
|
||||
|
||||
After creating the environment file, you'll need to fill it out with your environment variables.
|
||||
|
||||
#### Edit environment file
|
||||
```bash
|
||||
$ nano /etc/infisical/environment
|
||||
```
|
||||
|
||||
```bash
|
||||
DB_CONNECTION_URI=postgres://user:password@localhost:5432/infisical # Replace with your Postgres database connection URI
|
||||
REDIS_URL=redis://localhost:6379 # Replace with your Redis connection URI
|
||||
ENCRYPTION_KEY=your_encryption_key # Replace with your encryption key (can be generated with: openssl rand -hex 16)
|
||||
AUTH_SECRET=your_auth_secret # Replace with your auth secret (can be generated with: openssl rand -base64 32)
|
||||
```
|
||||
|
||||
<Info>
|
||||
The minimum required environment variables are `DB_CONNECTION_URI`, `REDIS_URL`, `ENCRYPTION_KEY`, and `AUTH_SECRET`. We recommend You take a look at our [list of all available environment variables](/docs/self-hosting/configuration/envars#general-platform), and configure the ones you need.
|
||||
</Info>
|
||||
</Step>
|
||||
<Step title="Running Postgres migrations">
|
||||
|
||||
Assuming you're starting with a fresh Postgres database, you'll need to run the Postgres migrations to syncronize the database schema.
|
||||
The migration command will use the environment variables you configured in the previous step.
|
||||
|
||||
|
||||
```bash
|
||||
$ eval $(cat /etc/infisical/environment) infisical-core migration:latest
|
||||
```
|
||||
|
||||
<Info>
|
||||
This step will need to be repeated if you update Infisical in the future.
|
||||
</Info>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Create service file">
|
||||
```bash
|
||||
$ nano /etc/systemd/system/infisical.service
|
||||
```
|
||||
</Step>
|
||||
<Step title="Create Infisical service">
|
||||
|
||||
Create a systemd service file for Infisical. Creating a systemd service file will allow Infisical to start automatically when the system boots or in case of a crash.
|
||||
|
||||
```bash
|
||||
$ nano /etc/systemd/system/infisical.service
|
||||
```
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Infisical Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
# The path to the environment file we created in the previous step
|
||||
EnvironmentFile=/etc/infisical/environment
|
||||
Type=simple
|
||||
# Change the user to the user you want to run Infisical as
|
||||
User=root
|
||||
ExecStart=/usr/local/bin/infisical-core
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Now we need to reload the systemd daemon and start the Infisical service.
|
||||
|
||||
```bash
|
||||
$ systemctl daemon-reload
|
||||
$ systemctl start infisical
|
||||
$ systemctl enable infisical
|
||||
```
|
||||
|
||||
<Info>
|
||||
You can check the status of the Infisical service by running `systemctl status infisical`.
|
||||
It is also a good idea to check the logs for any errors by running `journalctl --no-pager -u infisical`.
|
||||
</Info>
|
||||
</Step>
|
||||
<Step title="Exposing Infisical to the internet">
|
||||
Exposing Infisical to the internet requires setting up a reverse proxy. You can use any reverse proxy of your choice, but we recommend using HAProxy or Nginx. Below is an example of how to set up a reverse proxy using HAProxy.
|
||||
|
||||
#### Install HAProxy
|
||||
```bash
|
||||
$ apt-get install -y haproxy
|
||||
```
|
||||
|
||||
#### Edit HAProxy configuration
|
||||
```bash
|
||||
$ nano /etc/haproxy/haproxy.cfg
|
||||
```
|
||||
|
||||
```ini
|
||||
global
|
||||
log /dev/log local0
|
||||
log /dev/log local1 notice
|
||||
chroot /var/lib/haproxy
|
||||
stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
|
||||
stats timeout 30s
|
||||
user haproxy
|
||||
group haproxy
|
||||
daemon
|
||||
|
||||
defaults
|
||||
log global
|
||||
mode http
|
||||
option httplog
|
||||
option dontlognull
|
||||
timeout connect 5000
|
||||
timeout client 50000
|
||||
timeout server 50000
|
||||
|
||||
frontend http-in
|
||||
bind *:80
|
||||
default_backend infisical
|
||||
|
||||
backend infisical
|
||||
server infisicalapp 127.0.0.1:8080 check
|
||||
```
|
||||
|
||||
<Warning>
|
||||
If you decide to use Nginx, then please be aware that the configuration will be different. **Infisical listens on port 8080**.
|
||||
</Warning>
|
||||
|
||||
#### Restart HAProxy
|
||||
```bash
|
||||
$ systemctl restart haproxy
|
||||
```
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
And that's it! You have successfully deployed Infisical in a standalone environment. You can now access Infisical by visiting `http://your-server-ip`.
|
||||
|
||||
<Note>
|
||||
Please take note that the Infisical team cannot provide infrastructure support for **free self-hosted** deployments.<br/>If you need help with infrastructure, we recommend upgrading to a [paid plan](https://infisical.com/pricing) which includes infrastructure support.
|
||||
|
||||
You can also join our community [Slack](https://infisical.com/slack) for help and support from the community.
|
||||
</Note>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<Accordion title="I'm getting a error related to the HAProxy (Missing LF on last line, file might have been truncated at position X)">
|
||||
This is a common issue related to the HAProxy configuration file. The error is caused by the missing newline character at the end of the file. You can fix this by adding a newline character at the end of the file.
|
||||
|
||||
```bash
|
||||
$ echo "" >> /etc/haproxy/haproxy.cfg
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="I'm unable to connect to access the Infisical instance on the web">
|
||||
This issue can be caused by a number of reasons, mostly realted to the network configuration. Here are a few things you can check:
|
||||
1. Ensure that the firewall is not blocking the connection. You can check this by running `ufw status`. Ensure that port 80 is open.
|
||||
2. If you're using a cloud provider like AWS or GCP, ensure that the security group allows traffic on port 80.
|
||||
3. Ensure that the HAProxy service is running. You can check this by running `systemctl status haproxy`.
|
||||
4. Ensure that the Infisical service is running. You can check this by running `systemctl status infisical`.
|
||||
</Accordion>
|
@ -33,21 +33,3 @@ Choose from a number of deployment options listed below to get started.
|
||||
Use our Helm chart to Install Infisical on your Kubernetes cluster.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
<CardGroup cols={2}>
|
||||
<Card
|
||||
title="Native Deployment"
|
||||
color="#000000"
|
||||
icon="box"
|
||||
href="deployment-options/native/standalone-binary"
|
||||
>
|
||||
Install Infisical on your Debian-based system without containers using our standalone binary.
|
||||
</Card>
|
||||
<Card
|
||||
title="Native Deployment, High Availability"
|
||||
color="#000000"
|
||||
icon="boxes-stacked"
|
||||
href="deployment-options/native/high-availability"
|
||||
>
|
||||
Install Infisical on your Debian-based instances without containers using our standalone binary with high availability out of the box.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
@ -21,7 +21,7 @@ export const EmptyState = ({
|
||||
}: Props) => (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex w-full flex-col items-center bg-mineshaft-800 px-2 pt-4 text-bunker-300",
|
||||
"flex w-full flex-col items-center bg-mineshaft-800 px-2 pt-6 text-bunker-300",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
@ -7,6 +7,7 @@ export {
|
||||
useUpdateProjectRole
|
||||
} from "./mutation";
|
||||
export {
|
||||
useGetOrgRole,
|
||||
useGetOrgRoles,
|
||||
useGetProjectRoleBySlug,
|
||||
useGetProjectRoles,
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
TCreateProjectRoleDTO,
|
||||
TDeleteOrgRoleDTO,
|
||||
TDeleteProjectRoleDTO,
|
||||
TOrgRole,
|
||||
TUpdateOrgRoleDTO,
|
||||
TUpdateProjectRoleDTO
|
||||
} from "./types";
|
||||
@ -52,12 +53,17 @@ export const useDeleteProjectRole = () => {
|
||||
export const useCreateOrgRole = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ orgId, permissions, ...dto }: TCreateOrgRoleDTO) =>
|
||||
apiRequest.post(`/api/v1/organization/${orgId}/roles`, {
|
||||
return useMutation<TOrgRole, {}, TCreateOrgRoleDTO>({
|
||||
mutationFn: async ({ orgId, permissions, ...dto }: TCreateOrgRoleDTO) => {
|
||||
const {
|
||||
data: { role }
|
||||
} = await apiRequest.post(`/api/v1/organization/${orgId}/roles`, {
|
||||
...dto,
|
||||
permissions: permissions.length ? packRules(permissions) : []
|
||||
}),
|
||||
});
|
||||
|
||||
return role;
|
||||
},
|
||||
onSuccess: (_, { orgId }) => {
|
||||
queryClient.invalidateQueries(roleQueryKeys.getOrgRoles(orgId));
|
||||
}
|
||||
@ -67,14 +73,20 @@ export const useCreateOrgRole = () => {
|
||||
export const useUpdateOrgRole = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, orgId, permissions, ...dto }: TUpdateOrgRoleDTO) =>
|
||||
apiRequest.patch(`/api/v1/organization/${orgId}/roles/${id}`, {
|
||||
return useMutation<TOrgRole, {}, TUpdateOrgRoleDTO>({
|
||||
mutationFn: async ({ id, orgId, permissions, ...dto }: TUpdateOrgRoleDTO) => {
|
||||
const {
|
||||
data: { role }
|
||||
} = await apiRequest.patch(`/api/v1/organization/${orgId}/roles/${id}`, {
|
||||
...dto,
|
||||
permissions: permissions?.length ? packRules(permissions) : []
|
||||
}),
|
||||
onSuccess: (_, { orgId }) => {
|
||||
});
|
||||
|
||||
return role;
|
||||
},
|
||||
onSuccess: (_, { id, orgId }) => {
|
||||
queryClient.invalidateQueries(roleQueryKeys.getOrgRoles(orgId));
|
||||
queryClient.invalidateQueries(roleQueryKeys.getOrgRole(orgId, id));
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -82,13 +94,19 @@ export const useUpdateOrgRole = () => {
|
||||
export const useDeleteOrgRole = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ orgId, id }: TDeleteOrgRoleDTO) =>
|
||||
apiRequest.delete(`/api/v1/organization/${orgId}/roles/${id}`, {
|
||||
return useMutation<TOrgRole, {}, TDeleteOrgRoleDTO>({
|
||||
mutationFn: async ({ orgId, id }: TDeleteOrgRoleDTO) => {
|
||||
const {
|
||||
data: { role }
|
||||
} = await apiRequest.delete(`/api/v1/organization/${orgId}/roles/${id}`, {
|
||||
data: { orgId }
|
||||
}),
|
||||
onSuccess: (_, { orgId }) => {
|
||||
});
|
||||
|
||||
return role;
|
||||
},
|
||||
onSuccess: (_, { id, orgId }) => {
|
||||
queryClient.invalidateQueries(roleQueryKeys.getOrgRoles(orgId));
|
||||
queryClient.invalidateQueries(roleQueryKeys.getOrgRole(orgId, id));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -40,6 +40,7 @@ export const roleQueryKeys = {
|
||||
getProjectRoleBySlug: (projectSlug: string, roleSlug: string) =>
|
||||
["roles", { projectSlug, roleSlug }] as const,
|
||||
getOrgRoles: (orgId: string) => ["org-roles", { orgId }] as const,
|
||||
getOrgRole: (orgId: string, roleId: string) => [{ orgId, roleId }, "org-role"] as const,
|
||||
getUserOrgPermissions: ({ orgId }: TGetUserOrgPermissionsDTO) =>
|
||||
["user-permissions", { orgId }] as const,
|
||||
getUserProjectPermissions: ({ workspaceId }: TGetUserProjectPermissionDTO) =>
|
||||
@ -89,6 +90,21 @@ export const useGetOrgRoles = (orgId: string, enable = true) =>
|
||||
enabled: Boolean(orgId) && enable
|
||||
});
|
||||
|
||||
export const useGetOrgRole = (orgId: string, roleId: string) =>
|
||||
useQuery({
|
||||
queryKey: roleQueryKeys.getOrgRole(orgId, roleId),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{
|
||||
role: Omit<TOrgRole, "permissions"> & { permissions: unknown };
|
||||
}>(`/api/v1/organization/${orgId}/roles/${roleId}`);
|
||||
return {
|
||||
...data.role,
|
||||
permissions: unpackRules(data.role.permissions as PackRule<TPermission>[])
|
||||
};
|
||||
},
|
||||
enabled: Boolean(orgId && roleId)
|
||||
});
|
||||
|
||||
const getUserOrgPermissions = async ({ orgId }: TGetUserOrgPermissionsDTO) => {
|
||||
if (orgId === "") return { permissions: [], membership: null };
|
||||
|
||||
|
@ -46,10 +46,8 @@ export const usePerformSecretApprovalRequestMerge = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{}, {}, TPerformSecretApprovalRequestMerge>({
|
||||
mutationFn: async ({ id, bypassReason }) => {
|
||||
const { data } = await apiRequest.post(`/api/v1/secret-approval-requests/${id}/merge`, {
|
||||
bypassReason
|
||||
});
|
||||
mutationFn: async ({ id }) => {
|
||||
const { data } = await apiRequest.post(`/api/v1/secret-approval-requests/${id}/merge`);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { id, workspaceId }) => {
|
||||
|
@ -133,5 +133,4 @@ export type TUpdateSecretApprovalRequestStatusDTO = {
|
||||
export type TPerformSecretApprovalRequestMerge = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
bypassReason?: string;
|
||||
};
|
||||
|
@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { SecretSharingAccessType, TSharedSecret, TViewSharedSecretResponse } from "./types";
|
||||
import { TSharedSecret, TViewSharedSecretResponse } from "./types";
|
||||
|
||||
export const useGetSharedSecrets = () => {
|
||||
return useQuery({
|
||||
@ -17,7 +17,7 @@ export const useGetSharedSecrets = () => {
|
||||
export const useGetActiveSharedSecretByIdAndHashedHex = (id: string, hashedHex: string) => {
|
||||
return useQuery<TViewSharedSecretResponse, [string]>({
|
||||
queryFn: async () => {
|
||||
if(!id || !hashedHex) return Promise.resolve({ encryptedValue: "", iv: "", tag: "", accessType: SecretSharingAccessType.Organization, orgName: "" });
|
||||
if(!id || !hashedHex) return Promise.resolve({ encryptedValue: "", iv: "", tag: "" });
|
||||
const { data } = await apiRequest.get<TViewSharedSecretResponse>(
|
||||
`/api/v1/secret-sharing/public/${id}?hashedHex=${hashedHex}`
|
||||
);
|
||||
@ -25,8 +25,6 @@ export const useGetActiveSharedSecretByIdAndHashedHex = (id: string, hashedHex:
|
||||
encryptedValue: data.encryptedValue,
|
||||
iv: data.iv,
|
||||
tag: data.tag,
|
||||
accessType: data.accessType,
|
||||
orgName: data.orgName
|
||||
};
|
||||
}
|
||||
});
|
||||
|
@ -13,22 +13,14 @@ export type TCreateSharedSecretRequest = {
|
||||
hashedHex: string;
|
||||
expiresAt: Date;
|
||||
expiresAfterViews: number;
|
||||
accessType: SecretSharingAccessType;
|
||||
};
|
||||
|
||||
export type TViewSharedSecretResponse = {
|
||||
encryptedValue: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
accessType: SecretSharingAccessType;
|
||||
orgName?: string;
|
||||
};
|
||||
|
||||
export type TDeleteSharedSecretRequest = {
|
||||
sharedSecretId: string;
|
||||
};
|
||||
|
||||
export enum SecretSharingAccessType {
|
||||
Anyone = "anyone",
|
||||
Organization = "organization"
|
||||
}
|
20
frontend/src/pages/org/[id]/roles/[roleId]/index.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Head from "next/head";
|
||||
|
||||
import { RolePage } from "@app/views/Org/RolePage";
|
||||
|
||||
export default function Role() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{t("common.head-title", { title: t("settings.org.title") })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
</Head>
|
||||
<RolePage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Role.requireAuth = true;
|
@ -68,7 +68,7 @@ export const IdentitySection = withPermission(
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
|
||||
<div className="flex w-full justify-end pr-4">
|
||||
|
@ -106,8 +106,6 @@ const SIMPLE_PERMISSION_OPTIONS = [
|
||||
export const OrgRoleModifySection = ({ role, onGoBack }: Props) => {
|
||||
const isNonEditable = ["owner", "admin", "member", "no-access"].includes(role?.slug || "");
|
||||
const isNewRole = !role?.slug;
|
||||
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const {
|
||||
|
@ -7,7 +7,7 @@ import { OrgRoleModifySection } from "./OrgRoleModifySection";
|
||||
import { OrgRoleTable } from "./OrgRoleTable";
|
||||
|
||||
export const OrgRoleTabSection = () => {
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["editRole"] as const);
|
||||
const { popUp, handlePopUpClose } = usePopUp(["editRole"] as const);
|
||||
return popUp.editRole.isOpen ? (
|
||||
<motion.div
|
||||
key="role-modify"
|
||||
@ -29,7 +29,7 @@ export const OrgRoleTabSection = () => {
|
||||
animate={{ opacity: 1, translateX: 0 }}
|
||||
exit={{ opacity: 0, translateX: -30 }}
|
||||
>
|
||||
<OrgRoleTable onSelectRole={(role) => handlePopUpOpen("editRole", role)} />
|
||||
<OrgRoleTable />
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
@ -1,14 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { faEdit, faMagnifyingGlass, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useRouter } from "next/router";
|
||||
import { faEllipsis, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
IconButton,
|
||||
Input,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Table,
|
||||
TableContainer,
|
||||
TableSkeleton,
|
||||
@ -22,17 +25,17 @@ import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@a
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteOrgRole, useGetOrgRoles } from "@app/hooks/api";
|
||||
import { TOrgRole } from "@app/hooks/api/roles/types";
|
||||
import { RoleModal } from "@app/views/Org/RolePage/components";
|
||||
|
||||
type Props = {
|
||||
onSelectRole: (role?: TOrgRole) => void;
|
||||
};
|
||||
|
||||
export const OrgRoleTable = ({ onSelectRole }: Props) => {
|
||||
const [searchRoles, setSearchRoles] = useState("");
|
||||
export const OrgRoleTable = () => {
|
||||
const router = useRouter();
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const);
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"role",
|
||||
"deleteRole"
|
||||
] as const);
|
||||
|
||||
const { data: roles, isLoading: isRolesLoading } = useGetOrgRoles(orgId);
|
||||
|
||||
@ -54,100 +57,113 @@ export const OrgRoleTable = ({ onSelectRole }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-4 flex">
|
||||
<div className="mr-4 flex-1">
|
||||
<Input
|
||||
value={searchRoles}
|
||||
onChange={(e) => setSearchRoles(e.target.value)}
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
placeholder="Search roles..."
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="mb-4 flex justify-between">
|
||||
<p className="text-xl font-semibold text-mineshaft-100">Organization Roles</p>
|
||||
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Role}>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
isDisabled={!isAllowed}
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => onSelectRole()}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("role");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Role
|
||||
</Button>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
<div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Slug</Th>
|
||||
<Th aria-label="actions" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isRolesLoading && <TableSkeleton columns={4} innerKey="org-roles" />}
|
||||
{roles?.map((role) => {
|
||||
const { id, name, slug } = role;
|
||||
const isNonMutatable = ["owner", "admin", "member", "no-access"].includes(slug);
|
||||
|
||||
return (
|
||||
<Tr key={`role-list-${id}`}>
|
||||
<Td>{name}</Td>
|
||||
<Td>{slug}</Td>
|
||||
<Td className="flex justify-end">
|
||||
<div className="flex space-x-2">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Slug</Th>
|
||||
<Th aria-label="actions" className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isRolesLoading && <TableSkeleton columns={3} innerKey="org-roles" />}
|
||||
{roles?.map((role) => {
|
||||
const { id, name, slug } = role;
|
||||
const isNonMutatable = ["owner", "admin", "member", "no-access"].includes(slug);
|
||||
return (
|
||||
<Tr
|
||||
key={`role-list-${id}`}
|
||||
className="h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
|
||||
onClick={() => router.push(`/org/${orgId}/roles/${id}`)}
|
||||
>
|
||||
<Td>{name}</Td>
|
||||
<Td>{slug}</Td>
|
||||
<Td>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
||||
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.Role}
|
||||
renderTooltip
|
||||
allowedLabel="Edit"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
isDisabled={!isAllowed}
|
||||
ariaLabel="edit"
|
||||
onClick={() => onSelectRole(role)}
|
||||
variant="plain"
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/org/${orgId}/roles/${id}`);
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
{`${isNonMutatable ? "View" : "Edit"} Role`}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Delete}
|
||||
a={OrgPermissionSubjects.Role}
|
||||
renderTooltip
|
||||
allowedLabel={
|
||||
isNonMutatable ? "Reserved roles are non-removable" : "Delete"
|
||||
}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="delete"
|
||||
onClick={() => handlePopUpOpen("deleteRole", role)}
|
||||
variant="plain"
|
||||
isDisabled={isNonMutatable || !isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} />
|
||||
</IconButton>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
{!isNonMutatable && (
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Delete}
|
||||
a={OrgPermissionSubjects.Role}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
isAllowed
|
||||
? "hover:!bg-red-500 hover:!text-white"
|
||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteRole", role);
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
Delete Role
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<RoleModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteRole.isOpen}
|
||||
title={`Are you sure want to delete ${
|
||||
(popUp?.deleteRole?.data as TOrgRole)?.name || " "
|
||||
} role?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteRole", isOpen)}
|
||||
deleteKey={(popUp?.deleteRole?.data as TOrgRole)?.slug || ""}
|
||||
onClose={() => handlePopUpClose("deleteRole")}
|
||||
onDeleteApproved={handleRoleDelete}
|
||||
|
157
frontend/src/views/Org/RolePage/RolePage.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { useRouter } from "next/router";
|
||||
import { faChevronLeft, faEllipsis } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { withPermission } from "@app/hoc";
|
||||
import { useDeleteOrgRole, useGetOrgRole } from "@app/hooks/api";
|
||||
import { usePopUp } from "@app/hooks/usePopUp";
|
||||
|
||||
import { RoleDetailsSection, RoleModal, RolePermissionsSection } from "./components";
|
||||
|
||||
export const RolePage = withPermission(
|
||||
() => {
|
||||
const router = useRouter();
|
||||
const roleId = router.query.roleId as string;
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const { data } = useGetOrgRole(orgId, roleId);
|
||||
const { mutateAsync: deleteOrgRole } = useDeleteOrgRole();
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"role",
|
||||
"deleteOrgRole"
|
||||
] as const);
|
||||
|
||||
const onDeleteOrgRoleSubmit = async () => {
|
||||
try {
|
||||
if (!orgId || !roleId) return;
|
||||
|
||||
await deleteOrgRole({
|
||||
orgId,
|
||||
id: roleId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully deleted organization role",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpClose("deleteOrgRole");
|
||||
router.push(`/org/${orgId}/members`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text = error?.response?.data?.message ?? "Failed to delete organization role";
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isCustomRole = !["admin", "member", "no-access"].includes(data?.slug ?? "");
|
||||
|
||||
return (
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
{data && (
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
|
||||
<Button
|
||||
variant="link"
|
||||
type="submit"
|
||||
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
|
||||
onClick={() => {
|
||||
router.push(`/org/${orgId}/members`);
|
||||
}}
|
||||
className="mb-4"
|
||||
>
|
||||
Roles
|
||||
</Button>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-3xl font-semibold text-white">{data.name}</p>
|
||||
{isCustomRole && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
||||
<Tooltip content="More options">
|
||||
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Role}>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () => {
|
||||
handlePopUpOpen("role", {
|
||||
roleId
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
Edit Role
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Delete}
|
||||
a={OrgPermissionSubjects.Role}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
isAllowed
|
||||
? "hover:!bg-red-500 hover:!text-white"
|
||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () => {
|
||||
handlePopUpOpen("deleteOrgRole");
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
>
|
||||
Delete Role
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</OrgPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="mr-4 w-96">
|
||||
<RoleDetailsSection roleId={roleId} handlePopUpOpen={handlePopUpOpen} />
|
||||
</div>
|
||||
<RolePermissionsSection roleId={roleId} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<RoleModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteOrgRole.isOpen}
|
||||
title={`Are you sure want to delete the organization role ${data?.name ?? ""}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteOrgRole", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() => onDeleteOrgRoleSubmit()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.Role }
|
||||
);
|
@ -0,0 +1,95 @@
|
||||
import { faCheck, faCopy, faPencil } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { OrgPermissionCan } from "@app/components/permissions";
|
||||
import { IconButton, Tooltip } from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
import { useGetOrgRole } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
roleId: string;
|
||||
handlePopUpOpen: (popUpName: keyof UsePopUpState<["role"]>, data?: {}) => void;
|
||||
};
|
||||
|
||||
export const RoleDetailsSection = ({ roleId, handlePopUpOpen }: Props) => {
|
||||
const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset<string>({
|
||||
initialState: "Copy ID to clipboard"
|
||||
});
|
||||
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const { data } = useGetOrgRole(orgId, roleId);
|
||||
const isCustomRole = !["admin", "member", "no-access"].includes(data?.slug ?? "");
|
||||
|
||||
return data ? (
|
||||
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Details</h3>
|
||||
{isCustomRole && (
|
||||
<OrgPermissionCan I={OrgPermissionActions.Edit} a={OrgPermissionSubjects.Role}>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<Tooltip content="Edit Role">
|
||||
<IconButton
|
||||
isDisabled={!isAllowed}
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative"
|
||||
onClick={() =>
|
||||
handlePopUpOpen("role", {
|
||||
roleId
|
||||
})
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencil} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
</OrgPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Role ID</p>
|
||||
<div className="group flex align-top">
|
||||
<p className="text-sm text-mineshaft-300">{roleId}</p>
|
||||
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<Tooltip content={copyTextId}>
|
||||
<IconButton
|
||||
ariaLabel="copy icon"
|
||||
variant="plain"
|
||||
className="group relative ml-2"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(roleId);
|
||||
setCopyTextId("Copied");
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={isCopyingId ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Name</p>
|
||||
<p className="text-sm text-mineshaft-300">{data.name}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Slug</p>
|
||||
<p className="text-sm text-mineshaft-300">{data.slug}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-semibold text-mineshaft-300">Description</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
{data.description?.length ? data.description : "-"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
);
|
||||
};
|
200
frontend/src/views/Org/RolePage/components/RoleModal.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useRouter } from "next/router";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useCreateOrgRole, useGetOrgRole, useUpdateOrgRole } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
slug: z.string()
|
||||
})
|
||||
.required();
|
||||
|
||||
export type FormData = z.infer<typeof schema>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["role"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["role"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const RoleModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const router = useRouter();
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const popupData = popUp?.role?.data as {
|
||||
roleId: string;
|
||||
};
|
||||
|
||||
const { data: role } = useGetOrgRole(orgId, popupData?.roleId ?? "");
|
||||
|
||||
const { mutateAsync: createOrgRole } = useCreateOrgRole();
|
||||
const { mutateAsync: updateOrgRole } = useUpdateOrgRole();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: ""
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (role) {
|
||||
reset({
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
slug: role.slug
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
name: "",
|
||||
description: "",
|
||||
slug: ""
|
||||
});
|
||||
}
|
||||
}, [role]);
|
||||
|
||||
const onFormSubmit = async ({ name, description, slug }: FormData) => {
|
||||
try {
|
||||
console.log("onFormSubmit args: ", {
|
||||
name,
|
||||
description,
|
||||
slug
|
||||
});
|
||||
|
||||
if (!orgId) return;
|
||||
|
||||
if (role) {
|
||||
// update
|
||||
|
||||
await updateOrgRole({
|
||||
orgId,
|
||||
id: role.id,
|
||||
name,
|
||||
description,
|
||||
slug
|
||||
});
|
||||
|
||||
handlePopUpToggle("role", false);
|
||||
} else {
|
||||
// create
|
||||
|
||||
const newRole = await createOrgRole({
|
||||
orgId,
|
||||
name,
|
||||
description,
|
||||
slug,
|
||||
permissions: []
|
||||
});
|
||||
|
||||
handlePopUpToggle("role", false);
|
||||
router.push(`/org/${orgId}/roles/${newRole.id}`);
|
||||
}
|
||||
|
||||
createNotification({
|
||||
text: `Successfully ${popUp?.role?.data ? "updated" : "created"} role`,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
reset();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = err as any;
|
||||
const text =
|
||||
error?.response?.data?.message ??
|
||||
`Failed to ${popUp?.role?.data ? "update" : "create"} role`;
|
||||
|
||||
createNotification({
|
||||
text,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.role?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("role", isOpen);
|
||||
reset();
|
||||
}}
|
||||
>
|
||||
<ModalContent title={`${popUp?.role?.data ? "Update" : "Create"} Role`}>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="name"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Name"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
isRequired
|
||||
>
|
||||
<Input {...field} placeholder="Billing Team" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="slug"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Slug"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
isRequired
|
||||
>
|
||||
<Input {...field} placeholder="billing" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name="description"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Description" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} placeholder="To manage billing" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{popUp?.role?.data ? "Update" : "Create"}
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpToggle("role", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
@ -0,0 +1,215 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
|
||||
import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { TFormSchema } from "@app/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils";
|
||||
|
||||
const PERMISSIONS = [
|
||||
{ action: "read", label: "View" },
|
||||
{ action: "create", label: "Create" },
|
||||
{ action: "edit", label: "Modify" },
|
||||
{ action: "delete", label: "Remove" }
|
||||
] as const;
|
||||
|
||||
const SECRET_SCANNING_PERMISSIONS = [
|
||||
{ action: "read", label: "View risks" },
|
||||
{ action: "create", label: "Add integrations" },
|
||||
{ action: "edit", label: "Edit risk status" },
|
||||
{ action: "delete", label: "Remove integrations" }
|
||||
] as const;
|
||||
|
||||
const INCIDENT_CONTACTS_PERMISSIONS = [
|
||||
{ action: "read", label: "View contacts" },
|
||||
{ action: "create", label: "Add new contacts" },
|
||||
{ action: "edit", label: "Edit contacts" },
|
||||
{ action: "delete", label: "Remove contacts" }
|
||||
] as const;
|
||||
|
||||
const MEMBERS_PERMISSIONS = [
|
||||
{ action: "read", label: "View all members" },
|
||||
{ action: "create", label: "Invite members" },
|
||||
{ action: "edit", label: "Edit members" },
|
||||
{ action: "delete", label: "Remove members" }
|
||||
] as const;
|
||||
|
||||
const BILLING_PERMISSIONS = [
|
||||
{ action: "read", label: "View bills" },
|
||||
{ action: "create", label: "Add payment methods" },
|
||||
{ action: "edit", label: "Edit payments" },
|
||||
{ action: "delete", label: "Remove payments" }
|
||||
] as const;
|
||||
|
||||
const getPermissionList = (option: string) => {
|
||||
switch (option) {
|
||||
case "secret-scanning":
|
||||
return SECRET_SCANNING_PERMISSIONS;
|
||||
case "billing":
|
||||
return BILLING_PERMISSIONS;
|
||||
case "incident-contact":
|
||||
return INCIDENT_CONTACTS_PERMISSIONS;
|
||||
case "member":
|
||||
return MEMBERS_PERMISSIONS;
|
||||
default:
|
||||
return PERMISSIONS;
|
||||
}
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isEditable: boolean;
|
||||
title: string;
|
||||
formName: keyof Omit<Exclude<TFormSchema["permissions"], undefined>, "workspace">;
|
||||
setValue: UseFormSetValue<TFormSchema>;
|
||||
control: Control<TFormSchema>;
|
||||
};
|
||||
|
||||
enum Permission {
|
||||
NoAccess = "no-access",
|
||||
ReadOnly = "read-only",
|
||||
FullAccess = "full-acess",
|
||||
Custom = "custom"
|
||||
}
|
||||
|
||||
export const RolePermissionRow = ({ isEditable, title, formName, control, setValue }: Props) => {
|
||||
const [isRowExpanded, setIsRowExpanded] = useToggle();
|
||||
const [isCustom, setIsCustom] = useToggle();
|
||||
|
||||
const rule = useWatch({
|
||||
control,
|
||||
name: `permissions.${formName}`
|
||||
});
|
||||
|
||||
const selectedPermissionCategory = useMemo(() => {
|
||||
const actions = Object.keys(rule || {}) as Array<keyof typeof rule>;
|
||||
const totalActions = PERMISSIONS.length;
|
||||
const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number);
|
||||
|
||||
if (isCustom) return Permission.Custom;
|
||||
if (score === 0) return Permission.NoAccess;
|
||||
if (score === totalActions) return Permission.FullAccess;
|
||||
if (score === 1 && rule?.read) return Permission.ReadOnly;
|
||||
|
||||
return Permission.Custom;
|
||||
}, [rule, isCustom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPermissionCategory === Permission.Custom) setIsCustom.on();
|
||||
else setIsCustom.off();
|
||||
}, [selectedPermissionCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
const isRowCustom = selectedPermissionCategory === Permission.Custom;
|
||||
if (isRowCustom) {
|
||||
setIsRowExpanded.on();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePermissionChange = (val: Permission) => {
|
||||
if (val === Permission.Custom) {
|
||||
setIsRowExpanded.on();
|
||||
setIsCustom.on();
|
||||
return;
|
||||
}
|
||||
setIsCustom.off();
|
||||
|
||||
switch (val) {
|
||||
case Permission.NoAccess:
|
||||
setValue(
|
||||
`permissions.${formName}`,
|
||||
{ read: false, edit: false, create: false, delete: false },
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
break;
|
||||
case Permission.FullAccess:
|
||||
setValue(
|
||||
`permissions.${formName}`,
|
||||
{ read: true, edit: true, create: true, delete: true },
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
break;
|
||||
case Permission.ReadOnly:
|
||||
setValue(
|
||||
`permissions.${formName}`,
|
||||
{ read: true, edit: false, create: false, delete: false },
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
break;
|
||||
default:
|
||||
setValue(
|
||||
`permissions.${formName}`,
|
||||
{ read: false, edit: false, create: false, delete: false },
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tr
|
||||
className="h-10 cursor-pointer transition-colors duration-300 hover:bg-mineshaft-700"
|
||||
onClick={() => setIsRowExpanded.toggle()}
|
||||
>
|
||||
<Td>
|
||||
<FontAwesomeIcon icon={isRowExpanded ? faChevronDown : faChevronRight} />
|
||||
</Td>
|
||||
<Td>{title}</Td>
|
||||
<Td>
|
||||
<Select
|
||||
value={selectedPermissionCategory}
|
||||
className="w-40 bg-mineshaft-600"
|
||||
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
|
||||
onValueChange={handlePermissionChange}
|
||||
isDisabled={!isEditable}
|
||||
>
|
||||
<SelectItem value={Permission.NoAccess}>No Access</SelectItem>
|
||||
<SelectItem value={Permission.ReadOnly}>Read Only</SelectItem>
|
||||
<SelectItem value={Permission.FullAccess}>Full Access</SelectItem>
|
||||
<SelectItem value={Permission.Custom}>Custom</SelectItem>
|
||||
</Select>
|
||||
</Td>
|
||||
</Tr>
|
||||
{isRowExpanded && (
|
||||
<Tr>
|
||||
<Td
|
||||
colSpan={3}
|
||||
className={`bg-bunker-600 px-0 py-0 ${isRowExpanded && " border-mineshaft-500 p-8"}`}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{getPermissionList(formName).map(({ action, label }) => {
|
||||
return (
|
||||
<Controller
|
||||
name={`permissions.${formName}.${action}`}
|
||||
key={`permissions.${formName}.${action}`}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
isChecked={field.value}
|
||||
onCheckedChange={(e) => {
|
||||
if (!isEditable) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to update default role"
|
||||
});
|
||||
return;
|
||||
}
|
||||
field.onChange(e);
|
||||
}}
|
||||
id={`permissions.${formName}.${action}`}
|
||||
>
|
||||
{label}
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -0,0 +1,162 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button , Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { useGetOrgRole, useUpdateOrgRole } from "@app/hooks/api";
|
||||
import {
|
||||
formRolePermission2API,
|
||||
formSchema,
|
||||
rolePermission2Form,
|
||||
TFormSchema
|
||||
} from "@app/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/OrgRoleModifySection.utils";
|
||||
|
||||
import { RolePermissionRow } from "./RolePermissionRow";
|
||||
|
||||
const SIMPLE_PERMISSION_OPTIONS = [
|
||||
{
|
||||
title: "User management",
|
||||
formName: "member"
|
||||
},
|
||||
{
|
||||
title: "Group management",
|
||||
formName: "groups"
|
||||
},
|
||||
{
|
||||
title: "Machine identity management",
|
||||
formName: "identity"
|
||||
},
|
||||
{
|
||||
title: "Billing & usage",
|
||||
formName: "billing"
|
||||
},
|
||||
{
|
||||
title: "Role management",
|
||||
formName: "role"
|
||||
},
|
||||
{
|
||||
title: "Incident Contacts",
|
||||
formName: "incident-contact"
|
||||
},
|
||||
{
|
||||
title: "Organization profile",
|
||||
formName: "settings"
|
||||
},
|
||||
{
|
||||
title: "Secret Scanning",
|
||||
formName: "secret-scanning"
|
||||
},
|
||||
{
|
||||
title: "SSO",
|
||||
formName: "sso"
|
||||
},
|
||||
{
|
||||
title: "LDAP",
|
||||
formName: "ldap"
|
||||
},
|
||||
{
|
||||
title: "SCIM",
|
||||
formName: "scim"
|
||||
}
|
||||
] as const;
|
||||
|
||||
type Props = {
|
||||
roleId: string;
|
||||
};
|
||||
|
||||
export const RolePermissionsSection = ({ roleId }: Props) => {
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
|
||||
const { data: role } = useGetOrgRole(orgId, roleId);
|
||||
|
||||
const {
|
||||
setValue,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { isDirty, isSubmitting },
|
||||
reset
|
||||
} = useForm<TFormSchema>({
|
||||
defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {},
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
|
||||
const { mutateAsync: updateRole } = useUpdateOrgRole();
|
||||
|
||||
const onSubmit = async (el: TFormSchema) => {
|
||||
try {
|
||||
await updateRole({
|
||||
orgId,
|
||||
id: roleId,
|
||||
...el,
|
||||
permissions: formRolePermission2API(el.permissions)
|
||||
});
|
||||
createNotification({ type: "success", text: "Successfully updated role" });
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({ type: "error", text: "Failed to update role" });
|
||||
}
|
||||
};
|
||||
|
||||
const isCustomRole = !["admin", "member", "no-access"].includes(role?.slug ?? "");
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
|
||||
<h3 className="text-lg font-semibold text-mineshaft-100">Permissions</h3>
|
||||
{isCustomRole && (
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
type="submit"
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
className="ml-4 text-mineshaft-300"
|
||||
variant="link"
|
||||
isDisabled={isSubmitting || !isDirty}
|
||||
isLoading={isSubmitting}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th className="w-5" />
|
||||
<Th>Resource</Th>
|
||||
<Th>Permission</Th>
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{SIMPLE_PERMISSION_OPTIONS.map((permission) => {
|
||||
return (
|
||||
<RolePermissionRow
|
||||
title={permission.title}
|
||||
formName={permission.formName}
|
||||
control={control}
|
||||
setValue={setValue}
|
||||
key={`org-role-${roleId}-permission-${permission.formName}`}
|
||||
isEditable={isCustomRole}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export { RolePermissionsSection } from "./RolePermissionsSection";
|
3
frontend/src/views/Org/RolePage/components/index.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export { RoleDetailsSection } from "./RoleDetailsSection";
|
||||
export { RoleModal } from "./RoleModal";
|
||||
export { RolePermissionsSection } from "./RolePermissionsSection";
|
1
frontend/src/views/Org/RolePage/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { RolePage } from "./RolePage";
|
@ -6,13 +6,12 @@ import {
|
||||
faLockOpen,
|
||||
faSquareCheck,
|
||||
faSquareXmark,
|
||||
faTriangleExclamation,
|
||||
faUserLock} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, Checkbox, FormControl, Input } from "@app/components/v2";
|
||||
import { Button, Checkbox } from "@app/components/v2";
|
||||
import {
|
||||
usePerformSecretApprovalRequestMerge,
|
||||
useUpdateSecretApprovalRequestStatus
|
||||
@ -49,19 +48,12 @@ export const SecretApprovalRequestAction = ({
|
||||
useUpdateSecretApprovalRequestStatus();
|
||||
|
||||
const [byPassApproval, setByPassApproval] = useState(false);
|
||||
const [bypassReason, setBypassReason] = useState("");
|
||||
|
||||
const isValidBypassReason = (value: string) => {
|
||||
const trimmedValue = value.trim();
|
||||
return trimmedValue.length >= 10;
|
||||
};
|
||||
|
||||
const handleSecretApprovalRequestMerge = async () => {
|
||||
try {
|
||||
await performSecretApprovalMerge({
|
||||
id: approvalRequestId,
|
||||
workspaceId,
|
||||
bypassReason: byPassApproval ? bypassReason : undefined
|
||||
workspaceId
|
||||
});
|
||||
createNotification({
|
||||
type: "success",
|
||||
@ -100,7 +92,7 @@ export const SecretApprovalRequestAction = ({
|
||||
|
||||
if (!hasMerged && status === "open") {
|
||||
return (
|
||||
<div className="flex w-full items-start justify-between transition-all">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-start space-x-4">
|
||||
<FontAwesomeIcon
|
||||
icon={isMergable ? faSquareCheck : faSquareXmark}
|
||||
@ -113,33 +105,18 @@ export const SecretApprovalRequestAction = ({
|
||||
{Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`}
|
||||
</span>
|
||||
{!canApprove && isSoftEnforcement && (
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
<div className="mt-1">
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) => setByPassApproval(checked === true)}
|
||||
isChecked={byPassApproval}
|
||||
id="byPassApproval"
|
||||
checkIndicatorBg="text-white"
|
||||
className={twMerge("mr-2", byPassApproval ? "bg-red hover:bg-red-600 border-red" : "")}
|
||||
className={byPassApproval ? "bg-red hover:bg-red-600 border-red" : ""}
|
||||
>
|
||||
<span className="text-red text-xs">
|
||||
<span className="text-red text-sm">
|
||||
Merge without waiting for approval (bypass secret change policy)
|
||||
</span>
|
||||
</Checkbox>
|
||||
{byPassApproval && (
|
||||
<FormControl
|
||||
label="Reason for bypass"
|
||||
className="mt-2"
|
||||
isRequired
|
||||
tooltipText="Enter a reason for bypassing the secret change policy"
|
||||
>
|
||||
<Input
|
||||
value={bypassReason}
|
||||
onChange={(e) => setBypassReason(e.target.value)}
|
||||
placeholder="Enter reason for bypass (min 10 chars)"
|
||||
leftIcon={<FontAwesomeIcon icon={faTriangleExclamation} />}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
@ -160,7 +137,7 @@ export const SecretApprovalRequestAction = ({
|
||||
leftIcon={<FontAwesomeIcon icon={!canApprove ? faLandMineOn : faCheck} />}
|
||||
isDisabled={
|
||||
(!isMergable && canApprove)
|
||||
|| (!canApprove && isSoftEnforcement && (!byPassApproval || !isValidBypassReason(bypassReason)))
|
||||
|| (!canApprove && isSoftEnforcement && !byPassApproval)
|
||||
}
|
||||
isLoading={isMerging}
|
||||
onClick={handleSecretApprovalRequestMerge}
|
||||
|
@ -161,7 +161,6 @@ export const SecretOverviewTableRow = ({
|
||||
secretPath={secretPath}
|
||||
getSecretByKey={getSecretByKey}
|
||||
/>
|
||||
|
||||
<TableContainer>
|
||||
<table className="secret-table">
|
||||
<thead>
|
||||
|
@ -1,6 +1,5 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { AxiosError } from "axios";
|
||||
import * as yup from "yup";
|
||||
@ -8,14 +7,13 @@ import * as yup from "yup";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { encryptSymmetric } from "@app/components/utilities/cryptography/crypto";
|
||||
import { Button, Checkbox, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2";
|
||||
import { SecretSharingAccessType, useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api/secretSharing";
|
||||
import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api/secretSharing";
|
||||
|
||||
const schema = yup.object({
|
||||
value: yup.string().max(10000).required().label("Shared Secret Value"),
|
||||
expiresAfterSingleView: yup.boolean().required().label("Expires After Views"),
|
||||
expiresInValue: yup.number().min(1).required().label("Expiration Value"),
|
||||
expiresInUnit: yup.string().required().label("Expiration Unit"),
|
||||
accessType: yup.string().required().label("General Access")
|
||||
expiresInUnit: yup.string().required().label("Expiration Unit")
|
||||
});
|
||||
|
||||
export type FormData = yup.InferType<typeof schema>;
|
||||
@ -37,14 +35,6 @@ export const AddShareSecretForm = ({
|
||||
setNewSharedSecret: (value: string) => void;
|
||||
isInputDisabled?: boolean;
|
||||
}) => {
|
||||
const isMounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const publicSharedSecretCreator = useCreatePublicSharedSecret();
|
||||
const privateSharedSecretCreator = useCreateSharedSecret();
|
||||
const createSharedSecret = isPublic ? publicSharedSecretCreator : privateSharedSecretCreator;
|
||||
@ -75,8 +65,7 @@ export const AddShareSecretForm = ({
|
||||
value,
|
||||
expiresInValue,
|
||||
expiresInUnit,
|
||||
expiresAfterSingleView,
|
||||
accessType
|
||||
expiresAfterSingleView
|
||||
}: FormData) => {
|
||||
try {
|
||||
const key = crypto.randomBytes(16).toString("hex");
|
||||
@ -100,21 +89,18 @@ export const AddShareSecretForm = ({
|
||||
tag,
|
||||
hashedHex,
|
||||
expiresAt,
|
||||
expiresAfterViews: expiresAfterSingleView ? 1 : 1000,
|
||||
accessType: accessType as SecretSharingAccessType
|
||||
expiresAfterViews: expiresAfterSingleView ? 1 : 1000
|
||||
});
|
||||
setNewSharedSecret(
|
||||
`${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(
|
||||
hashedHex
|
||||
)}-${encodeURIComponent(key)}`
|
||||
);
|
||||
|
||||
if (isMounted.current) {
|
||||
setNewSharedSecret(
|
||||
`${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent(
|
||||
hashedHex
|
||||
)}-${encodeURIComponent(key)}`
|
||||
);
|
||||
createNotification({
|
||||
text: "Successfully created a shared secret",
|
||||
type: "success"
|
||||
});
|
||||
}
|
||||
createNotification({
|
||||
text: "Successfully created a shared secret",
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const axiosError = err as AxiosError;
|
||||
@ -157,7 +143,7 @@ export const AddShareSecretForm = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col md:flex-row justify-stretch">
|
||||
<div className="flex w-full flex-col md:flex-row justify-start">
|
||||
<div className="flex justify-start">
|
||||
<div className="flex justify-start">
|
||||
<div className="flex w-full justify-center pr-2">
|
||||
@ -202,8 +188,8 @@ export const AddShareSecretForm = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:w-1/7 mx-auto items-center justify-center hidden md:flex">
|
||||
<p className="mt-2 text-sm text-gray-400">AND</p>
|
||||
<div className="sm:w-1/7 mx-auto items-center justify-center px-6 hidden md:flex">
|
||||
<p className="px-4 mt-2 text-sm text-gray-400">AND</p>
|
||||
</div>
|
||||
<div className="items-center pb-4 md:pb-0 md:pt-2 flex">
|
||||
<Controller
|
||||
@ -241,25 +227,7 @@ export const AddShareSecretForm = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!isPublic && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="accessType"
|
||||
defaultValue="organization"
|
||||
render={({ field: { onChange, ...field } }) => (
|
||||
<FormControl label="General Access">
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
>
|
||||
<SelectItem value="organization">People within your organization</SelectItem>
|
||||
<SelectItem value="anyone">Anyone</SelectItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className={`flex items-center space-x-4 pt-2 ${!inModal && ""}`}>
|
||||
<div className={`flex items-center ${!inModal && "justify-start pt-2"}`}>
|
||||
<Button className="mr-0" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
|
||||
{inModal ? "Create" : "Share Secret"}
|
||||
</Button>
|
||||
|
@ -20,14 +20,12 @@ export const ShareSecretPublicPage = ({ isNewSession }: { isNewSession: boolean
|
||||
const [hashedHex, key] = urlEncodedPublicKey
|
||||
? urlEncodedPublicKey.toString().split("-")
|
||||
: ["", ""];
|
||||
|
||||
|
||||
const publicKey = decodeURIComponent(urlEncodedPublicKey as string);
|
||||
const { isLoading, data } = useGetActiveSharedSecretByIdAndHashedHex(
|
||||
id as string,
|
||||
hashedHex as string
|
||||
);
|
||||
const accessType = data?.accessType;
|
||||
const orgName = data?.orgName;
|
||||
|
||||
const decryptedSecret = useMemo(() => {
|
||||
if (data && data.encryptedValue && publicKey) {
|
||||
@ -89,8 +87,6 @@ export const ShareSecretPublicPage = ({ isNewSession }: { isNewSession: boolean
|
||||
decryptedSecret={decryptedSecret}
|
||||
isUrlCopied={isUrlCopied}
|
||||
copyUrlToClipboard={copyUrlToClipboard}
|
||||
accessType={accessType}
|
||||
orgName={orgName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -1,17 +1,14 @@
|
||||
import { faArrowRight, faCheck, faCopy, faEye, faEyeSlash, faKey } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faCheck, faCopy, faEye, faEyeSlash, faKey } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Button, EmptyState, IconButton, Td, Tr } from "@app/components/v2";
|
||||
import { EmptyState, IconButton, Td, Tr } from "@app/components/v2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { SecretSharingAccessType } from "@app/hooks/api/secretSharing/types";
|
||||
|
||||
type Props = {
|
||||
isLoading: boolean;
|
||||
decryptedSecret: string;
|
||||
isUrlCopied: boolean;
|
||||
copyUrlToClipboard: () => void;
|
||||
accessType?: SecretSharingAccessType;
|
||||
orgName?: string;
|
||||
};
|
||||
|
||||
const replaceContentWithDot = (str: string) => {
|
||||
@ -27,49 +24,20 @@ export const SecretTable = ({
|
||||
isLoading,
|
||||
decryptedSecret,
|
||||
isUrlCopied,
|
||||
copyUrlToClipboard,
|
||||
accessType,
|
||||
orgName
|
||||
copyUrlToClipboard
|
||||
}: Props) => {
|
||||
const [isVisible, setIsVisible] = useToggle(false);
|
||||
const title = orgName
|
||||
? (<p>Someone from <strong>{orgName}</strong> organization has shared a secret with you</p>)
|
||||
: (<p>You need to be logged in to view this secret</p>);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center rounded-md border border-solid border-mineshaft-700 bg-mineshaft-800 p-2">
|
||||
{isLoading && <div className="bg-mineshaft-800 text-center text-bunker-400">Loading...</div>}
|
||||
{!isLoading && !decryptedSecret && accessType !== SecretSharingAccessType.Organization && (
|
||||
{!isLoading && !decryptedSecret && (
|
||||
<Tr>
|
||||
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
|
||||
<EmptyState title="Secret has either expired or does not exist!" icon={faKey} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{!isLoading && !decryptedSecret && accessType === SecretSharingAccessType.Organization && (
|
||||
<Tr>
|
||||
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-4000">
|
||||
<EmptyState title={title} icon={faKey}>
|
||||
<div className="flex flex-1 flex-col items-center justify-center pt-6">
|
||||
<a
|
||||
href="/login"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
colorSchema="primary"
|
||||
size="sm"
|
||||
onClick={() => {}}
|
||||
rightIcon={<FontAwesomeIcon icon={faArrowRight} className="ml-2" />}
|
||||
>
|
||||
Login into <strong>{orgName}</strong> to view this secret
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</EmptyState>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
{!isLoading && decryptedSecret && (
|
||||
<div className="dark relative flex h-full w-full items-center overflow-y-auto rounded-md border border-mineshaft-700 bg-mineshaft-900 p-2 pr-2 md:p-3">
|
||||
<div
|
||||
|