Revert "Permission phase 2"

This commit is contained in:
Maidul Islam
2024-10-17 17:37:41 -04:00
committed by GitHub
parent 3d938ea62f
commit 8b9244b079
100 changed files with 1568 additions and 3353 deletions
@@ -56,10 +56,7 @@ describe("Secret expansion", () => {
} }
]; ];
for (const secret of secrets) { await Promise.all(secrets.map((el) => createSecretV2(el)));
// eslint-disable-next-line no-await-in-loop
await createSecretV2(secret);
}
const expandedSecret = await getSecretByNameV2({ const expandedSecret = await getSecretByNameV2({
environmentSlug: seedData1.environment.slug, environmentSlug: seedData1.environment.slug,
@@ -126,10 +123,7 @@ describe("Secret expansion", () => {
} }
]; ];
for (const secret of secrets) { await Promise.all(secrets.map((el) => createSecretV2(el)));
// eslint-disable-next-line no-await-in-loop
await createSecretV2(secret);
}
const expandedSecret = await getSecretByNameV2({ const expandedSecret = await getSecretByNameV2({
environmentSlug: seedData1.environment.slug, environmentSlug: seedData1.environment.slug,
@@ -196,11 +190,7 @@ describe("Secret expansion", () => {
} }
]; ];
for (const secret of secrets) { await Promise.all(secrets.map((el) => createSecretV2(el)));
// eslint-disable-next-line no-await-in-loop
await createSecretV2(secret);
}
const secretImportFromProdToDev = await createSecretImport({ const secretImportFromProdToDev = await createSecretImport({
environmentSlug: seedData1.environment.slug, environmentSlug: seedData1.environment.slug,
workspaceId: projectId, workspaceId: projectId,
@@ -285,11 +275,7 @@ describe("Secret expansion", () => {
} }
]; ];
for (const secret of secrets) { await Promise.all(secrets.map((el) => createSecretV2(el)));
// eslint-disable-next-line no-await-in-loop
await createSecretV2(secret);
}
const secretImportFromProdToDev = await createSecretImport({ const secretImportFromProdToDev = await createSecretImport({
environmentSlug: seedData1.environment.slug, environmentSlug: seedData1.environment.slug,
workspaceId: projectId, workspaceId: projectId,
+266 -348
View File
File diff suppressed because it is too large Load Diff
@@ -4,40 +4,27 @@ import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> { export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.SecretSharing)) { if (await knex.schema.hasTable(TableName.SecretSharing)) {
const hasEncryptedSecret = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSecret");
const hasIdentifier = await knex.schema.hasColumn(TableName.SecretSharing, "identifier");
await knex.schema.alterTable(TableName.SecretSharing, (t) => { await knex.schema.alterTable(TableName.SecretSharing, (t) => {
t.string("iv").nullable().alter(); t.string("iv").nullable().alter();
t.string("tag").nullable().alter(); t.string("tag").nullable().alter();
t.string("encryptedValue").nullable().alter(); t.string("encryptedValue").nullable().alter();
if (!hasEncryptedSecret) { t.binary("encryptedSecret").nullable();
t.binary("encryptedSecret").nullable();
}
t.string("hashedHex").nullable().alter(); t.string("hashedHex").nullable().alter();
if (!hasIdentifier) { t.string("identifier", 64).nullable();
t.string("identifier", 64).nullable(); t.unique("identifier");
t.unique("identifier"); t.index("identifier");
t.index("identifier");
}
}); });
} }
} }
export async function down(knex: Knex): Promise<void> { export async function down(knex: Knex): Promise<void> {
const hasEncryptedSecret = await knex.schema.hasColumn(TableName.SecretSharing, "encryptedSecret");
const hasIdentifier = await knex.schema.hasColumn(TableName.SecretSharing, "identifier");
if (await knex.schema.hasTable(TableName.SecretSharing)) { if (await knex.schema.hasTable(TableName.SecretSharing)) {
await knex.schema.alterTable(TableName.SecretSharing, (t) => { await knex.schema.alterTable(TableName.SecretSharing, (t) => {
if (hasEncryptedSecret) { t.dropColumn("encryptedSecret");
t.dropColumn("encryptedSecret");
}
if (hasIdentifier) { t.dropColumn("identifier");
t.dropColumn("identifier");
}
}); });
} }
} }
@@ -7,18 +7,15 @@ export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.KmsKey)) { if (await knex.schema.hasTable(TableName.KmsKey)) {
const hasOrgId = await knex.schema.hasColumn(TableName.KmsKey, "orgId"); const hasOrgId = await knex.schema.hasColumn(TableName.KmsKey, "orgId");
const hasSlug = await knex.schema.hasColumn(TableName.KmsKey, "slug"); const hasSlug = await knex.schema.hasColumn(TableName.KmsKey, "slug");
const hasProjectId = await knex.schema.hasColumn(TableName.KmsKey, "projectId");
// drop constraint if exists (won't exist if rolled back, see below) // drop constraint if exists (won't exist if rolled back, see below)
await dropConstraintIfExists(TableName.KmsKey, "kms_keys_orgid_slug_unique", knex); await dropConstraintIfExists(TableName.KmsKey, "kms_keys_orgid_slug_unique", knex);
// projectId for CMEK functionality // projectId for CMEK functionality
await knex.schema.alterTable(TableName.KmsKey, (table) => { await knex.schema.alterTable(TableName.KmsKey, (table) => {
if (!hasProjectId) { table.string("projectId").nullable().references("id").inTable(TableName.Project).onDelete("CASCADE");
table.string("projectId").nullable().references("id").inTable(TableName.Project).onDelete("CASCADE");
}
if (hasOrgId && hasSlug) { if (hasOrgId) {
table.unique(["orgId", "projectId", "slug"]); table.unique(["orgId", "projectId", "slug"]);
} }
@@ -33,7 +30,6 @@ export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.KmsKey)) { if (await knex.schema.hasTable(TableName.KmsKey)) {
const hasOrgId = await knex.schema.hasColumn(TableName.KmsKey, "orgId"); const hasOrgId = await knex.schema.hasColumn(TableName.KmsKey, "orgId");
const hasName = await knex.schema.hasColumn(TableName.KmsKey, "name"); const hasName = await knex.schema.hasColumn(TableName.KmsKey, "name");
const hasProjectId = await knex.schema.hasColumn(TableName.KmsKey, "projectId");
// remove projectId for CMEK functionality // remove projectId for CMEK functionality
await knex.schema.alterTable(TableName.KmsKey, (table) => { await knex.schema.alterTable(TableName.KmsKey, (table) => {
@@ -44,9 +40,7 @@ export async function down(knex: Knex): Promise<void> {
if (hasOrgId) { if (hasOrgId) {
table.dropUnique(["orgId", "projectId", "slug"]); table.dropUnique(["orgId", "projectId", "slug"]);
} }
if (hasProjectId) { table.dropColumn("projectId");
table.dropColumn("projectId");
}
}); });
} }
} }
@@ -1,101 +0,0 @@
/* eslint-disable no-await-in-loop */
import { packRules, unpackRules } from "@casl/ability/extra";
import { Knex } from "knex";
import {
backfillPermissionV1SchemaToV2Schema,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { TableName } from "../schemas";
const CHUNK_SIZE = 1000;
export async function up(knex: Knex): Promise<void> {
const hasVersion = await knex.schema.hasColumn(TableName.ProjectRoles, "version");
if (!hasVersion) {
await knex.schema.alterTable(TableName.ProjectRoles, (t) => {
t.integer("version").defaultTo(1).notNullable();
});
const docs = await knex(TableName.ProjectRoles).select("*");
const updatedDocs = docs
.filter((i) => {
const permissionString = JSON.stringify(i.permissions || []);
return (
!permissionString.includes(ProjectPermissionSub.SecretImports) &&
!permissionString.includes(ProjectPermissionSub.DynamicSecrets)
);
})
.map((el) => ({
...el,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error this is valid ts
permissions: JSON.stringify(packRules(backfillPermissionV1SchemaToV2Schema(unpackRules(el.permissions))))
}));
if (updatedDocs.length) {
for (let i = 0; i < updatedDocs.length; i += CHUNK_SIZE) {
const chunk = updatedDocs.slice(i, i + CHUNK_SIZE);
await knex(TableName.ProjectRoles).insert(chunk).onConflict("id").merge();
}
}
// secret permission is split into multiple ones like secrets, folders, imports and dynamic-secrets
// so we just find all the privileges with respective mapping and map it as needed
const identityPrivileges = await knex(TableName.IdentityProjectAdditionalPrivilege).select("*");
const updatedIdentityPrivilegesDocs = identityPrivileges
.filter((i) => {
const permissionString = JSON.stringify(i.permissions || []);
return (
!permissionString.includes(ProjectPermissionSub.SecretImports) &&
!permissionString.includes(ProjectPermissionSub.DynamicSecrets) &&
!permissionString.includes(ProjectPermissionSub.SecretFolders)
);
})
.map((el) => ({
...el,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error this is valid ts
permissions: JSON.stringify(packRules(backfillPermissionV1SchemaToV2Schema(unpackRules(el.permissions))))
}));
if (updatedIdentityPrivilegesDocs.length) {
for (let i = 0; i < updatedIdentityPrivilegesDocs.length; i += CHUNK_SIZE) {
const chunk = updatedIdentityPrivilegesDocs.slice(i, i + CHUNK_SIZE);
await knex(TableName.IdentityProjectAdditionalPrivilege).insert(chunk).onConflict("id").merge();
}
}
const userPrivileges = await knex(TableName.ProjectUserAdditionalPrivilege).select("*");
const updatedUserPrivilegeDocs = userPrivileges
.filter((i) => {
const permissionString = JSON.stringify(i.permissions || []);
return (
!permissionString.includes(ProjectPermissionSub.SecretImports) &&
!permissionString.includes(ProjectPermissionSub.DynamicSecrets) &&
!permissionString.includes(ProjectPermissionSub.SecretFolders)
);
})
.map((el) => ({
...el,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error this is valid ts
permissions: JSON.stringify(packRules(backfillPermissionV1SchemaToV2Schema(unpackRules(el.permissions))))
}));
if (docs.length) {
for (let i = 0; i < updatedUserPrivilegeDocs.length; i += CHUNK_SIZE) {
const chunk = updatedUserPrivilegeDocs.slice(i, i + CHUNK_SIZE);
await knex(TableName.ProjectUserAdditionalPrivilege).insert(chunk).onConflict("id").merge();
}
}
}
}
export async function down(knex: Knex): Promise<void> {
const hasVersion = await knex.schema.hasColumn(TableName.ProjectRoles, "version");
if (hasVersion) {
await knex.schema.alterTable(TableName.ProjectRoles, (t) => {
t.dropColumn("version");
});
// permission change can be ignored
}
}
@@ -4,7 +4,6 @@ import ms from "ms";
import { z } from "zod"; import { z } from "zod";
import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types"; import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types";
import { backfillPermissionV1SchemaToV2Schema } from "@app/ee/services/permission/project-permission";
import { IDENTITY_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; import { IDENTITY_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs";
import { UnauthorizedError } from "@app/lib/errors"; import { UnauthorizedError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
@@ -80,9 +79,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
...req.body, ...req.body,
slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)),
isTemporary: false, isTemporary: false,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment permissions: JSON.stringify(packRules(permission))
// @ts-ignore-error this is valid ts
permissions: JSON.stringify(packRules(backfillPermissionV1SchemaToV2Schema(permission)))
}); });
return { privilege }; return { privilege };
} }
@@ -162,9 +159,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
...req.body, ...req.body,
slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)),
isTemporary: true, isTemporary: true,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment permissions: JSON.stringify(packRules(permission))
// @ts-ignore-error this is valid ts
permissions: JSON.stringify(packRules(backfillPermissionV1SchemaToV2Schema(permission)))
}); });
return { privilege }; return { privilege };
} }
@@ -249,11 +244,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F
projectSlug: req.body.projectSlug, projectSlug: req.body.projectSlug,
data: { data: {
...updatedInfo, ...updatedInfo,
permissions: permission permissions: permission ? JSON.stringify(packRules(permission)) : undefined
? // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error this is valid ts
JSON.stringify(packRules(backfillPermissionV1SchemaToV2Schema(permission)))
: undefined
} }
}); });
return { privilege }; return { privilege };
@@ -3,10 +3,7 @@ import slugify from "@sindresorhus/slugify";
import { z } from "zod"; import { z } from "zod";
import { ProjectMembershipRole, ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas"; import { ProjectMembershipRole, ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas";
import { import { ProjectPermissionSchema } from "@app/ee/services/permission/project-permission";
backfillPermissionV1SchemaToV2Schema,
ProjectPermissionV1Schema
} from "@app/ee/services/permission/project-permission";
import { PROJECT_ROLE } from "@app/lib/api-docs"; import { PROJECT_ROLE } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -46,7 +43,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
.describe(PROJECT_ROLE.CREATE.slug), .describe(PROJECT_ROLE.CREATE.slug),
name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name), name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name),
description: z.string().trim().optional().describe(PROJECT_ROLE.CREATE.description), description: z.string().trim().optional().describe(PROJECT_ROLE.CREATE.description),
permissions: ProjectPermissionV1Schema.array().describe(PROJECT_ROLE.CREATE.permissions) permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.CREATE.permissions)
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -64,7 +61,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
projectSlug: req.params.projectSlug, projectSlug: req.params.projectSlug,
data: { data: {
...req.body, ...req.body,
permissions: JSON.stringify(packRules(backfillPermissionV1SchemaToV2Schema(req.body.permissions))) permissions: JSON.stringify(packRules(req.body.permissions))
} }
}); });
return { role }; return { role };
@@ -106,7 +103,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
}), }),
name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name),
description: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.description), description: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.description),
permissions: ProjectPermissionV1Schema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional() permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional()
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -125,9 +122,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
roleId: req.params.roleId, roleId: req.params.roleId,
data: { data: {
...req.body, ...req.body,
permissions: req.body.permissions permissions: req.body.permissions ? JSON.stringify(packRules(req.body.permissions)) : undefined
? JSON.stringify(packRules(backfillPermissionV1SchemaToV2Schema(req.body.permissions)))
: undefined
} }
}); });
return { role }; return { role };
@@ -1,16 +1,13 @@
import { packRules } from "@casl/ability/extra";
import slugify from "@sindresorhus/slugify"; import slugify from "@sindresorhus/slugify";
import ms from "ms"; import ms from "ms";
import { z } from "zod"; import { z } from "zod";
import { ProjectUserAdditionalPrivilegeSchema } from "@app/db/schemas"; import { ProjectUserAdditionalPrivilegeSchema } from "@app/db/schemas";
import { backfillPermissionV1SchemaToV2Schema } from "@app/ee/services/permission/project-permission";
import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-types"; import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-types";
import { PROJECT_USER_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; import { PROJECT_USER_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { ProjectSpecificPrivilegePermissionSchema } from "@app/server/routes/sanitizedSchemas";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => { export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => {
@@ -34,9 +31,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr
}) })
.optional() .optional()
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug),
permissions: ProjectSpecificPrivilegePermissionSchema.describe( permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions)
PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions
)
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -54,17 +49,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr
...req.body, ...req.body,
slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)),
isTemporary: false, isTemporary: false,
permissions: JSON.stringify( permissions: JSON.stringify(req.body.permissions)
packRules(
backfillPermissionV1SchemaToV2Schema(
req.body.permissions.actions.map((action) => ({
action,
subject: req.body.permissions.subject,
conditions: req.body.permissions.conditions
}))
)
)
)
}); });
return { privilege }; return { privilege };
} }
@@ -90,9 +75,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr
}) })
.optional() .optional()
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug), .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug),
permissions: ProjectSpecificPrivilegePermissionSchema.describe( permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions),
PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions
),
temporaryMode: z temporaryMode: z
.nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode) .nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode)
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode),
@@ -121,17 +104,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr
...req.body, ...req.body,
slug: req.body.slug ? slugify(req.body.slug) : `privilege-${slugify(alphaNumericNanoId(12))}`, slug: req.body.slug ? slugify(req.body.slug) : `privilege-${slugify(alphaNumericNanoId(12))}`,
isTemporary: true, isTemporary: true,
permissions: JSON.stringify( permissions: JSON.stringify(req.body.permissions)
packRules(
backfillPermissionV1SchemaToV2Schema(
req.body.permissions.actions.map((action) => ({
action,
subject: req.body.permissions.subject,
conditions: req.body.permissions.conditions
}))
)
)
)
}); });
return { privilege }; return { privilege };
} }
@@ -158,9 +131,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr
message: "Slug must be a valid slug" message: "Slug must be a valid slug"
}) })
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.slug), .describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.slug),
permissions: ProjectSpecificPrivilegePermissionSchema.describe( permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.permissions),
PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.permissions
).optional(),
isTemporary: z.boolean().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary), isTemporary: z.boolean().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary),
temporaryMode: z temporaryMode: z
.nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode) .nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode)
@@ -189,19 +160,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
...req.body, ...req.body,
permissions: req.body.permissions permissions: req.body.permissions ? JSON.stringify(req.body.permissions) : undefined,
? JSON.stringify(
packRules(
backfillPermissionV1SchemaToV2Schema(
req.body.permissions.actions.map((action) => ({
action,
subject: req.body.permissions!.subject,
conditions: req.body.permissions!.conditions
}))
)
)
)
: undefined,
privilegeId: req.params.privilegeId privilegeId: req.params.privilegeId
}); });
return { privilege }; return { privilege };
-11
View File
@@ -1,11 +0,0 @@
import { registerProjectRoleRouter } from "./project-role-router";
export const registerV2EERoutes = async (server: FastifyZodProvider) => {
// org role starts with organization
await server.register(
async (projectRouter) => {
await projectRouter.register(registerProjectRoleRouter);
},
{ prefix: "/workspace" }
);
};
@@ -1,272 +0,0 @@
import { packRules } from "@casl/ability/extra";
import slugify from "@sindresorhus/slugify";
import { z } from "zod";
import { ProjectMembershipRole, ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas";
import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission";
import { PROJECT_ROLE } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/:projectSlug/roles",
config: {
rateLimit: writeLimit
},
schema: {
description: "Create a project role",
security: [
{
bearerAuth: []
}
],
params: z.object({
projectSlug: z.string().trim().describe(PROJECT_ROLE.CREATE.projectSlug)
}),
body: z.object({
slug: z
.string()
.toLowerCase()
.trim()
.min(1)
.refine(
(val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole),
"Please choose a different slug, the slug you have entered is reserved"
)
.refine((v) => slugify(v) === v, {
message: "Slug must be a valid"
})
.describe(PROJECT_ROLE.CREATE.slug),
name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name),
description: z.string().trim().optional().describe(PROJECT_ROLE.CREATE.description),
permissions: ProjectPermissionV2Schema.array().describe(PROJECT_ROLE.CREATE.permissions)
}),
response: {
200: z.object({
role: SanitizedRoleSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const role = await server.services.projectRole.createRole({
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actor: req.permission.type,
projectSlug: req.params.projectSlug,
data: {
...req.body,
permissions: JSON.stringify(packRules(req.body.permissions))
}
});
return { role };
}
});
server.route({
method: "PATCH",
url: "/:projectSlug/roles/:roleId",
config: {
rateLimit: writeLimit
},
schema: {
description: "Update a project role",
security: [
{
bearerAuth: []
}
],
params: z.object({
projectSlug: z.string().trim().describe(PROJECT_ROLE.UPDATE.projectSlug),
roleId: z.string().trim().describe(PROJECT_ROLE.UPDATE.roleId)
}),
body: z.object({
slug: z
.string()
.toLowerCase()
.trim()
.optional()
.describe(PROJECT_ROLE.UPDATE.slug)
.refine(
(val) =>
typeof val === "undefined" ||
!Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole),
"Please choose a different slug, the slug you have entered is reserved"
)
.refine((val) => typeof val === "undefined" || slugify(val) === val, {
message: "Slug must be a valid"
}),
name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name),
description: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.description),
permissions: ProjectPermissionV2Schema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional()
}),
response: {
200: z.object({
role: SanitizedRoleSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const role = await server.services.projectRole.updateRole({
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actor: req.permission.type,
projectSlug: req.params.projectSlug,
roleId: req.params.roleId,
data: {
...req.body,
permissions: req.body.permissions ? JSON.stringify(packRules(req.body.permissions)) : undefined
}
});
return { role };
}
});
server.route({
method: "DELETE",
url: "/:projectSlug/roles/:roleId",
config: {
rateLimit: writeLimit
},
schema: {
description: "Delete a project role",
security: [
{
bearerAuth: []
}
],
params: z.object({
projectSlug: z.string().trim().describe(PROJECT_ROLE.DELETE.projectSlug),
roleId: z.string().trim().describe(PROJECT_ROLE.DELETE.roleId)
}),
response: {
200: z.object({
role: SanitizedRoleSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const role = await server.services.projectRole.deleteRole({
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actor: req.permission.type,
projectSlug: req.params.projectSlug,
roleId: req.params.roleId
});
return { role };
}
});
server.route({
method: "GET",
url: "/:projectSlug/roles",
config: {
rateLimit: readLimit
},
schema: {
description: "List project role",
security: [
{
bearerAuth: []
}
],
params: z.object({
projectSlug: z.string().trim().describe(PROJECT_ROLE.LIST.projectSlug)
}),
response: {
200: z.object({
roles: ProjectRolesSchema.omit({ permissions: true }).array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const roles = await server.services.projectRole.listRoles({
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actor: req.permission.type,
projectSlug: req.params.projectSlug
});
return { roles };
}
});
server.route({
method: "GET",
url: "/:projectSlug/roles/slug/:roleSlug",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
projectSlug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.projectSlug),
roleSlug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.roleSlug)
}),
response: {
200: z.object({
role: SanitizedRoleSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const role = await server.services.projectRole.getRoleBySlug({
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actor: req.permission.type,
projectSlug: req.params.projectSlug,
roleSlug: req.params.roleSlug
});
return { role };
}
});
server.route({
method: "GET",
url: "/:projectId/permissions",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
projectId: z.string().trim()
}),
response: {
200: z.object({
data: z.object({
membership: ProjectMembershipsSchema.extend({
roles: z
.object({
role: z.string()
})
.array()
}),
permissions: z.any().array()
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { permissions, membership } = await server.services.projectRole.getUserPermission(
req.permission.id,
req.params.projectId,
req.permission.authMethod,
req.permission.orgId
);
return { data: { permissions, membership } };
}
});
};
@@ -14,7 +14,7 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
const accessApprovalPolicyFindQuery = async ( const accessApprovalPolicyFindQuery = async (
tx: Knex, tx: Knex,
filter: TFindFilter<TAccessApprovalPolicies & { projectId: string }>, filter: TFindFilter<TAccessApprovalPolicies>,
customFilter?: { customFilter?: {
policyId?: string; policyId?: string;
} }
@@ -0,0 +1,36 @@
import { ForbiddenError, subject } from "@casl/ability";
import { ActorType } from "@app/services/auth/auth-type";
import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission";
import { TIsApproversValid } from "./access-approval-policy-types";
export const isApproversValid = async ({
userIds,
projectId,
orgId,
envSlug,
actorAuthMethod,
secretPath,
permissionService
}: TIsApproversValid) => {
try {
for await (const userId of userIds) {
const { permission: approverPermission } = await permissionService.getProjectPermission(
ActorType.USER,
userId,
projectId,
actorAuthMethod,
orgId
);
ForbiddenError.from(approverPermission).throwUnlessCan(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, { environment: envSlug, secretPath })
);
}
} catch {
return false;
}
return true;
};
@@ -11,6 +11,7 @@ import { TUserDALFactory } from "@app/services/user/user-dal";
import { TGroupDALFactory } from "../group/group-dal"; import { TGroupDALFactory } from "../group/group-dal";
import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal"; import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal";
import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal"; import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal";
import { isApproversValid } from "./access-approval-policy-fns";
import { import {
ApproverType, ApproverType,
TCreateAccessApprovalPolicy, TCreateAccessApprovalPolicy,
@@ -131,6 +132,22 @@ export const accessApprovalPolicyServiceFactory = ({
.map((user) => user.id); .map((user) => user.id);
verifyAllApprovers.push(...verifyGroupApprovers); verifyAllApprovers.push(...verifyGroupApprovers);
const approversValid = await isApproversValid({
projectId: project.id,
orgId: actorOrgId,
envSlug: environment,
secretPath,
actorAuthMethod,
permissionService,
userIds: verifyAllApprovers
});
if (!approversValid) {
throw new BadRequestError({
message: "One or more approvers doesn't have access to be specified secret path"
});
}
const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => { const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => {
const doc = await accessApprovalPolicyDAL.create( const doc = await accessApprovalPolicyDAL.create(
{ {
@@ -272,6 +289,22 @@ export const accessApprovalPolicyServiceFactory = ({
userApproverIds = userApproverIds.concat(approverUsers.map((user) => user.id)); userApproverIds = userApproverIds.concat(approverUsers.map((user) => user.id));
} }
const approversValid = await isApproversValid({
projectId: accessApprovalPolicy.projectId,
orgId: actorOrgId,
envSlug: accessApprovalPolicy.environment.slug,
secretPath: doc.secretPath!,
actorAuthMethod,
permissionService,
userIds: userApproverIds
});
if (!approversValid) {
throw new BadRequestError({
message: "One or more approvers doesn't have access to be specified secret path"
});
}
await accessApprovalPolicyApproverDAL.insertMany( await accessApprovalPolicyApproverDAL.insertMany(
userApproverIds.map((userId) => ({ userApproverIds.map((userId) => ({
approverUserId: userId, approverUserId: userId,
@@ -282,6 +315,41 @@ export const accessApprovalPolicyServiceFactory = ({
} }
if (groupApprovers) { if (groupApprovers) {
const usersPromises: Promise<
{
id: string;
email: string | null | undefined;
username: string;
firstName: string | null | undefined;
lastName: string | null | undefined;
isPartOfGroup: boolean;
}[]
>[] = [];
for (const groupId of groupApprovers) {
usersPromises.push(groupDAL.findAllGroupPossibleMembers({ orgId: actorOrgId, groupId, offset: 0 }));
}
const verifyGroupApprovers = (await Promise.all(usersPromises))
.flat()
.filter((user) => user.isPartOfGroup)
.map((user) => user.id);
const approversValid = await isApproversValid({
projectId: accessApprovalPolicy.projectId,
orgId: actorOrgId,
envSlug: accessApprovalPolicy.environment.slug,
secretPath: doc.secretPath!,
actorAuthMethod,
permissionService,
userIds: verifyGroupApprovers
});
if (!approversValid) {
throw new BadRequestError({
message: "One or more approvers doesn't have access to be specified secret path"
});
}
await accessApprovalPolicyApproverDAL.insertMany( await accessApprovalPolicyApproverDAL.insertMany(
groupApprovers.map((groupId) => ({ groupApprovers.map((groupId) => ({
approverGroupId: groupId, approverGroupId: groupId,
@@ -17,6 +17,7 @@ import { TUserDALFactory } from "@app/services/user/user-dal";
import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-policy/access-approval-policy-approver-dal"; import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-policy/access-approval-policy-approver-dal";
import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal"; import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal";
import { isApproversValid } from "../access-approval-policy/access-approval-policy-fns";
import { TGroupDALFactory } from "../group/group-dal"; import { TGroupDALFactory } from "../group/group-dal";
import { TPermissionServiceFactory } from "../permission/permission-service"; import { TPermissionServiceFactory } from "../permission/permission-service";
import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal";
@@ -77,6 +78,7 @@ export const accessApprovalRequestServiceFactory = ({
permissionService, permissionService,
accessApprovalRequestDAL, accessApprovalRequestDAL,
accessApprovalRequestReviewerDAL, accessApprovalRequestReviewerDAL,
projectMembershipDAL,
accessApprovalPolicyDAL, accessApprovalPolicyDAL,
accessApprovalPolicyApproverDAL, accessApprovalPolicyApproverDAL,
additionalPrivilegeDAL, additionalPrivilegeDAL,
@@ -321,6 +323,22 @@ export const accessApprovalRequestServiceFactory = ({
throw new ForbiddenRequestError({ message: "You are not authorized to approve this request" }); throw new ForbiddenRequestError({ message: "You are not authorized to approve this request" });
} }
const reviewerProjectMembership = await projectMembershipDAL.findById(membership.id);
const approversValid = await isApproversValid({
projectId: accessApprovalRequest.projectId,
orgId: actorOrgId,
envSlug: accessApprovalRequest.environment,
secretPath: accessApprovalRequest.policy.secretPath!,
actorAuthMethod,
permissionService,
userIds: [reviewerProjectMembership.userId]
});
if (!approversValid) {
throw new ForbiddenRequestError({ message: "You don't have access to approve this request" });
}
const existingReviews = await accessApprovalRequestReviewerDAL.find({ requestId: accessApprovalRequest.id }); const existingReviews = await accessApprovalRequestReviewerDAL.find({ requestId: accessApprovalRequest.id });
if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) { if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) {
throw new BadRequestError({ message: "The request has already been rejected by another reviewer" }); throw new BadRequestError({ message: "The request has already been rejected by another reviewer" });
@@ -4,10 +4,7 @@ import ms from "ms";
import { SecretKeyEncoding } from "@app/db/schemas"; import { SecretKeyEncoding } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
ProjectPermissionDynamicSecretActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
@@ -75,8 +72,8 @@ export const dynamicSecretLeaseServiceFactory = ({
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
); );
const plan = await licenseService.getPlan(actorOrgId); const plan = await licenseService.getPlan(actorOrgId);
@@ -148,8 +145,8 @@ export const dynamicSecretLeaseServiceFactory = ({
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease, ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
); );
const plan = await licenseService.getPlan(actorOrgId); const plan = await licenseService.getPlan(actorOrgId);
@@ -222,8 +219,8 @@ export const dynamicSecretLeaseServiceFactory = ({
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease, ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
); );
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
@@ -287,8 +284,8 @@ export const dynamicSecretLeaseServiceFactory = ({
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
); );
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
@@ -323,8 +320,8 @@ export const dynamicSecretLeaseServiceFactory = ({
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.Lease, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
); );
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
@@ -3,10 +3,7 @@ import { ForbiddenError, subject } from "@casl/ability";
import { SecretKeyEncoding } from "@app/db/schemas"; import { SecretKeyEncoding } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
ProjectPermissionDynamicSecretActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { OrderByDirection } from "@app/lib/types"; import { OrderByDirection } from "@app/lib/types";
@@ -80,8 +77,8 @@ export const dynamicSecretServiceFactory = ({
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.CreateRootCredential, ProjectPermissionActions.Create,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
); );
const plan = await licenseService.getPlan(actorOrgId); const plan = await licenseService.getPlan(actorOrgId);
@@ -149,8 +146,8 @@ export const dynamicSecretServiceFactory = ({
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.EditRootCredential, ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
); );
const plan = await licenseService.getPlan(actorOrgId); const plan = await licenseService.getPlan(actorOrgId);
@@ -228,8 +225,8 @@ export const dynamicSecretServiceFactory = ({
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.DeleteRootCredential, ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
); );
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
@@ -285,12 +282,8 @@ export const dynamicSecretServiceFactory = ({
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.ReadRootCredential, ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.EditRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path })
); );
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
@@ -335,8 +328,8 @@ export const dynamicSecretServiceFactory = ({
// verify user has access to each env in request // verify user has access to each env in request
environmentSlugs.forEach((environmentSlug) => environmentSlugs.forEach((environmentSlug) =>
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.ReadRootCredential, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
) )
); );
} }
@@ -371,8 +364,8 @@ export const dynamicSecretServiceFactory = ({
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.ReadRootCredential, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
); );
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
@@ -417,8 +410,8 @@ export const dynamicSecretServiceFactory = ({
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.ReadRootCredential, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
); );
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path); const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
@@ -459,8 +452,8 @@ export const dynamicSecretServiceFactory = ({
// verify user has access to each env in request // verify user has access to each env in request
environmentSlugs.forEach((environmentSlug) => environmentSlugs.forEach((environmentSlug) =>
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionDynamicSecretActions.ReadRootCredential, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.DynamicSecrets, { environment: environmentSlug, secretPath: path }) subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
) )
); );
} }
@@ -1,10 +1,10 @@
import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability"; import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability";
import { PackRule, unpackRules } from "@casl/ability/extra"; import { PackRule, unpackRules } from "@casl/ability/extra";
import ms from "ms"; import ms from "ms";
import { z } from "zod";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { isAtLeastAsPrivileged } from "@app/lib/casl";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission";
import { ActorType } from "@app/services/auth/auth-type"; import { ActorType } from "@app/services/auth/auth-type";
import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
@@ -32,6 +32,16 @@ export type TIdentityProjectAdditionalPrivilegeServiceFactory = ReturnType<
typeof identityProjectAdditionalPrivilegeServiceFactory typeof identityProjectAdditionalPrivilegeServiceFactory
>; >;
// TODO(akhilmhdh): move this to more centralized
export const UnpackedPermissionSchema = z.object({
subject: z
.union([z.string().min(1), z.string().array()])
.transform((el) => (typeof el !== "string" ? el[0] : el))
.optional(),
action: z.union([z.string().min(1), z.string().array()]).transform((el) => (typeof el === "string" ? [el] : el)),
conditions: z.unknown().optional()
});
const unpackPermissions = (permissions: unknown) => const unpackPermissions = (permissions: unknown) =>
UnpackedPermissionSchema.array().parse( UnpackedPermissionSchema.array().parse(
unpackRules((permissions || []) as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[]) unpackRules((permissions || []) as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[])
@@ -193,6 +203,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
}); });
return { return {
...additionalPrivilege, ...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions) permissions: unpackPermissions(additionalPrivilege.permissions)
}; };
}; };
@@ -313,6 +324,7 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
}); });
return identityPrivileges.map((el) => ({ return identityPrivileges.map((el) => ({
...el, ...el,
permissions: unpackPermissions(el.permissions) permissions: unpackPermissions(el.permissions)
})); }));
}; };
@@ -67,7 +67,7 @@ export const permissionServiceFactory = ({
throw new NotFoundError({ name: "OrgRoleInvalid", message: "Organization role not found" }); throw new NotFoundError({ name: "OrgRoleInvalid", message: "Organization role not found" });
} }
}) })
.reduce((prev, curr) => prev.concat(curr), []); .reduce((curr, prev) => prev.concat(curr), []);
return createMongoAbility<OrgPermissionSet>(rules, { return createMongoAbility<OrgPermissionSet>(rules, {
conditionsMatcher conditionsMatcher
@@ -98,7 +98,7 @@ export const permissionServiceFactory = ({
}); });
} }
}) })
.reduce((prev, curr) => prev.concat(curr), []); .reduce((curr, prev) => prev.concat(curr), []);
return rules; return rules;
}; };
@@ -11,8 +11,8 @@ export enum PermissionConditionOperators {
} }
export const PermissionConditionSchema = { export const PermissionConditionSchema = {
[PermissionConditionOperators.$IN]: z.string().trim().min(1).array(), [PermissionConditionOperators.$IN]: z.string().min(1).array(),
[PermissionConditionOperators.$ALL]: z.string().trim().min(1).array(), [PermissionConditionOperators.$ALL]: z.string().min(1).array(),
[PermissionConditionOperators.$REGEX]: z [PermissionConditionOperators.$REGEX]: z
.string() .string()
.min(1) .min(1)
@@ -1,8 +1,9 @@
import { AbilityBuilder, createMongoAbility, ForcedSubject, MongoAbility } from "@casl/ability"; import { AbilityBuilder, createMongoAbility, ForcedSubject, MongoAbility } from "@casl/ability";
import { z } from "zod"; import { z } from "zod";
import { TableName } from "@app/db/schemas";
import { conditionsMatcher } from "@app/lib/casl"; import { conditionsMatcher } from "@app/lib/casl";
import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission"; import { BadRequestError } from "@app/lib/errors";
import { PermissionConditionOperators, PermissionConditionSchema } from "./permission-types"; import { PermissionConditionOperators, PermissionConditionSchema } from "./permission-types";
@@ -22,14 +23,6 @@ export enum ProjectPermissionCmekActions {
Decrypt = "decrypt" Decrypt = "decrypt"
} }
export enum ProjectPermissionDynamicSecretActions {
ReadRootCredential = "read-root-credential",
CreateRootCredential = "create-root-credential",
EditRootCredential = "edit-root-credential",
DeleteRootCredential = "delete-root-credential",
Lease = "lease"
}
export enum ProjectPermissionSub { export enum ProjectPermissionSub {
Role = "role", Role = "role",
Member = "member", Member = "member",
@@ -45,8 +38,6 @@ export enum ProjectPermissionSub {
Project = "workspace", Project = "workspace",
Secrets = "secrets", Secrets = "secrets",
SecretFolders = "secret-folders", SecretFolders = "secret-folders",
SecretImports = "secret-imports",
DynamicSecrets = "dynamic-secrets",
SecretRollback = "secret-rollback", SecretRollback = "secret-rollback",
SecretApproval = "secret-approval", SecretApproval = "secret-approval",
SecretRotation = "secret-rotation", SecretRotation = "secret-rotation",
@@ -63,8 +54,19 @@ export enum ProjectPermissionSub {
export type SecretSubjectFields = { export type SecretSubjectFields = {
environment: string; environment: string;
secretPath: string; secretPath: string;
secretName?: string; // secretName: string;
secretTags?: string[]; // secretTags: string[];
};
export const CaslSecretsV2SubjectKnexMapper = (field: string) => {
switch (field) {
case "secretName":
return `${TableName.SecretV2}.key`;
case "secretTags":
return `${TableName.SecretTag}.slug`;
default:
break;
}
}; };
export type SecretFolderSubjectFields = { export type SecretFolderSubjectFields = {
@@ -72,16 +74,6 @@ export type SecretFolderSubjectFields = {
secretPath: string; secretPath: string;
}; };
export type DynamicSecretSubjectFields = {
environment: string;
secretPath: string;
};
export type SecretImportSubjectFields = {
environment: string;
secretPath: string;
};
export type ProjectPermissionSet = export type ProjectPermissionSet =
| [ | [
ProjectPermissionActions, ProjectPermissionActions,
@@ -94,20 +86,6 @@ export type ProjectPermissionSet =
| (ForcedSubject<ProjectPermissionSub.SecretFolders> & SecretFolderSubjectFields) | (ForcedSubject<ProjectPermissionSub.SecretFolders> & SecretFolderSubjectFields)
) )
] ]
| [
ProjectPermissionDynamicSecretActions,
(
| ProjectPermissionSub.DynamicSecrets
| (ForcedSubject<ProjectPermissionSub.DynamicSecrets> & DynamicSecretSubjectFields)
)
]
| [
ProjectPermissionActions,
(
| ProjectPermissionSub.SecretImports
| (ForcedSubject<ProjectPermissionSub.SecretImports> & SecretImportSubjectFields)
)
]
| [ProjectPermissionActions, ProjectPermissionSub.Role] | [ProjectPermissionActions, ProjectPermissionSub.Role]
| [ProjectPermissionActions, ProjectPermissionSub.Tags] | [ProjectPermissionActions, ProjectPermissionSub.Tags]
| [ProjectPermissionActions, ProjectPermissionSub.Member] | [ProjectPermissionActions, ProjectPermissionSub.Member]
@@ -142,9 +120,7 @@ const CASL_ACTION_SCHEMA_NATIVE_ENUM = <ACTION extends z.EnumLike>(actions: ACTI
const CASL_ACTION_SCHEMA_ENUM = <ACTION extends z.EnumValues>(actions: ACTION) => const CASL_ACTION_SCHEMA_ENUM = <ACTION extends z.EnumValues>(actions: ACTION) =>
z.union([z.enum(actions), z.enum(actions).array().min(1)]).transform((el) => (typeof el === "string" ? [el] : el)); z.union([z.enum(actions), z.enum(actions).array().min(1)]).transform((el) => (typeof el === "string" ? [el] : el));
// akhilmhdh: don't modify this for v2 const SecretConditionSchema = z
// if you want to update create a new schema
const SecretConditionV1Schema = z
.object({ .object({
environment: z.union([ environment: z.union([
z.string(), z.string(),
@@ -170,50 +146,16 @@ const SecretConditionV1Schema = z
}) })
.partial(); .partial();
const SecretConditionV2Schema = z export const ProjectPermissionSchema = z.discriminatedUnion("subject", [
.object({ z.object({
environment: z.union([ subject: z.literal(ProjectPermissionSub.Secrets).describe("The entity this permission pertains to."),
z.string(), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
z "Describe what action an entity can take."
.object({ ),
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], conditions: SecretConditionSchema.describe(
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], "When specified, only matching conditions will be allowed to access given resource."
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], ).optional()
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] }),
})
.partial()
]),
secretPath: z.union([
z.string(),
z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
})
.partial()
]),
secretName: z.union([
z.string(),
z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
})
.partial()
]),
secretTags: z
.object({
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN]
})
.partial()
})
.partial();
const GeneralPermissionSchema = [
z.object({ z.object({
subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."), subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
@@ -317,7 +259,7 @@ const GeneralPermissionSchema = [
) )
}), }),
z.object({ z.object({
subject: z.literal(ProjectPermissionSub.CertificateTemplates).describe("The entity this permission pertains to."), subject: z.literal(ProjectPermissionSub.CertificateTemplates).describe("The entity this permission pertains to. "),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
"Describe what action an entity can take." "Describe what action an entity can take."
) )
@@ -346,78 +288,18 @@ const GeneralPermissionSchema = [
"Describe what action an entity can take." "Describe what action an entity can take."
) )
}), }),
z.object({
subject: z.literal(ProjectPermissionSub.Cmek).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCmekActions).describe(
"Describe what action an entity can take."
)
})
];
export const ProjectPermissionV1Schema = z.discriminatedUnion("subject", [
z.object({
subject: z.literal(ProjectPermissionSub.Secrets).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
"Describe what action an entity can take."
),
conditions: SecretConditionV1Schema.describe(
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
z.object({ z.object({
subject: z.literal(ProjectPermissionSub.SecretFolders).describe("The entity this permission pertains to."), subject: z.literal(ProjectPermissionSub.SecretFolders).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
action: CASL_ACTION_SCHEMA_ENUM([ProjectPermissionActions.Read]).describe( action: CASL_ACTION_SCHEMA_ENUM([ProjectPermissionActions.Read]).describe(
"Describe what action an entity can take." "Describe what action an entity can take."
) )
}), }),
...GeneralPermissionSchema
]);
export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
z.object({ z.object({
subject: z.literal(ProjectPermissionSub.Secrets).describe("The entity this permission pertains to."), subject: z.literal(ProjectPermissionSub.Cmek).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCmekActions).describe(
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
"Describe what action an entity can take." "Describe what action an entity can take."
), )
conditions: SecretConditionV2Schema.describe( })
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
z.object({
subject: z.literal(ProjectPermissionSub.SecretFolders).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
"Describe what action an entity can take."
),
conditions: SecretConditionV1Schema.describe(
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
z.object({
subject: z.literal(ProjectPermissionSub.SecretImports).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe(
"Describe what action an entity can take."
),
conditions: SecretConditionV1Schema.describe(
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
z.object({
subject: z.literal(ProjectPermissionSub.DynamicSecrets).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionDynamicSecretActions).describe(
"Describe what action an entity can take."
),
conditions: SecretConditionV1Schema.describe(
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
...GeneralPermissionSchema
]); ]);
const buildAdminPermissionRules = () => { const buildAdminPermissionRules = () => {
@@ -426,8 +308,6 @@ const buildAdminPermissionRules = () => {
// Admins get full access to everything // Admins get full access to everything
[ [
ProjectPermissionSub.Secrets, ProjectPermissionSub.Secrets,
ProjectPermissionSub.SecretFolders,
ProjectPermissionSub.SecretImports,
ProjectPermissionSub.SecretApproval, ProjectPermissionSub.SecretApproval,
ProjectPermissionSub.SecretRotation, ProjectPermissionSub.SecretRotation,
ProjectPermissionSub.Member, ProjectPermissionSub.Member,
@@ -459,17 +339,6 @@ const buildAdminPermissionRules = () => {
); );
}); });
can(
[
ProjectPermissionDynamicSecretActions.ReadRootCredential,
ProjectPermissionDynamicSecretActions.EditRootCredential,
ProjectPermissionDynamicSecretActions.CreateRootCredential,
ProjectPermissionDynamicSecretActions.DeleteRootCredential,
ProjectPermissionDynamicSecretActions.Lease
],
ProjectPermissionSub.DynamicSecrets
);
can([ProjectPermissionActions.Edit, ProjectPermissionActions.Delete], ProjectPermissionSub.Project); can([ProjectPermissionActions.Edit, ProjectPermissionActions.Delete], ProjectPermissionSub.Project);
can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback); can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback);
can([ProjectPermissionActions.Edit], ProjectPermissionSub.Kms); can([ProjectPermissionActions.Edit], ProjectPermissionSub.Kms);
@@ -501,34 +370,6 @@ const buildMemberPermissionRules = () => {
], ],
ProjectPermissionSub.Secrets ProjectPermissionSub.Secrets
); );
can(
[
ProjectPermissionActions.Read,
ProjectPermissionActions.Edit,
ProjectPermissionActions.Create,
ProjectPermissionActions.Delete
],
ProjectPermissionSub.SecretFolders
);
can(
[
ProjectPermissionDynamicSecretActions.ReadRootCredential,
ProjectPermissionDynamicSecretActions.EditRootCredential,
ProjectPermissionDynamicSecretActions.CreateRootCredential,
ProjectPermissionDynamicSecretActions.DeleteRootCredential,
ProjectPermissionDynamicSecretActions.Lease
],
ProjectPermissionSub.DynamicSecrets
);
can(
[
ProjectPermissionActions.Read,
ProjectPermissionActions.Edit,
ProjectPermissionActions.Create,
ProjectPermissionActions.Delete
],
ProjectPermissionSub.SecretImports
);
can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretApproval); can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretApproval);
can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretRotation); can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretRotation);
@@ -652,9 +493,6 @@ const buildViewerPermissionRules = () => {
const { can, rules } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility); const { can, rules } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretFolders);
can(ProjectPermissionDynamicSecretActions.ReadRootCredential, ProjectPermissionSub.DynamicSecrets);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretImports);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation);
@@ -757,52 +595,17 @@ export const isAtLeastAsPrivilegedWorkspace = (
}; };
/* eslint-enable */ /* eslint-enable */
export const backfillPermissionV1SchemaToV2Schema = (data: z.infer<typeof ProjectPermissionV1Schema>[]) => { export const SecretV2SubjectFieldMapper = (arg: string) => {
const formattedData = UnpackedPermissionSchema.array().parse(data); switch (arg) {
const secretSubjects = formattedData.filter((el) => el.subject === ProjectPermissionSub.Secrets); case "environment":
return null;
// this means the folder permission as readonly is set case "secretPath":
const hasReadOnlyFolder = formattedData.filter((el) => el.subject === ProjectPermissionSub.SecretFolders); return null;
const secretImportPolicies = secretSubjects.map(({ subject, ...el }) => ({ case "secretName":
...el, return `${TableName.SecretV2}.key`;
subject: ProjectPermissionSub.SecretImports as const case "secretTags":
})); return `${TableName.SecretTag}.slug`;
default:
const secretFolderPolicies = secretSubjects.map(({ subject, ...el }) => ({ throw new BadRequestError({ message: `Invalid dynamic knex operator field: ${arg}` });
...el, }
subject: ProjectPermissionSub.SecretFolders
}));
const dynamicSecretPolicies = secretSubjects.map(({ subject, ...el }) => {
const action = el.action.map((e) => {
switch (e) {
case ProjectPermissionActions.Edit:
return ProjectPermissionDynamicSecretActions.EditRootCredential;
case ProjectPermissionActions.Create:
return ProjectPermissionDynamicSecretActions.CreateRootCredential;
case ProjectPermissionActions.Delete:
return ProjectPermissionDynamicSecretActions.DeleteRootCredential;
case ProjectPermissionActions.Read:
return ProjectPermissionDynamicSecretActions.ReadRootCredential;
default:
return ProjectPermissionDynamicSecretActions.ReadRootCredential;
}
});
return {
...el,
action: el.action.includes(ProjectPermissionActions.Edit)
? [...action, ProjectPermissionDynamicSecretActions.Lease]
: action,
subject: ProjectPermissionSub.DynamicSecrets
};
});
return formattedData.concat(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error this is valid ts
secretImportPolicies,
dynamicSecretPolicies,
hasReadOnlyFolder.length ? [] : secretFolderPolicies
);
}; };
@@ -1,13 +1,11 @@
import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { PackRule, unpackRules } from "@casl/ability/extra";
import ms from "ms"; import ms from "ms";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { TPermissionServiceFactory } from "../permission/permission-service"; import { TPermissionServiceFactory } from "../permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSet, ProjectPermissionSub } from "../permission/project-permission"; import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission";
import { TProjectUserAdditionalPrivilegeDALFactory } from "./project-user-additional-privilege-dal"; import { TProjectUserAdditionalPrivilegeDALFactory } from "./project-user-additional-privilege-dal";
import { import {
ProjectUserAdditionalPrivilegeTemporaryMode, ProjectUserAdditionalPrivilegeTemporaryMode,
@@ -28,11 +26,6 @@ export type TProjectUserAdditionalPrivilegeServiceFactory = ReturnType<
typeof projectUserAdditionalPrivilegeServiceFactory typeof projectUserAdditionalPrivilegeServiceFactory
>; >;
const unpackPermissions = (permissions: unknown) =>
UnpackedPermissionSchema.array().parse(
unpackRules((permissions || []) as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[])
);
export const projectUserAdditionalPrivilegeServiceFactory = ({ export const projectUserAdditionalPrivilegeServiceFactory = ({
projectUserAdditionalPrivilegeDAL, projectUserAdditionalPrivilegeDAL,
projectMembershipDAL, projectMembershipDAL,
@@ -74,10 +67,7 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
slug, slug,
permissions: customPermission permissions: customPermission
}); });
return { return additionalPrivilege;
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
};
} }
const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange); const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange);
@@ -92,10 +82,7 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime), temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime),
temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs) temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs)
}); });
return { return additionalPrivilege;
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
};
}; };
const updateById = async ({ const updateById = async ({
@@ -144,11 +131,7 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""), temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""),
temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || "")) temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || ""))
}); });
return additionalPrivilege;
return {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
};
} }
const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.updateById(userPrivilege.id, { const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.updateById(userPrivilege.id, {
@@ -159,10 +142,7 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
temporaryRange: null, temporaryRange: null,
temporaryMode: null temporaryMode: null
}); });
return { return additionalPrivilege;
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
};
}; };
const deleteById = async ({ actorId, actor, actorOrgId, actorAuthMethod, privilegeId }: TDeleteUserPrivilegeDTO) => { const deleteById = async ({ actorId, actor, actorOrgId, actorAuthMethod, privilegeId }: TDeleteUserPrivilegeDTO) => {
@@ -185,10 +165,7 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member);
const deletedPrivilege = await projectUserAdditionalPrivilegeDAL.deleteById(userPrivilege.id); const deletedPrivilege = await projectUserAdditionalPrivilegeDAL.deleteById(userPrivilege.id);
return { return deletedPrivilege;
...deletedPrivilege,
permissions: unpackPermissions(deletedPrivilege.permissions)
};
}; };
const getPrivilegeDetailsById = async ({ const getPrivilegeDetailsById = async ({
@@ -216,10 +193,7 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
); );
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member);
return { return userPrivilege;
...userPrivilege,
permissions: unpackPermissions(userPrivilege.permissions)
};
}; };
const listPrivileges = async ({ const listPrivileges = async ({
@@ -245,10 +219,7 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
userId: projectMembership.userId, userId: projectMembership.userId,
projectId: projectMembership.projectId projectId: projectMembership.projectId
}); });
return userPrivileges.map((el) => ({ return userPrivileges;
...el,
permissions: unpackPermissions(el.permissions)
}));
}; };
return { return {
@@ -14,7 +14,7 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => {
const secretApprovalPolicyFindQuery = ( const secretApprovalPolicyFindQuery = (
tx: Knex, tx: Knex,
filter: TFindFilter<TSecretApprovalPolicies & { projectId: string }>, filter: TFindFilter<TSecretApprovalPolicies>,
customFilter?: { customFilter?: {
sapId?: string; sapId?: string;
} }
@@ -1,4 +1,4 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError, subject } from "@casl/ability";
import picomatch from "picomatch"; import picomatch from "picomatch";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
@@ -344,8 +344,17 @@ export const secretApprovalPolicyServiceFactory = ({
environment, environment,
secretPath secretPath
}: TGetBoardSapDTO) => { }: TGetBoardSapDTO) => {
await permissionService.getProjectPermission(actor, actorId, projectId, actorAuthMethod, actorOrgId); const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { secretPath, environment })
);
return getSecretApprovalPolicy(projectId, environment, secretPath); return getSecretApprovalPolicy(projectId, environment, secretPath);
}; };
@@ -43,7 +43,7 @@ import {
fnSecretBulkDelete as fnSecretV2BridgeBulkDelete, fnSecretBulkDelete as fnSecretV2BridgeBulkDelete,
fnSecretBulkInsert as fnSecretV2BridgeBulkInsert, fnSecretBulkInsert as fnSecretV2BridgeBulkInsert,
fnSecretBulkUpdate as fnSecretV2BridgeBulkUpdate, fnSecretBulkUpdate as fnSecretV2BridgeBulkUpdate,
getAllSecretReferences as getAllSecretReferencesV2Bridge getAllNestedSecretReferences as getAllNestedSecretReferencesV2Bridge
} from "@app/services/secret-v2-bridge/secret-v2-bridge-fns"; } from "@app/services/secret-v2-bridge/secret-v2-bridge-fns";
import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secret-version-dal";
import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal";
@@ -523,11 +523,11 @@ export const secretApprovalRequestServiceFactory = ({
skipMultilineEncoding: el.skipMultilineEncoding, skipMultilineEncoding: el.skipMultilineEncoding,
key: el.key, key: el.key,
references: el.encryptedValue references: el.encryptedValue
? getAllSecretReferencesV2Bridge( ? getAllNestedSecretReferencesV2Bridge(
secretManagerDecryptor({ secretManagerDecryptor({
cipherTextBlob: el.encryptedValue cipherTextBlob: el.encryptedValue
}).toString() }).toString()
).nestedReferences )
: [], : [],
type: SecretType.Shared type: SecretType.Shared
})), })),
@@ -547,11 +547,11 @@ export const secretApprovalRequestServiceFactory = ({
? { ? {
encryptedValue: el.encryptedValue as Buffer, encryptedValue: el.encryptedValue as Buffer,
references: el.encryptedValue references: el.encryptedValue
? getAllSecretReferencesV2Bridge( ? getAllNestedSecretReferencesV2Bridge(
secretManagerDecryptor({ secretManagerDecryptor({
cipherTextBlob: el.encryptedValue cipherTextBlob: el.encryptedValue
}).toString() }).toString()
).nestedReferences )
: [] : []
} }
: {}; : {};
@@ -1125,6 +1125,10 @@ export const secretApprovalRequestServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
);
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) if (!folder)
@@ -1288,23 +1292,6 @@ export const secretApprovalRequestServiceFactory = ({
const tagIds = unique(Object.values(commitTagIds).flat()); const tagIds = unique(Object.values(commitTagIds).flat());
const tags = tagIds.length ? await secretTagDAL.findManyTagsById(projectId, tagIds) : []; const tags = tagIds.length ? await secretTagDAL.findManyTagsById(projectId, tagIds) : [];
if (tagIds.length !== tags.length) throw new NotFoundError({ message: "Tag not found" }); if (tagIds.length !== tags.length) throw new NotFoundError({ message: "Tag not found" });
const tagsGroupById = groupBy(tags, (i) => i.id);
commits.forEach((commit) => {
let action = ProjectPermissionActions.Create;
if (commit.op === SecretOperations.Update) action = ProjectPermissionActions.Edit;
if (commit.op === SecretOperations.Delete) action = ProjectPermissionActions.Delete;
ForbiddenError.from(permission).throwUnlessCan(
action,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: commit.key,
secretTags: commitTagIds?.[commit.key]?.map((secretTagId) => tagsGroupById[secretTagId][0].slug)
})
);
});
const secretApprovalRequest = await secretApprovalRequestDAL.transaction(async (tx) => { const secretApprovalRequest = await secretApprovalRequestDAL.transaction(async (tx) => {
const doc = await secretApprovalRequestDAL.create( const doc = await secretApprovalRequestDAL.create(
@@ -28,7 +28,8 @@ import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret
import { import {
fnSecretBulkInsert as fnSecretV2BridgeBulkInsert, fnSecretBulkInsert as fnSecretV2BridgeBulkInsert,
fnSecretBulkUpdate as fnSecretV2BridgeBulkUpdate, fnSecretBulkUpdate as fnSecretV2BridgeBulkUpdate,
getAllSecretReferences getAllNestedSecretReferences,
getAllNestedSecretReferences as getAllNestedSecretReferencesV2Bridge
} from "@app/services/secret-v2-bridge/secret-v2-bridge-fns"; } from "@app/services/secret-v2-bridge/secret-v2-bridge-fns";
import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secret-version-dal";
import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal";
@@ -252,12 +253,11 @@ export const secretReplicationServiceFactory = ({
const sourceLocalSecrets = await secretV2BridgeDAL.find({ folderId: folder.id, type: SecretType.Shared }); const sourceLocalSecrets = await secretV2BridgeDAL.find({ folderId: folder.id, type: SecretType.Shared });
const sourceSecretImports = await secretImportDAL.find({ folderId: folder.id }); const sourceSecretImports = await secretImportDAL.find({ folderId: folder.id });
const sourceImportedSecrets = await fnSecretsV2FromImports({ const sourceImportedSecrets = await fnSecretsV2FromImports({
secretImports: sourceSecretImports, allowedImports: sourceSecretImports,
secretDAL: secretV2BridgeDAL, secretDAL: secretV2BridgeDAL,
folderDAL, folderDAL,
secretImportDAL, secretImportDAL,
decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : ""), decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "")
hasSecretAccess: () => true
}); });
// secrets that gets replicated across imports // secrets that gets replicated across imports
const sourceDecryptedLocalSecrets = sourceLocalSecrets.map((el) => ({ const sourceDecryptedLocalSecrets = sourceLocalSecrets.map((el) => ({
@@ -416,7 +416,7 @@ export const secretReplicationServiceFactory = ({
encryptedValue: doc.encryptedValue, encryptedValue: doc.encryptedValue,
encryptedComment: doc.encryptedComment, encryptedComment: doc.encryptedComment,
skipMultilineEncoding: doc.skipMultilineEncoding, skipMultilineEncoding: doc.skipMultilineEncoding,
references: doc.secretValue ? getAllSecretReferences(doc.secretValue).nestedReferences : [] references: doc.secretValue ? getAllNestedSecretReferencesV2Bridge(doc.secretValue) : []
}; };
}) })
}); });
@@ -442,7 +442,7 @@ export const secretReplicationServiceFactory = ({
encryptedValue: doc.encryptedValue as Buffer, encryptedValue: doc.encryptedValue as Buffer,
encryptedComment: doc.encryptedComment, encryptedComment: doc.encryptedComment,
skipMultilineEncoding: doc.skipMultilineEncoding, skipMultilineEncoding: doc.skipMultilineEncoding,
references: doc.secretValue ? getAllSecretReferences(doc.secretValue).nestedReferences : [] references: doc.secretValue ? getAllNestedSecretReferencesV2Bridge(doc.secretValue) : []
} }
}; };
}) })
@@ -687,7 +687,7 @@ export const secretReplicationServiceFactory = ({
secretCommentTag: doc.secretCommentTag, secretCommentTag: doc.secretCommentTag,
secretCommentCiphertext: doc.secretCommentCiphertext, secretCommentCiphertext: doc.secretCommentCiphertext,
skipMultilineEncoding: doc.skipMultilineEncoding, skipMultilineEncoding: doc.skipMultilineEncoding,
references: getAllSecretReferences(doc.secretValue).nestedReferences references: getAllNestedSecretReferences(doc.secretValue)
}; };
}) })
}); });
@@ -723,7 +723,7 @@ export const secretReplicationServiceFactory = ({
secretCommentTag: doc.secretCommentTag, secretCommentTag: doc.secretCommentTag,
secretCommentCiphertext: doc.secretCommentCiphertext, secretCommentCiphertext: doc.secretCommentCiphertext,
skipMultilineEncoding: doc.skipMultilineEncoding, skipMultilineEncoding: doc.skipMultilineEncoding,
references: getAllSecretReferences(doc.secretValue).nestedReferences references: getAllNestedSecretReferences(doc.secretValue)
} }
}; };
}) })
@@ -1,7 +1,7 @@
import { ForbiddenError, subject } from "@casl/ability"; import { ForbiddenError, subject } from "@casl/ability";
import Ajv from "ajv"; import Ajv from "ajv";
import { ProjectVersion, TableName } from "@app/db/schemas"; import { ProjectVersion } from "@app/db/schemas";
import { decryptSymmetric128BitHexKeyUTF8, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { decryptSymmetric128BitHexKeyUTF8, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { TProjectPermission } from "@app/lib/types"; import { TProjectPermission } from "@app/lib/types";
@@ -99,14 +99,13 @@ export const secretRotationServiceFactory = ({
ProjectPermissionActions.Edit, ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment, secretPath }) subject(ProjectPermissionSub.Secrets, { environment, secretPath })
); );
const project = await projectDAL.findById(projectId); const project = await projectDAL.findById(projectId);
const shouldUseBridge = project.version === ProjectVersion.V3; const shouldUseBridge = project.version === ProjectVersion.V3;
if (shouldUseBridge) { if (shouldUseBridge) {
const selectedSecrets = await secretV2BridgeDAL.find({ const selectedSecrets = await secretV2BridgeDAL.find({
folderId: folder.id, folderId: folder.id,
$in: { [`${TableName.SecretV2}.id` as "id"]: Object.values(outputs) } $in: { id: Object.values(outputs) }
}); });
if (selectedSecrets.length !== Object.values(outputs).length) if (selectedSecrets.length !== Object.values(outputs).length)
throw new NotFoundError({ message: "Secrets not found" }); throw new NotFoundError({ message: "Secrets not found" });
+111
View File
@@ -0,0 +1,111 @@
import { AnyAbility, ExtractSubjectType } from "@casl/ability";
import { AbilityQuery, rulesToQuery } from "@casl/ability/extra";
import { Tables } from "knex/types/tables";
import { BadRequestError, UnauthorizedError } from "../errors";
import { TKnexDynamicOperator } from "../knex/dynamic";
type TBuildKnexQueryFromCaslDTO<K extends AnyAbility> = {
ability: K;
subject: ExtractSubjectType<Parameters<K["rulesFor"]>[1]>;
action: Parameters<K["rulesFor"]>[0];
};
export const buildKnexQueryFromCaslOperators = <K extends AnyAbility>({
ability,
subject,
action
}: TBuildKnexQueryFromCaslDTO<K>) => {
const query = rulesToQuery(ability, action, subject, (rule) => {
if (!rule.ast) throw new Error("Ast not defined");
return rule.ast;
});
if (query === null) throw new UnauthorizedError({ message: `You don't have permission to do ${action} ${subject}` });
return query;
};
type TFieldMapper<T extends keyof Tables> = {
[K in T]: `${K}.${Exclude<keyof Tables[K]["base"], symbol>}`;
}[T];
type TFormatCaslFieldsWithTableNames<T extends keyof Tables> = {
// handle if any missing operator else throw error let the app break because this is executing again the db
missingOperatorCallback?: (operator: string) => void;
fieldMapping: (arg: string) => TFieldMapper<T> | null;
dynamicQuery: TKnexDynamicOperator;
};
export const formatCaslOperatorFieldsWithTableNames = <T extends keyof Tables>({
missingOperatorCallback = (arg) => {
throw new BadRequestError({ message: `Unknown permission operator: ${arg}` });
},
dynamicQuery: dynamicQueryAst,
fieldMapping
}: TFormatCaslFieldsWithTableNames<T>) => {
const stack: [TKnexDynamicOperator, TKnexDynamicOperator | null][] = [[dynamicQueryAst, null]];
while (stack.length) {
const [filterAst, parentAst] = stack.pop()!;
if (filterAst.operator === "and" || filterAst.operator === "or" || filterAst.operator === "not") {
filterAst.value.forEach((el) => {
stack.push([el, filterAst]);
});
// eslint-disable-next-line no-continue
continue;
}
if (
filterAst.operator === "eq" ||
filterAst.operator === "ne" ||
filterAst.operator === "in" ||
filterAst.operator === "endsWith" ||
filterAst.operator === "startsWith"
) {
const attrPath = fieldMapping(filterAst.field);
if (attrPath) {
filterAst.field = attrPath;
} else if (parentAst && Array.isArray(parentAst.value)) {
parentAst.value = parentAst.value.filter((childAst) => childAst !== filterAst) as string[];
} else throw new Error("Unknown casl field");
// eslint-disable-next-line no-continue
continue;
}
if (parentAst && Array.isArray(parentAst.value)) {
parentAst.value = parentAst.value.filter((childAst) => childAst !== filterAst) as string[];
} else {
missingOperatorCallback?.(filterAst.operator);
}
}
return dynamicQueryAst;
};
export const convertCaslOperatorToKnexOperator = <T extends keyof Tables>(
caslKnexOperators: AbilityQuery,
fieldMapping: (arg: string) => TFieldMapper<T> | null
) => {
const value = [];
if (caslKnexOperators.$and) {
value.push({
operator: "not" as const,
value: caslKnexOperators.$and as TKnexDynamicOperator[]
});
}
if (caslKnexOperators.$or) {
value.push({
operator: "or" as const,
value: caslKnexOperators.$or as TKnexDynamicOperator[]
});
}
return formatCaslOperatorFieldsWithTableNames({
dynamicQuery: {
operator: "and",
value
},
fieldMapping
});
};
-22
View File
@@ -81,25 +81,3 @@ export const chunkArray = <T>(array: T[], chunkSize: number): T[][] => {
} }
return chunks; return chunks;
}; };
/*
* Returns all items from the first list that
* do not exist in the second list.
*/
export const diff = <T>(
root: readonly T[],
other: readonly T[],
identity: (item: T) => string | number | symbol = (t: T) => t as unknown as string | number | symbol
): T[] => {
if (!root?.length && !other?.length) return [];
if (root?.length === undefined) return [...other];
if (!other?.length) return [...root];
const bKeys = other.reduce(
(acc, item) => {
acc[identity(item)] = true;
return acc;
},
{} as Record<string | number | symbol, boolean>
);
return root.filter((a) => !bKeys[identity(a)]);
};
+31 -21
View File
@@ -2,31 +2,32 @@ import { Knex } from "knex";
import { UnauthorizedError } from "../errors"; import { UnauthorizedError } from "../errors";
type TKnexDynamicPrimitiveOperator<T extends object> = { type TKnexDynamicPrimitiveOperator = {
operator: "eq" | "ne" | "startsWith" | "endsWith"; operator: "eq" | "ne" | "startsWith" | "endsWith";
value: string; value: string;
field: Extract<keyof T, string>; field: string;
}; };
type TKnexDynamicInOperator<T extends object> = { type TKnexDynamicInOperator = {
operator: "in"; operator: "in";
value: string[] | number[]; value: string[] | number[];
field: Extract<keyof T, string>; field: string;
}; };
type TKnexNonGroupOperator<T extends object> = TKnexDynamicInOperator<T> | TKnexDynamicPrimitiveOperator<T>; type TKnexNonGroupOperator = TKnexDynamicInOperator | TKnexDynamicPrimitiveOperator;
type TKnexGroupOperator<T extends object> = { type TKnexGroupOperator = {
operator: "and" | "or" | "not"; operator: "and" | "or" | "not";
value: (TKnexNonGroupOperator<T> | TKnexGroupOperator<T>)[]; value: (TKnexNonGroupOperator | TKnexGroupOperator)[];
}; };
export type TKnexDynamicOperator<T extends object> = TKnexGroupOperator<T> | TKnexNonGroupOperator<T>; // akhilmhdh: This is still in pending state and not yet ready. If you want to use it ping me.
// used when you need to write a complex query with the orm
// use it when you need complex or and and condition - most of the time not needed
// majorly used with casl permission to filter data based on permission
export type TKnexDynamicOperator = TKnexGroupOperator | TKnexNonGroupOperator;
export const buildDynamicKnexQuery = <T extends object>( export const buildDynamicKnexQuery = (dynamicQuery: TKnexDynamicOperator, rootQueryBuild: Knex.QueryBuilder) => {
rootQueryBuild: Knex.QueryBuilder,
dynamicQuery: TKnexDynamicOperator<T>
) => {
const stack = [{ filterAst: dynamicQuery, queryBuilder: rootQueryBuild }]; const stack = [{ filterAst: dynamicQuery, queryBuilder: rootQueryBuild }];
while (stack.length) { while (stack.length) {
@@ -49,25 +50,34 @@ export const buildDynamicKnexQuery = <T extends object>(
break; break;
} }
case "and": { case "and": {
filterAst.value.forEach((el) => { void queryBuilder.andWhere((subQueryBuilder) => {
void queryBuilder.andWhere((subQueryBuilder) => { filterAst.value.forEach((el) => {
buildDynamicKnexQuery(subQueryBuilder, el); stack.push({
queryBuilder: subQueryBuilder,
filterAst: el
});
}); });
}); });
break; break;
} }
case "or": { case "or": {
filterAst.value.forEach((el) => { void queryBuilder.orWhere((subQueryBuilder) => {
void queryBuilder.orWhere((subQueryBuilder) => { filterAst.value.forEach((el) => {
buildDynamicKnexQuery(subQueryBuilder, el); stack.push({
queryBuilder: subQueryBuilder,
filterAst: el
});
}); });
}); });
break; break;
} }
case "not": { case "not": {
filterAst.value.forEach((el) => { void queryBuilder.whereNot((subQueryBuilder) => {
void queryBuilder.whereNot((subQueryBuilder) => { filterAst.value.forEach((el) => {
buildDynamicKnexQuery(subQueryBuilder, el); stack.push({
queryBuilder: subQueryBuilder,
filterAst: el
});
}); });
}); });
break; break;
+1 -6
View File
@@ -3,7 +3,6 @@ import { Knex } from "knex";
import { Tables } from "knex/types/tables"; import { Tables } from "knex/types/tables";
import { DatabaseError } from "../errors"; import { DatabaseError } from "../errors";
import { buildDynamicKnexQuery, TKnexDynamicOperator } from "./dynamic";
export * from "./connection"; export * from "./connection";
export * from "./join"; export * from "./join";
@@ -21,10 +20,9 @@ export const withTransaction = <K extends object>(db: Knex, dal: K) => ({
export type TFindFilter<R extends object = object> = Partial<R> & { export type TFindFilter<R extends object = object> = Partial<R> & {
$in?: Partial<{ [k in keyof R]: R[k][] }>; $in?: Partial<{ [k in keyof R]: R[k][] }>;
$search?: Partial<{ [k in keyof R]: R[k] }>; $search?: Partial<{ [k in keyof R]: R[k] }>;
$complex?: TKnexDynamicOperator<R>;
}; };
export const buildFindFilter = export const buildFindFilter =
<R extends object = object>({ $in, $search, $complex, ...filter }: TFindFilter<R>) => <R extends object = object>({ $in, $search, ...filter }: TFindFilter<R>) =>
(bd: Knex.QueryBuilder<R, R>) => { (bd: Knex.QueryBuilder<R, R>) => {
void bd.where(filter); void bd.where(filter);
if ($in) { if ($in) {
@@ -41,9 +39,6 @@ export const buildFindFilter =
} }
}); });
} }
if ($complex) {
return buildDynamicKnexQuery(bd, $complex);
}
return bd; return bd;
}; };
+1 -1
View File
@@ -61,7 +61,7 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
void res.status(HttpStatusCodes.Forbidden).send({ void res.status(HttpStatusCodes.Forbidden).send({
statusCode: HttpStatusCodes.Forbidden, statusCode: HttpStatusCodes.Forbidden,
error: "PermissionDenied", error: "PermissionDenied",
message: `You are not allowed to ${error.action} on ${error.subjectType} - ${JSON.stringify(error.subject)}` message: `You are not allowed to ${error.action} on ${error.subjectType}`
}); });
} else if (error instanceof ForbiddenRequestError) { } else if (error instanceof ForbiddenRequestError) {
void res.status(HttpStatusCodes.Forbidden).send({ void res.status(HttpStatusCodes.Forbidden).send({
+1 -8
View File
@@ -5,7 +5,6 @@ import { z } from "zod";
import { registerCertificateEstRouter } from "@app/ee/routes/est/certificate-est-router"; import { registerCertificateEstRouter } from "@app/ee/routes/est/certificate-est-router";
import { registerV1EERoutes } from "@app/ee/routes/v1"; import { registerV1EERoutes } from "@app/ee/routes/v1";
import { registerV2EERoutes } from "@app/ee/routes/v2";
import { accessApprovalPolicyApproverDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-approver-dal"; import { accessApprovalPolicyApproverDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-approver-dal";
import { accessApprovalPolicyDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-dal"; import { accessApprovalPolicyDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-dal";
import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service"; import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service";
@@ -1425,13 +1424,7 @@ export const registerRoutes = async (
}, },
{ prefix: "/api/v1" } { prefix: "/api/v1" }
); );
await server.register( await server.register(registerV2Routes, { prefix: "/api/v2" });
async (v2Server) => {
await v2Server.register(registerV2EERoutes);
await v2Server.register(registerV2Routes);
},
{ prefix: "/api/v2" }
);
await server.register(registerV3Routes, { prefix: "/api/v3" }); await server.register(registerV3Routes, { prefix: "/api/v3" });
server.addHook("onClose", async () => { server.addHook("onClose", async () => {
@@ -9,10 +9,9 @@ import {
SecretApprovalPoliciesSchema, SecretApprovalPoliciesSchema,
UsersSchema UsersSchema
} from "@app/db/schemas"; } from "@app/db/schemas";
import { UnpackedPermissionSchema } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { UnpackedPermissionSchema } from "./santizedSchemas/permission";
// sometimes the return data must be santizied to avoid leaking important values // sometimes the return data must be santizied to avoid leaking important values
// always prefer pick over omit in zod // always prefer pick over omit in zod
export const integrationAuthPubSchema = IntegrationAuthsSchema.pick({ export const integrationAuthPubSchema = IntegrationAuthsSchema.pick({
@@ -1,11 +0,0 @@
import { z } from "zod";
export const UnpackedPermissionSchema = z.object({
subject: z
.union([z.string().min(1), z.string().array()])
.transform((el) => (typeof el !== "string" ? el[0] : el))
.optional(),
action: z.union([z.string().min(1), z.string().array()]).transform((el) => (typeof el === "string" ? [el] : el)),
conditions: z.unknown().optional(),
inverted: z.boolean().optional()
});
@@ -3,10 +3,7 @@ import { z } from "zod";
import { SecretFoldersSchema, SecretImportsSchema, SecretTagsSchema } from "@app/db/schemas"; import { SecretFoldersSchema, SecretImportsSchema, SecretTagsSchema } from "@app/db/schemas";
import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types";
import { import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
ProjectPermissionDynamicSecretActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { DASHBOARD } from "@app/lib/api-docs"; import { DASHBOARD } from "@app/lib/api-docs";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn"; import { removeTrailingSlash } from "@app/lib/fn";
@@ -195,15 +192,15 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
req.permission.orgId req.permission.orgId
); );
const allowedDynamicSecretEnvironments = // filter envs user has access to const permissiveEnvs = // filter envs user has access to
environments.filter((environment) => environments.filter((environment) =>
permission.can( permission.can(
ProjectPermissionDynamicSecretActions.Lease, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath }) subject(ProjectPermissionSub.Secrets, { environment, secretPath })
) )
); );
if (includeDynamicSecrets && allowedDynamicSecretEnvironments.length) { if (includeDynamicSecrets && permissiveEnvs.length) {
// this is the unique count, ie duplicate secrets across envs only count as 1 // this is the unique count, ie duplicate secrets across envs only count as 1
totalDynamicSecretCount = await server.services.dynamicSecret.getCountMultiEnv({ totalDynamicSecretCount = await server.services.dynamicSecret.getCountMultiEnv({
actor: req.permission.type, actor: req.permission.type,
@@ -212,7 +209,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
projectId, projectId,
search, search,
environmentSlugs: allowedDynamicSecretEnvironments, environmentSlugs: permissiveEnvs,
path: secretPath, path: secretPath,
isInternal: true isInternal: true
}); });
@@ -227,7 +224,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
search, search,
orderBy, orderBy,
orderDirection, orderDirection,
environmentSlugs: allowedDynamicSecretEnvironments, environmentSlugs: permissiveEnvs,
path: secretPath, path: secretPath,
limit: remainingLimit, limit: remainingLimit,
offset: adjustedOffset, offset: adjustedOffset,
@@ -244,13 +241,13 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
} }
} }
if (includeSecrets) { if (includeSecrets && permissiveEnvs.length) {
// this is the unique count, ie duplicate secrets across envs only count as 1 // this is the unique count, ie duplicate secrets across envs only count as 1
totalSecretCount = await server.services.secret.getSecretsCountMultiEnv({ totalSecretCount = await server.services.secret.getSecretsCountMultiEnv({
actorId: req.permission.id, actorId: req.permission.id,
actor: req.permission.type, actor: req.permission.type,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
environments, environments: permissiveEnvs,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
projectId, projectId,
path: secretPath, path: secretPath,
@@ -263,7 +260,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
actorId: req.permission.id, actorId: req.permission.id,
actor: req.permission.type, actor: req.permission.type,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
environments, environments: permissiveEnvs,
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
projectId, projectId,
path: secretPath, path: secretPath,
@@ -275,7 +272,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
isInternal: true isInternal: true
}); });
for await (const environment of environments) { for await (const environment of permissiveEnvs) {
const secretCountFromEnv = secrets.filter((secret) => secret.environment === environment).length; const secretCountFromEnv = secrets.filter((secret) => secret.environment === environment).length;
if (secretCountFromEnv) { if (secretCountFromEnv) {
@@ -19,7 +19,7 @@ import { TProjectEnvServiceFactory } from "../project-env/project-env-service";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal"; import { TSecretTagDALFactory } from "../secret-tag/secret-tag-dal";
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
import { fnSecretBulkInsert, getAllSecretReferences } from "../secret-v2-bridge/secret-v2-bridge-fns"; import { fnSecretBulkInsert, getAllNestedSecretReferences } from "../secret-v2-bridge/secret-v2-bridge-fns";
import type { TSecretV2BridgeServiceFactory } from "../secret-v2-bridge/secret-v2-bridge-service"; import type { TSecretV2BridgeServiceFactory } from "../secret-v2-bridge/secret-v2-bridge-service";
import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal";
import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal"; import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal";
@@ -242,7 +242,7 @@ export const importDataIntoInfisicalFn = async ({
} }
await fnSecretBulkInsert({ await fnSecretBulkInsert({
inputSecrets: secretBatch.map((el) => { inputSecrets: secretBatch.map((el) => {
const references = getAllSecretReferences(el.secretValue).nestedReferences; const references = getAllNestedSecretReferences(el.secretValue);
return { return {
version: 1, version: 1,
@@ -67,8 +67,7 @@ const getIntegrationSecretsV2 = async (
folderDAL, folderDAL,
secretDAL: secretV2BridgeDAL, secretDAL: secretV2BridgeDAL,
secretImportDAL, secretImportDAL,
secretImports, allowedImports: secretImports
hasSecretAccess: () => true
}); });
for (let i = importedSecrets.length - 1; i >= 0; i -= 1) { for (let i = importedSecrets.length - 1; i >= 0; i -= 1) {
@@ -89,10 +89,7 @@ export const integrationServiceFactory = ({
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { subject(ProjectPermissionSub.Secrets, { environment: sourceEnvironment, secretPath })
environment: sourceEnvironment,
secretPath
})
); );
const folder = await folderDAL.findBySecretPath(integrationAuth.projectId, sourceEnvironment, secretPath); const folder = await folderDAL.findBySecretPath(integrationAuth.projectId, sourceEnvironment, secretPath);
@@ -165,10 +162,7 @@ export const integrationServiceFactory = ({
if (environment || secretPath) { if (environment || secretPath) {
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { subject(ProjectPermissionSub.Secrets, { environment: newEnvironment, secretPath: newSecretPath })
environment: newEnvironment,
secretPath: newSecretPath
})
); );
} }
@@ -2,6 +2,7 @@ import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability";
import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; import { PackRule, packRules, unpackRules } from "@casl/ability/extra";
import { ProjectMembershipRole } from "@app/db/schemas"; import { ProjectMembershipRole } from "@app/db/schemas";
import { UnpackedPermissionSchema } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
@@ -9,7 +10,6 @@ import {
ProjectPermissionSub ProjectPermissionSub
} from "@app/ee/services/permission/project-permission"; } from "@app/ee/services/permission/project-permission";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { UnpackedPermissionSchema } from "@app/server/routes/santizedSchemas/permission";
import { ActorAuthMethod } from "../auth/auth-type"; import { ActorAuthMethod } from "../auth/auth-type";
import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal";
@@ -0,0 +1,6 @@
import { RawRule } from "@casl/ability";
import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
export const shouldCheckFolderPermission = (rules: RawRule[]) =>
rules.some((rule) => (rule.subject as ProjectPermissionSub[]).includes(ProjectPermissionSub.SecretFolders));
@@ -12,6 +12,7 @@ import { OrderByDirection } from "@app/lib/types";
import { TProjectDALFactory } from "../project/project-dal"; import { TProjectDALFactory } from "../project/project-dal";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TSecretFolderDALFactory } from "./secret-folder-dal"; import { TSecretFolderDALFactory } from "./secret-folder-dal";
import { shouldCheckFolderPermission } from "./secret-folder-fns";
import { import {
TCreateFolderDTO, TCreateFolderDTO,
TDeleteFolderDTO, TDeleteFolderDTO,
@@ -59,10 +60,20 @@ export const secretFolderServiceFactory = ({
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan( // we do this because we've split Secret and SecretFolder resources
ProjectPermissionActions.Create, // previously, if one can create/update/read/delete secrets then they can do the same for folders
subject(ProjectPermissionSub.SecretFolders, { environment, secretPath }) // for backwards compatibility, we handle authorization only when SecretFolders subject is used
); if (shouldCheckFolderPermission(permission.rules)) {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })
);
} else {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
);
}
const env = await projectEnvDAL.findOne({ projectId, slug: environment }); const env = await projectEnvDAL.findOne({ projectId, slug: environment });
if (!env) throw new NotFoundError({ message: "Environment not found", name: "Create folder" }); if (!env) throw new NotFoundError({ message: "Environment not found", name: "Create folder" });
@@ -150,10 +161,20 @@ export const secretFolderServiceFactory = ({
); );
folders.forEach(({ environment, path: secretPath }) => { folders.forEach(({ environment, path: secretPath }) => {
ForbiddenError.from(permission).throwUnlessCan( // we do this because we've split Secret and SecretFolder resources
ProjectPermissionActions.Edit, // previously, if one can create/update/read/delete secrets then they can do the same for folders
subject(ProjectPermissionSub.SecretFolders, { environment, secretPath }) // for backwards compatibility, we handle authorization only when SecretFolders subject is used
); if (shouldCheckFolderPermission(permission.rules)) {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })
);
} else {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
);
}
}); });
const result = await folderDAL.transaction(async (tx) => const result = await folderDAL.transaction(async (tx) =>
@@ -246,10 +267,20 @@ export const secretFolderServiceFactory = ({
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan( // we do this because we've split Secret and SecretFolder resources
ProjectPermissionActions.Edit, // previously, if one can create/update/read/delete secrets then they can do the same for folders
subject(ProjectPermissionSub.SecretFolders, { environment, secretPath }) // for backwards compatibility, we handle authorization differently only when SecretFolders subject is used
); if (shouldCheckFolderPermission(permission.rules)) {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })
);
} else {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
);
}
const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath); const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!parentFolder) throw new NotFoundError({ message: "Secret path not found" }); if (!parentFolder) throw new NotFoundError({ message: "Secret path not found" });
@@ -320,10 +351,20 @@ export const secretFolderServiceFactory = ({
actorOrgId actorOrgId
); );
ForbiddenError.from(permission).throwUnlessCan( // we do this because we've split Secret and SecretFolder resources
ProjectPermissionActions.Delete, // previously, if one can create/update/read/delete secrets then they can do the same for folders
subject(ProjectPermissionSub.SecretFolders, { environment, secretPath }) // for backwards compatibility, we handle authorization differently only when SecretFolders subject is used
); if (shouldCheckFolderPermission(permission.rules)) {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })
);
} else {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
);
}
const env = await projectEnvDAL.findOne({ projectId, slug: environment }); const env = await projectEnvDAL.findOne({ projectId, slug: environment });
if (!env) throw new NotFoundError({ message: "Environment not found", name: "Create folder" }); if (!env) throw new NotFoundError({ message: "Environment not found", name: "Create folder" });
@@ -27,7 +27,6 @@ type TSecretImportSecretsV2 = {
slug: string; slug: string;
name: string; name: string;
}; };
id: string;
folderId: string | undefined; folderId: string | undefined;
importFolderId: string; importFolderId: string;
secrets: (TSecretsV2 & { secrets: (TSecretsV2 & {
@@ -140,22 +139,24 @@ export const fnSecretsFromImports = async ({
return secrets; return secrets;
}; };
/* eslint-disable no-await-in-loop, no-continue */
export const fnSecretsV2FromImports = async ({ export const fnSecretsV2FromImports = async ({
secretImports: rootSecretImports, allowedImports: possibleCyclicImports,
folderDAL, folderDAL,
secretDAL, secretDAL,
secretImportDAL, secretImportDAL,
depth = 0,
cyclicDetector = new Set(),
decryptor, decryptor,
expandSecretReferences, expandSecretReferences
hasSecretAccess
}: { }: {
secretImports: (Omit<TSecretImports, "importEnv"> & { allowedImports: (Omit<TSecretImports, "importEnv"> & {
importEnv: { id: string; slug: string; name: string }; importEnv: { id: string; slug: string; name: string };
})[]; })[];
folderDAL: Pick<TSecretFolderDALFactory, "findByManySecretPath">; folderDAL: Pick<TSecretFolderDALFactory, "findByManySecretPath">;
secretDAL: Pick<TSecretV2BridgeDALFactory, "find">; secretDAL: Pick<TSecretV2BridgeDALFactory, "find">;
secretImportDAL: Pick<TSecretImportDALFactory, "findByFolderIds">; secretImportDAL: Pick<TSecretImportDALFactory, "findByFolderIds">;
depth?: number;
cyclicDetector?: Set<string>;
decryptor: (value?: Buffer | null) => string; decryptor: (value?: Buffer | null) => string;
expandSecretReferences?: (inputSecret: { expandSecretReferences?: (inputSecret: {
value?: string; value?: string;
@@ -163,107 +164,92 @@ export const fnSecretsV2FromImports = async ({
secretPath: string; secretPath: string;
environment: string; environment: string;
}) => Promise<string | undefined>; }) => Promise<string | undefined>;
hasSecretAccess: (environment: string, secretPath: string, secretName: string, secretTagSlugs: string[]) => boolean;
}) => { }) => {
const cyclicDetector = new Set(); // avoid going more than a depth
const stack: { secretImports: typeof rootSecretImports; depth: number; parentImportedSecrets: TSecretsV2[] }[] = [ if (depth >= LEVEL_BREAK) return [];
{ secretImports: rootSecretImports, depth: 0, parentImportedSecrets: [] }
];
const processedImports: TSecretImportSecretsV2[] = []; const allowedImports = possibleCyclicImports.filter(
({ importPath, importEnv }) => !cyclicDetector.has(getImportUniqKey(importEnv.slug, importPath))
);
while (stack.length) { const importedFolders = (
const { secretImports, depth, parentImportedSecrets } = stack.pop()!; await folderDAL.findByManySecretPath(
allowedImports.map(({ importEnv, importPath }) => ({
if (depth > LEVEL_BREAK) continue;
const sanitizedImports = secretImports.filter(
({ importPath, importEnv }) => !cyclicDetector.has(getImportUniqKey(importEnv.slug, importPath))
);
if (!sanitizedImports.length) continue;
const importedFolders = await folderDAL.findByManySecretPath(
sanitizedImports.map(({ importEnv, importPath }) => ({
envId: importEnv.id, envId: importEnv.id,
secretPath: importPath secretPath: importPath
})) }))
); )
if (!importedFolders.length) continue; ).filter(Boolean); // remove undefined ones
if (!importedFolders.length) {
return [];
}
const importedFolderIds = importedFolders.map((el) => el?.id) as string[]; const importedFolderIds = importedFolders.map((el) => el?.id) as string[];
const importedFolderGroupBySourceImport = groupBy(importedFolders, (i) => `${i?.envId}-${i?.path}`); const importedFolderGroupBySourceImport = groupBy(importedFolders, (i) => `${i?.envId}-${i?.path}`);
const importedSecrets = await secretDAL.find(
{
$in: { folderId: importedFolderIds },
type: SecretType.Shared
},
{
sort: [["id", "asc"]]
}
);
const importedSecrets = await secretDAL.find( const importedSecretsGroupByFolderId = groupBy(importedSecrets, (i) => i.folderId);
{
$in: { folderId: importedFolderIds },
type: SecretType.Shared
},
{
sort: [["id", "asc"]]
}
);
const importedSecretsGroupByFolderId = groupBy(importedSecrets, (i) => i.folderId);
sanitizedImports.forEach(({ importPath, importEnv }) => { allowedImports.forEach(({ importPath, importEnv }) => {
cyclicDetector.add(getImportUniqKey(importEnv.slug, importPath)); cyclicDetector.add(getImportUniqKey(importEnv.slug, importPath));
}); });
// now we need to check recursively deeper imports made inside other imports // now we need to check recursively deeper imports made inside other imports
// we go level wise meaning we take all imports of a tree level and then go deeper ones level by level // we go level wise meaning we take all imports of a tree level and then go deeper ones level by level
const deeperImports = await secretImportDAL.findByFolderIds(importedFolderIds); const deeperImports = await secretImportDAL.findByFolderIds(importedFolderIds);
const deeperImportsGroupByFolderId = groupBy(deeperImports, (i) => i.folderId); let secretsFromDeeperImports: TSecretImportSecretsV2[] = [];
if (deeperImports.length) {
const isFirstIteration = !processedImports.length; secretsFromDeeperImports = await fnSecretsV2FromImports({
sanitizedImports.forEach(({ importPath, importEnv, id, folderId }, i) => { allowedImports: deeperImports.filter(({ isReplication }) => !isReplication),
const sourceImportFolder = importedFolderGroupBySourceImport[`${importEnv.id}-${importPath}`]?.[0]; secretImportDAL,
const secretsWithDuplicate = (importedSecretsGroupByFolderId?.[importedFolders?.[i]?.id as string] || []) folderDAL,
.filter((item) => secretDAL,
hasSecretAccess( depth: depth + 1,
importEnv.slug, cyclicDetector,
importPath, decryptor,
item.key, expandSecretReferences
item.tags.map((el) => el.slug)
)
)
.map((item) => ({
...item,
secretKey: item.key,
secretValue: decryptor(item.encryptedValue),
secretComment: decryptor(item.encryptedComment),
environment: importEnv.slug,
workspace: "", // This field should not be used, it's only here to keep the older Python SDK versions backwards compatible with the new Postgres backend.
_id: item.id // The old Python SDK depends on the _id field being returned. We return this to keep the older Python SDK versions backwards compatible with the new Postgres backend.
}));
if (deeperImportsGroupByFolderId?.[sourceImportFolder?.id || ""]) {
stack.push({
secretImports: deeperImportsGroupByFolderId[sourceImportFolder?.id || ""],
depth: depth + 1,
parentImportedSecrets: secretsWithDuplicate
});
}
if (isFirstIteration) {
processedImports.push({
secretPath: importPath,
environment: importEnv.slug,
environmentInfo: importEnv,
folderId: importedFolders?.[i]?.id,
id,
importFolderId: folderId,
secrets: secretsWithDuplicate
});
} else {
parentImportedSecrets.push(...secretsWithDuplicate);
}
}); });
} }
/* eslint-enable */ const secretsFromdeeperImportGroupedByFolderId = groupBy(secretsFromDeeperImports, (i) => i.importFolderId);
const processedImports = allowedImports.map(({ importPath, importEnv, id, folderId }, i) => {
const sourceImportFolder = importedFolderGroupBySourceImport[`${importEnv.id}-${importPath}`]?.[0];
const folderDeeperImportSecrets =
secretsFromdeeperImportGroupedByFolderId?.[sourceImportFolder?.id || ""]?.[0]?.secrets || [];
const secretsWithDuplicate = (importedSecretsGroupByFolderId?.[importedFolders?.[i]?.id as string] || [])
.map((item) => ({
...item,
secretKey: item.key,
secretValue: decryptor(item.encryptedValue),
secretComment: decryptor(item.encryptedComment),
environment: importEnv.slug,
workspace: "", // This field should not be used, it's only here to keep the older Python SDK versions backwards compatible with the new Postgres backend.
_id: item.id // The old Python SDK depends on the _id field being returned. We return this to keep the older Python SDK versions backwards compatible with the new Postgres backend.
}))
.concat(folderDeeperImportSecrets);
return {
secretPath: importPath,
environment: importEnv.slug,
environmentInfo: importEnv,
folderId: importedFolders?.[i]?.id,
id,
importFolderId: folderId,
secrets: unique(secretsWithDuplicate, (el) => el.secretKey)
};
});
if (expandSecretReferences) { if (expandSecretReferences) {
await Promise.allSettled( await Promise.allSettled(
processedImports.map((processedImport) => { processedImports.map((processedImport) =>
// eslint-disable-next-line Promise.allSettled(
processedImport.secrets = unique(processedImport.secrets, (i) => i.key);
return Promise.allSettled(
processedImport.secrets.map(async (decryptedSecret, index) => { processedImport.secrets.map(async (decryptedSecret, index) => {
const expandedSecretValue = await expandSecretReferences({ const expandedSecretValue = await expandSecretReferences({
value: decryptedSecret.secretValue, value: decryptedSecret.secretValue,
@@ -274,8 +260,8 @@ export const fnSecretsV2FromImports = async ({
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
processedImport.secrets[index].secretValue = expandedSecretValue || ""; processedImport.secrets[index].secretValue = expandedSecretValue || "";
}) })
); )
}) )
); );
} }
@@ -84,12 +84,12 @@ export const secretImportServiceFactory = ({
// check if user has permission to import into destination path // check if user has permission to import into destination path
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create, ProjectPermissionActions.Create,
subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) subject(ProjectPermissionSub.Secrets, { environment, secretPath })
); );
// check if user has permission to import from target path // check if user has permission to import from target path
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read, ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, { subject(ProjectPermissionSub.Secrets, {
environment: data.environment, environment: data.environment,
secretPath: data.path secretPath: data.path
@@ -191,7 +191,7 @@ export const secretImportServiceFactory = ({
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit, ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) subject(ProjectPermissionSub.Secrets, { environment, secretPath })
); );
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
@@ -277,7 +277,7 @@ export const secretImportServiceFactory = ({
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete, ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) subject(ProjectPermissionSub.Secrets, { environment, secretPath })
); );
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
@@ -342,8 +342,8 @@ export const secretImportServiceFactory = ({
// check if user has permission to import into destination path // check if user has permission to import into destination path
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit, ProjectPermissionActions.Create,
subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) subject(ProjectPermissionSub.Secrets, { environment, secretPath })
); );
const plan = await licenseService.getPlan(actorOrgId); const plan = await licenseService.getPlan(actorOrgId);
@@ -366,7 +366,7 @@ export const secretImportServiceFactory = ({
// check if user has permission to import from target path // check if user has permission to import from target path
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read, ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, { subject(ProjectPermissionSub.Secrets, {
environment: secretImportDoc.importEnv.slug, environment: secretImportDoc.importEnv.slug,
secretPath: secretImportDoc.importPath secretPath: secretImportDoc.importPath
@@ -414,7 +414,7 @@ export const secretImportServiceFactory = ({
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) subject(ProjectPermissionSub.Secrets, { environment, secretPath })
); );
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
@@ -446,7 +446,7 @@ export const secretImportServiceFactory = ({
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) subject(ProjectPermissionSub.Secrets, { environment, secretPath })
); );
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
@@ -489,7 +489,7 @@ export const secretImportServiceFactory = ({
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.SecretImports, { subject(ProjectPermissionSub.Secrets, {
environment: folder.environment.envSlug, environment: folder.environment.envSlug,
secretPath: folderWithPath.path secretPath: folderWithPath.path
}) })
@@ -532,19 +532,20 @@ export const secretImportServiceFactory = ({
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) subject(ProjectPermissionSub.Secrets, { environment, secretPath })
); );
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) return []; if (!folder) return [];
// this will already order by position // this will already order by position
// so anything based on this order will also be in right position // so anything based on this order will also be in right position
const secretImports = await secretImportDAL.find({ folderId: folder.id, isReplication: false }); const secretImports = await secretImportDAL.find({ folderId: folder.id, isReplication: false });
const allowedImports = secretImports.filter((el) =>
const allowedImports = secretImports.filter(({ importEnv, importPath }) =>
permission.can( permission.can(
ProjectPermissionActions.Read, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { subject(ProjectPermissionSub.Secrets, {
environment: el.importEnv.slug, environment: importEnv.slug,
secretPath: el.importPath secretPath: importPath
}) })
) )
); );
@@ -569,7 +570,7 @@ export const secretImportServiceFactory = ({
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.SecretImports, { environment, secretPath }) subject(ProjectPermissionSub.Secrets, { environment, secretPath })
); );
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) return []; if (!folder) return [];
@@ -577,6 +578,16 @@ export const secretImportServiceFactory = ({
// so anything based on this order will also be in right position // so anything based on this order will also be in right position
const secretImports = await secretImportDAL.find({ folderId: folder.id, isReplication: false }); const secretImports = await secretImportDAL.find({ folderId: folder.id, isReplication: false });
const allowedImports = secretImports.filter(({ importEnv, importPath }) =>
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment: importEnv.slug,
secretPath: importPath
})
)
);
const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
if (shouldUseSecretV2Bridge) { if (shouldUseSecretV2Bridge) {
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
@@ -584,21 +595,11 @@ export const secretImportServiceFactory = ({
projectId projectId
}); });
const importedSecrets = await fnSecretsV2FromImports({ const importedSecrets = await fnSecretsV2FromImports({
secretImports, allowedImports,
folderDAL, folderDAL,
secretDAL: secretV2BridgeDAL, secretDAL: secretV2BridgeDAL,
secretImportDAL, secretImportDAL,
decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : ""), decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "")
hasSecretAccess: (expandEnvironment, expandSecretPath, expandSecretKey, expandSecretTags) =>
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment: expandEnvironment,
secretPath: expandSecretPath,
secretName: expandSecretKey,
secretTags: expandSecretTags
})
)
}); });
return importedSecrets; return importedSecrets;
} }
@@ -609,21 +610,7 @@ export const secretImportServiceFactory = ({
name: "bot_not_found_error" name: "bot_not_found_error"
}); });
const allowedImports = secretImports.filter((el) => const importedSecrets = await fnSecretsFromImports({ allowedImports, folderDAL, secretDAL, secretImportDAL });
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment: el.importEnv.slug,
secretPath: el.importPath
})
)
);
const importedSecrets = await fnSecretsFromImports({
allowedImports,
folderDAL,
secretDAL,
secretImportDAL
});
return importedSecrets.map((el) => ({ return importedSecrets.map((el) => ({
...el, ...el,
secrets: el.secrets.map((encryptedSecret) => secrets: el.secrets.map((encryptedSecret) =>
@@ -4,14 +4,7 @@ import { validate as uuidValidate } from "uuid";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecretsV2Update } from "@app/db/schemas"; import { SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecretsV2Update } from "@app/db/schemas";
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
import { import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
buildFindFilter,
ormify,
selectAllTableCols,
sqlNestRelationships,
TFindFilter,
TFindOpt
} from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types"; import { OrderByDirection } from "@app/lib/types";
import { SecretsOrderBy } from "@app/services/secret/secret-types"; import { SecretsOrderBy } from "@app/services/secret/secret-types";
@@ -20,97 +13,6 @@ export type TSecretV2BridgeDALFactory = ReturnType<typeof secretV2BridgeDALFacto
export const secretV2BridgeDALFactory = (db: TDbClient) => { export const secretV2BridgeDALFactory = (db: TDbClient) => {
const secretOrm = ormify(db, TableName.SecretV2); const secretOrm = ormify(db, TableName.SecretV2);
const findOne = async (filter: Partial<TSecretsV2>, tx?: Knex) => {
try {
const docs = await (tx || db)(TableName.SecretV2)
.where(filter)
.leftJoin(
TableName.SecretV2JnTag,
`${TableName.SecretV2}.id`,
`${TableName.SecretV2JnTag}.${TableName.SecretV2}Id`
)
.leftJoin(
TableName.SecretTag,
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
`${TableName.SecretTag}.id`
)
.select(selectAllTableCols(TableName.SecretV2))
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
.select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
.select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"));
const data = sqlNestRelationships({
data: docs,
key: "id",
parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }),
childrenMapper: [
{
key: "tagId",
label: "tags" as const,
mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({
id,
color,
slug,
name: slug
})
}
]
});
return data?.[0];
} catch (error) {
throw new DatabaseError({ error, name: `${TableName.SecretV2}: FindOne` });
}
};
const find = async (filter: TFindFilter<TSecretsV2>, { offset, limit, sort, tx }: TFindOpt<TSecretsV2> = {}) => {
try {
const query = (tx || db)(TableName.SecretV2)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter(filter))
.leftJoin(
TableName.SecretV2JnTag,
`${TableName.SecretV2}.id`,
`${TableName.SecretV2JnTag}.${TableName.SecretV2}Id`
)
.leftJoin(
TableName.SecretTag,
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
`${TableName.SecretTag}.id`
)
.select(selectAllTableCols(TableName.SecretV2))
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
.select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
.select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"));
if (limit) void query.limit(limit);
if (offset) void query.offset(offset);
if (sort) {
void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls })));
}
const docs = await query;
const data = sqlNestRelationships({
data: docs,
key: "id",
parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }),
childrenMapper: [
{
key: "tagId",
label: "tags" as const,
mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({
id,
color,
slug,
name: slug
})
}
]
});
return data;
} catch (error) {
throw new DatabaseError({ error, name: `${TableName.SecretV2}: Find` });
}
};
const update = async (filter: Partial<TSecretsV2>, data: Omit<TSecretsV2Update, "version">, tx?: Knex) => { const update = async (filter: Partial<TSecretsV2>, data: Omit<TSecretsV2Update, "version">, tx?: Knex) => {
try { try {
const sec = await (tx || db)(TableName.SecretV2) const sec = await (tx || db)(TableName.SecretV2)
@@ -582,8 +484,6 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
upsertSecretReferences, upsertSecretReferences,
findReferencedSecretReferences, findReferencedSecretReferences,
findAllProjectSecretValues, findAllProjectSecretValues,
countByFolderIds, countByFolderIds
findOne,
find
}; };
}; };
@@ -30,10 +30,9 @@ export const shouldUseSecretV2Bridge = (version: number) => version === 3;
* // { environment: 'prod', secretPath: '/anotherFolder' } * // { environment: 'prod', secretPath: '/anotherFolder' }
* // ] * // ]
*/ */
export const getAllSecretReferences = (maybeSecretReference: string) => { export const getAllNestedSecretReferences = (maybeSecretReference: string) => {
const references = Array.from(maybeSecretReference.matchAll(INTERPOLATION_SYNTAX_REG), (m) => m[1]); const references = Array.from(maybeSecretReference.matchAll(INTERPOLATION_SYNTAX_REG), (m) => m[1]);
return references
const nestedReferences = references
.filter((el) => el.includes(".")) .filter((el) => el.includes("."))
.map((el) => { .map((el) => {
const [environment, ...secretPathList] = el.split("."); const [environment, ...secretPathList] = el.split(".");
@@ -43,8 +42,6 @@ export const getAllSecretReferences = (maybeSecretReference: string) => {
secretKey: secretPathList[secretPathList.length - 1] secretKey: secretPathList[secretPathList.length - 1]
}; };
}); });
const localReferences = references.filter((el) => !el.includes("."));
return { nestedReferences, localReferences };
}; };
// these functions are special functions shared by a couple of resources // these functions are special functions shared by a couple of resources
@@ -328,13 +325,16 @@ type TRecursivelyFetchSecretsFromFoldersArg = {
projectId: string; projectId: string;
environment: string; environment: string;
currentPath: string; currentPath: string;
hasAccess: (environment: string, secretPath: string) => boolean;
}; };
export const recursivelyGetSecretPaths = async ({ export const recursivelyGetSecretPaths = async ({
folderDAL, folderDAL,
projectEnvDAL, projectEnvDAL,
projectId, projectId,
environment environment,
currentPath,
hasAccess
}: TRecursivelyFetchSecretsFromFoldersArg) => { }: TRecursivelyFetchSecretsFromFoldersArg) => {
const env = await projectEnvDAL.findOne({ const env = await projectEnvDAL.findOne({
projectId, projectId,
@@ -360,7 +360,12 @@ export const recursivelyGetSecretPaths = async ({
folderId: p.folderId folderId: p.folderId
})); }));
return paths; // Filter out paths that the user does not have permission to access, and paths that are not in the current path
const allowedPaths = paths.filter(
(folder) => hasAccess(environment, folder.path) && folder.path.startsWith(currentPath === "/" ? "" : currentPath)
);
return allowedPaths;
}; };
// used to convert multi line ones to quotes ones with \n // used to convert multi line ones to quotes ones with \n
const formatMultiValueEnv = (val?: string) => { const formatMultiValueEnv = (val?: string) => {
@@ -374,7 +379,7 @@ type TInterpolateSecretArg = {
decryptSecretValue: (encryptedValue?: Buffer | null) => string | undefined; decryptSecretValue: (encryptedValue?: Buffer | null) => string | undefined;
secretDAL: Pick<TSecretV2BridgeDALFactory, "findByFolderId">; secretDAL: Pick<TSecretV2BridgeDALFactory, "findByFolderId">;
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath">; folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath">;
canExpandValue: (environment: string, secretPath: string, secretName: string, secretTagSlugs: string[]) => boolean; canExpandValue: (environment: string, secretPath: string) => boolean;
}; };
const MAX_SECRET_REFERENCE_DEPTH = 10; const MAX_SECRET_REFERENCE_DEPTH = 10;
@@ -385,29 +390,29 @@ export const expandSecretReferencesFactory = ({
folderDAL, folderDAL,
canExpandValue canExpandValue
}: TInterpolateSecretArg) => { }: TInterpolateSecretArg) => {
const secretCache: Record<string, Record<string, { value: string; tags: string[] }>> = {}; const secretCache: Record<string, Record<string, string>> = {};
const getCacheUniqueKey = (environment: string, secretPath: string) => `${environment}-${secretPath}`; const getCacheUniqueKey = (environment: string, secretPath: string) => `${environment}-${secretPath}`;
const fetchSecret = async (environment: string, secretPath: string, secretKey: string) => { const fetchSecret = async (environment: string, secretPath: string, secretKey: string) => {
const cacheKey = getCacheUniqueKey(environment, secretPath); const cacheKey = getCacheUniqueKey(environment, secretPath);
if (secretCache?.[cacheKey]) { if (secretCache?.[cacheKey]) {
return secretCache[cacheKey][secretKey] || { value: "", tags: [] }; return secretCache[cacheKey][secretKey] || "";
} }
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) return { value: "", tags: [] }; if (!folder) return "";
const secrets = await secretDAL.findByFolderId(folder.id); const secrets = await secretDAL.findByFolderId(folder.id);
const decryptedSecret = secrets.reduce<Record<string, { value: string; tags: string[] }>>((prev, secret) => { const decryptedSecret = secrets.reduce<Record<string, string>>((prev, secret) => {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
prev[secret.key] = { value: decryptSecret(secret.encryptedValue) || "", tags: secret.tags?.map((el) => el.slug) }; prev[secret.key] = decryptSecret(secret.encryptedValue) || "";
return prev; return prev;
}, {}); }, {});
secretCache[cacheKey] = decryptedSecret; secretCache[cacheKey] = decryptedSecret;
return secretCache[cacheKey][secretKey] || { value: "", tags: [] }; return secretCache[cacheKey][secretKey] || "";
}; };
const recursivelyExpandSecret = async (dto: { value?: string; secretPath: string; environment: string }) => { const recursivelyExpandSecret = async (dto: { value?: string; secretPath: string; environment: string }) => {
@@ -433,43 +438,43 @@ export const expandSecretReferencesFactory = ({
if (entities.length === 1) { if (entities.length === 1) {
const [secretKey] = entities; const [secretKey] = entities;
// eslint-disable-next-line no-continue,no-await-in-loop if (!canExpandValue(environment, secretPath))
const referredValue = await fetchSecret(environment, secretPath, secretKey);
if (!canExpandValue(environment, secretPath, secretKey, referredValue.tags))
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: `You are attempting to reference secret named ${secretKey} from environment ${environment} in path ${secretPath} which you do not have access to.` message: `You are attempting to reference secret named ${secretKey} from environment ${environment} in path ${secretPath} which you do not have access to.`
}); });
// eslint-disable-next-line no-continue,no-await-in-loop
const referedValue = await fetchSecret(environment, secretPath, secretKey);
const cacheKey = getCacheUniqueKey(environment, secretPath); const cacheKey = getCacheUniqueKey(environment, secretPath);
secretCache[cacheKey][secretKey] = referredValue; secretCache[cacheKey][secretKey] = referedValue;
if (INTERPOLATION_SYNTAX_REG.test(referredValue.value)) { if (INTERPOLATION_SYNTAX_REG.test(referedValue)) {
stack.push({ stack.push({
value: referredValue.value, value: referedValue,
secretPath, secretPath,
environment, environment,
depth: depth + 1 depth: depth + 1
}); });
} }
if (referredValue) { if (referedValue) {
expandedValue = expandedValue.replaceAll(interpolationSyntax, referredValue.value); expandedValue = expandedValue.replaceAll(interpolationSyntax, referedValue);
} }
} else { } else {
const secretReferenceEnvironment = entities[0]; const secretReferenceEnvironment = entities[0];
const secretReferencePath = path.join("/", ...entities.slice(1, entities.length - 1)); const secretReferencePath = path.join("/", ...entities.slice(1, entities.length - 1));
const secretReferenceKey = entities[entities.length - 1]; const secretReferenceKey = entities[entities.length - 1];
// eslint-disable-next-line no-await-in-loop if (!canExpandValue(secretReferenceEnvironment, secretReferencePath))
const referedValue = await fetchSecret(secretReferenceEnvironment, secretReferencePath, secretReferenceKey);
if (!canExpandValue(secretReferenceEnvironment, secretReferencePath, secretReferenceKey, referedValue.tags))
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: `You are attempting to reference secret named ${secretReferenceKey} from environment ${secretReferenceEnvironment} in path ${secretReferencePath} which you do not have access to.` message: `You are attempting to reference secret named ${secretReferenceKey} from environment ${secretReferenceEnvironment} in path ${secretReferencePath} which you do not have access to.`
}); });
// eslint-disable-next-line no-await-in-loop
const referedValue = await fetchSecret(secretReferenceEnvironment, secretReferencePath, secretReferenceKey);
const cacheKey = getCacheUniqueKey(secretReferenceEnvironment, secretReferencePath); const cacheKey = getCacheUniqueKey(secretReferenceEnvironment, secretReferencePath);
secretCache[cacheKey][secretReferenceKey] = referedValue; secretCache[cacheKey][secretReferenceKey] = referedValue;
if (INTERPOLATION_SYNTAX_REG.test(referedValue.value)) { if (INTERPOLATION_SYNTAX_REG.test(referedValue)) {
stack.push({ stack.push({
value: referedValue.value, value: referedValue,
secretPath: secretReferencePath, secretPath: secretReferencePath,
environment: secretReferenceEnvironment, environment: secretReferenceEnvironment,
depth: depth + 1 depth: depth + 1
@@ -477,7 +482,7 @@ export const expandSecretReferencesFactory = ({
} }
if (referedValue) { if (referedValue) {
expandedValue = expandedValue.replaceAll(interpolationSyntax, referedValue.value); expandedValue = expandedValue.replaceAll(interpolationSyntax, referedValue);
} }
} }
} }
File diff suppressed because it is too large Load Diff
@@ -15,12 +15,6 @@ type TPartialSecret = Pick<TSecretsV2, "id" | "reminderRepeatDays" | "reminderNo
type TPartialInputSecret = Pick<TSecretsV2, "type" | "reminderNote" | "reminderRepeatDays" | "id">; type TPartialInputSecret = Pick<TSecretsV2, "type" | "reminderNote" | "reminderRepeatDays" | "id">;
export type TSecretReferenceDTO = {
environment: string;
secretPath: string;
secretKey: string;
};
export type TGetSecretsDTO = { export type TGetSecretsDTO = {
expandSecretReferences?: boolean; expandSecretReferences?: boolean;
path: string; path: string;
+3 -3
View File
@@ -25,7 +25,7 @@ import { logger } from "@app/lib/logger";
import { import {
fnSecretBulkInsert as fnSecretV2BridgeBulkInsert, fnSecretBulkInsert as fnSecretV2BridgeBulkInsert,
fnSecretBulkUpdate as fnSecretV2BridgeBulkUpdate, fnSecretBulkUpdate as fnSecretV2BridgeBulkUpdate,
getAllSecretReferences getAllNestedSecretReferences as getAllNestedSecretReferencesV2Bridge
} from "@app/services/secret-v2-bridge/secret-v2-bridge-fns"; } from "@app/services/secret-v2-bridge/secret-v2-bridge-fns";
import { ActorAuthMethod, ActorType } from "../auth/auth-type"; import { ActorAuthMethod, ActorType } from "../auth/auth-type";
@@ -791,7 +791,7 @@ export const createManySecretsRawFnFactory = ({
: null, : null,
skipMultilineEncoding: secret.skipMultilineEncoding, skipMultilineEncoding: secret.skipMultilineEncoding,
tags: secret.tags, tags: secret.tags,
references: getAllSecretReferences(secret.secretValue).nestedReferences references: getAllNestedSecretReferencesV2Bridge(secret.secretValue)
}; };
}); });
@@ -971,7 +971,7 @@ export const updateManySecretsRawFnFactory = ({
: null, : null,
skipMultilineEncoding: secret.skipMultilineEncoding, skipMultilineEncoding: secret.skipMultilineEncoding,
tags: secret.tags, tags: secret.tags,
references: getAllSecretReferences(secret.secretValue).nestedReferences references: getAllNestedSecretReferencesV2Bridge(secret.secretValue)
}; };
}); });
+3 -4
View File
@@ -50,7 +50,7 @@ import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns"; import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns";
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
import { expandSecretReferencesFactory, getAllSecretReferences } from "../secret-v2-bridge/secret-v2-bridge-fns"; import { expandSecretReferencesFactory, getAllNestedSecretReferences } from "../secret-v2-bridge/secret-v2-bridge-fns";
import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal";
import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal"; import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
@@ -342,8 +342,7 @@ export const secretQueueFactory = ({
secretDAL: secretV2BridgeDAL, secretDAL: secretV2BridgeDAL,
expandSecretReferences, expandSecretReferences,
secretImportDAL, secretImportDAL,
secretImports, allowedImports: secretImports
hasSecretAccess: () => true
}); });
for (let i = importedSecrets.length - 1; i >= 0; i -= 1) { for (let i = importedSecrets.length - 1; i >= 0; i -= 1) {
@@ -1148,7 +1147,7 @@ export const secretQueueFactory = ({
: ""; : "";
const encryptedValue = secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob; const encryptedValue = secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob;
// create references // create references
const references = getAllSecretReferences(value).nestedReferences; const references = getAllNestedSecretReferences(value);
secretReferences.push({ secretId: el.id, references }); secretReferences.push({ secretId: el.id, references });
const encryptedComment = comment const encryptedComment = comment
+3 -12
View File
@@ -2407,26 +2407,17 @@ export const secretServiceFactory = ({
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Delete, ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.Secrets, { subject(ProjectPermissionSub.Secrets, { environment: sourceEnvironment, secretPath: sourceSecretPath })
environment: sourceEnvironment,
secretPath: sourceSecretPath
})
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create, ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, { subject(ProjectPermissionSub.Secrets, { environment: destinationEnvironment, secretPath: destinationSecretPath })
environment: destinationEnvironment,
secretPath: destinationSecretPath
})
); );
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Edit, ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { subject(ProjectPermissionSub.Secrets, { environment: destinationEnvironment, secretPath: destinationSecretPath })
environment: destinationEnvironment,
secretPath: destinationSecretPath
})
); );
const { botKey } = await projectBotService.getBotKey(project.id); const { botKey } = await projectBotService.getBotKey(project.id);
@@ -1,33 +0,0 @@
import { useState } from "react";
import picomatch from "picomatch";
import { FormControl } from "../v2/FormControl";
import { Input } from "../v2/Input";
export const GlobPermissionInfo = () => {
const [pattern, setPattern] = useState("");
const [text, setText] = useState("");
return (
<div>
<div className="mt-2">A glob pattern uses wildcards to match resources or paths.</div>
<div>
<FormControl label="Glob pattern" helperText="Examples: /{a,b}, DB_**">
<Input value={pattern} onChange={(e) => setPattern(e.target.value)} />
</FormControl>
</div>
<div>
<FormControl
label="Test string"
helperText="Type a value to test glob match"
isError={
pattern && text ? !picomatch.isMatch(text, pattern, { strictSlashes: false }) : false
}
errorText="Invalid"
>
<Input value={text} onChange={(e) => setText(e.target.value)} />
</FormControl>
</div>
</div>
);
};
@@ -1,25 +1,23 @@
import { FunctionComponent, ReactNode } from "react"; import { FunctionComponent, ReactNode } from "react";
import { AbilityTuple, MongoAbility } from "@casl/ability"; import { BoundCanProps, Can } from "@casl/react";
import { Can } from "@casl/react";
import { ProjectPermissionSet, useProjectPermission } from "@app/context/ProjectPermissionContext"; import { TProjectPermission, useProjectPermission } from "@app/context/ProjectPermissionContext";
import { Tooltip } from "../v2/Tooltip"; import { Tooltip } from "../v2";
type Props<T extends AbilityTuple> = { type Props = {
label?: ReactNode; label?: ReactNode;
// this prop is used when there exist already a tooltip as helper text for users // this prop is used when there exist already a tooltip as helper text for users
// so when permission is allowed same tooltip will be reused to show helpertext // so when permission is allowed same tooltip will be reused to show helpertext
renderTooltip?: boolean; renderTooltip?: boolean;
allowedLabel?: string; allowedLabel?: string;
children: ReactNode | ((isAllowed: boolean, ability: T) => ReactNode); // BUG(akhilmhdh): As a workaround for now i put any but this should be TProjectPermission
passThrough?: boolean; // For some reason when i put TProjectPermission in a wrapper component it just wont work causes a weird ts error
I: T[0]; // tried a lot combinations
a: T[1]; // REF: https://github.com/stalniy/casl/blob/ac081a34f56366a7eaaed05d21689d27041ef005/packages/casl-react/src/factory.ts#L15
ability?: MongoAbility<T>; } & BoundCanProps<any>;
};
export const ProjectPermissionCan: FunctionComponent<Props<ProjectPermissionSet>> = ({ export const ProjectPermissionCan: FunctionComponent<Props> = ({
label = "Access restricted", label = "Access restricted",
children, children,
passThrough = true, passThrough = true,
@@ -33,7 +31,9 @@ export const ProjectPermissionCan: FunctionComponent<Props<ProjectPermissionSet>
{(isAllowed, ability) => { {(isAllowed, ability) => {
// akhilmhdh: This is set as type due to error in casl react type. // akhilmhdh: This is set as type due to error in casl react type.
const finalChild = const finalChild =
typeof children === "function" ? children(isAllowed, ability as any) : children; typeof children === "function"
? children(isAllowed, ability as TProjectPermission)
: children;
if (!isAllowed && passThrough) { if (!isAllowed && passThrough) {
return <Tooltip content={label}>{finalChild}</Tooltip>; return <Tooltip content={label}>{finalChild}</Tooltip>;
@@ -1,4 +1,3 @@
export { GlobPermissionInfo } from "./GlobPermissionInfo";
export { OrgPermissionCan } from "./OrgPermissionCan"; export { OrgPermissionCan } from "./OrgPermissionCan";
export { PermissionDeniedBanner } from "./PermissionDeniedBanner"; export { PermissionDeniedBanner } from "./PermissionDeniedBanner";
export { ProjectPermissionCan } from "./ProjectPermissionCan"; export { ProjectPermissionCan } from "./ProjectPermissionCan";
+1 -3
View File
@@ -12,7 +12,6 @@ type Props = {
placeholder?: string; placeholder?: string;
className?: string; className?: string;
dropdownContainerClassName?: string; dropdownContainerClassName?: string;
containerClassName?: string;
isLoading?: boolean; isLoading?: boolean;
position?: "item-aligned" | "popper"; position?: "item-aligned" | "popper";
isDisabled?: boolean; isDisabled?: boolean;
@@ -32,13 +31,12 @@ export const Select = forwardRef<HTMLButtonElement, SelectProps>(
isDisabled, isDisabled,
dropdownContainerClassName, dropdownContainerClassName,
position, position,
containerClassName,
...props ...props
}, },
ref ref
): JSX.Element => { ): JSX.Element => {
return ( return (
<div className={twMerge("flex items-center space-x-2", containerClassName)}> <div className="flex items-center space-x-2">
<SelectPrimitive.Root <SelectPrimitive.Root
{...props} {...props}
onValueChange={(value) => { onValueChange={(value) => {
@@ -3,6 +3,5 @@ export type { ProjectPermissionSet, TProjectPermission } from "./types";
export { export {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionCmekActions, ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions,
ProjectPermissionSub ProjectPermissionSub
} from "./types"; } from "./types";
@@ -7,14 +7,6 @@ export enum ProjectPermissionActions {
Delete = "delete" Delete = "delete"
} }
export enum ProjectPermissionDynamicSecretActions {
ReadRootCredential = "read-root-credential",
CreateRootCredential = "create-root-credential",
EditRootCredential = "edit-root-credential",
DeleteRootCredential = "delete-root-credential",
Lease = "lease"
}
export enum ProjectPermissionCmekActions { export enum ProjectPermissionCmekActions {
Read = "read", Read = "read",
Create = "create", Create = "create",
@@ -29,7 +21,7 @@ export enum PermissionConditionOperators {
$ALL = "$all", $ALL = "$all",
$REGEX = "$regex", $REGEX = "$regex",
$EQ = "$eq", $EQ = "$eq",
$NEQ = "$ne", $NEQ = "$neq",
$GLOB = "$glob" $GLOB = "$glob"
} }
@@ -45,7 +37,7 @@ export type TPermissionConditionOperators = {
export type TPermissionCondition = Record< export type TPermissionCondition = Record<
string, string,
| string | string
| { $in: string[]; $all: string[]; $regex: string; $eq: string; $ne: string; $glob: string } | { $in: string[]; $all: string[]; $regex: string; $eq: string; $neq: string; $glob: string }
>; >;
export enum ProjectPermissionSub { export enum ProjectPermissionSub {
@@ -60,11 +52,9 @@ export enum ProjectPermissionSub {
Tags = "tags", Tags = "tags",
AuditLogs = "audit-logs", AuditLogs = "audit-logs",
IpAllowList = "ip-allowlist", IpAllowList = "ip-allowlist",
Project = "workspace", Workspace = "workspace",
Secrets = "secrets", Secrets = "secrets",
SecretFolders = "secret-folders", SecretFolders = "secret-folders",
SecretImports = "secret-imports",
DynamicSecrets = "dynamic-secrets",
SecretRollback = "secret-rollback", SecretRollback = "secret-rollback",
SecretApproval = "secret-approval", SecretApproval = "secret-approval",
SecretRotation = "secret-rotation", SecretRotation = "secret-rotation",
@@ -78,24 +68,7 @@ export enum ProjectPermissionSub {
Cmek = "cmek" Cmek = "cmek"
} }
export type SecretSubjectFields = { type SubjectFields = {
environment: string;
secretPath: string;
secretName: string;
secretTags: string[];
};
export type SecretFolderSubjectFields = {
environment: string;
secretPath: string;
};
export type DynamicSecretSubjectFields = {
environment: string;
secretPath: string;
};
export type SecretImportSubjectFields = {
environment: string; environment: string;
secretPath: string; secretPath: string;
}; };
@@ -103,30 +76,13 @@ export type SecretImportSubjectFields = {
export type ProjectPermissionSet = export type ProjectPermissionSet =
| [ | [
ProjectPermissionActions, ProjectPermissionActions,
( ProjectPermissionSub.Secrets | (ForcedSubject<ProjectPermissionSub.Secrets> & SubjectFields)
| ProjectPermissionSub.Secrets
| (ForcedSubject<ProjectPermissionSub.Secrets> & SecretSubjectFields)
)
] ]
| [ | [
ProjectPermissionActions, ProjectPermissionActions,
( (
| ProjectPermissionSub.SecretFolders | ProjectPermissionSub.SecretFolders
| (ForcedSubject<ProjectPermissionSub.SecretFolders> & SecretFolderSubjectFields) | (ForcedSubject<ProjectPermissionSub.SecretFolders> & SubjectFields)
)
]
| [
ProjectPermissionDynamicSecretActions,
(
| ProjectPermissionSub.DynamicSecrets
| (ForcedSubject<ProjectPermissionSub.DynamicSecrets> & DynamicSecretSubjectFields)
)
]
| [
ProjectPermissionActions,
(
| ProjectPermissionSub.SecretImports
| (ForcedSubject<ProjectPermissionSub.SecretImports> & SecretImportSubjectFields)
) )
] ]
| [ProjectPermissionActions, ProjectPermissionSub.Role] | [ProjectPermissionActions, ProjectPermissionSub.Role]
@@ -139,19 +95,19 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions, ProjectPermissionSub.Environments] | [ProjectPermissionActions, ProjectPermissionSub.Environments]
| [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList]
| [ProjectPermissionActions, ProjectPermissionSub.Settings] | [ProjectPermissionActions, ProjectPermissionSub.Settings]
| [ProjectPermissionActions, ProjectPermissionSub.Identity]
| [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens]
| [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval]
| [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation]
| [ProjectPermissionActions, ProjectPermissionSub.Identity]
| [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities]
| [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions, ProjectPermissionSub.Certificates]
| [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates] | [ProjectPermissionActions, ProjectPermissionSub.CertificateTemplates]
| [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts]
| [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections]
| [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace]
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Project] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace]
| [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback]
| [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback] | [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback]
| [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek] | [ProjectPermissionCmekActions, ProjectPermissionSub.Cmek];
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Kms];
export type TProjectPermission = MongoAbility<ProjectPermissionSet>; export type TProjectPermission = MongoAbility<ProjectPermissionSet>;
-1
View File
@@ -11,7 +11,6 @@ export type { TProjectPermission } from "./ProjectPermissionContext";
export { export {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionCmekActions, ProjectPermissionCmekActions,
ProjectPermissionDynamicSecretActions,
ProjectPermissionProvider, ProjectPermissionProvider,
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission useProjectPermission
@@ -1,29 +1,31 @@
import { ComponentType } from "react"; import { ComponentType } from "react";
import { AbilityTuple } from "@casl/ability"; import { Abilities, AbilityTuple, Generics, SubjectType } from "@casl/ability";
import { faLock } from "@fortawesome/free-solid-svg-icons"; import { faLock } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { useProjectPermission } from "@app/context"; import { TProjectPermission, useProjectPermission } from "@app/context";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext";
type Props<T extends AbilityTuple> = { type Props<T extends Abilities> = (T extends AbilityTuple
className?: string; ? {
containerClassName?: string; action: T[0];
action: T[0]; subject: Extract<T[1], SubjectType>;
subject: T[1]; }
}; : {
action: string;
subject: string;
}) & { className?: string; containerClassName?: string };
export const withProjectPermission = <T extends {}>( export const withProjectPermission = <T extends {}, J extends TProjectPermission>(
Component: ComponentType<Omit<Props<ProjectPermissionSet>, "action" | "subject"> & T>, Component: ComponentType<T>,
{ action, subject, className, containerClassName }: Props<ProjectPermissionSet> { action, subject, className, containerClassName }: Props<Generics<J>["abilities"]>
) => { ) => {
const HOC = (hocProps: Omit<Props<ProjectPermissionSet>, "action" | "subject"> & T) => { const HOC = (hocProps: T) => {
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
// akhilmhdh: Set as any due to casl/react ts type bug // akhilmhdh: Set as any due to casl/react ts type bug
// REASON: casl due to its type checking can't seem to union even if union intersection is applied // REASON: casl due to its type checking can't seem to union even if union intersection is applied
if (permission.cannot(action as any, subject as any)) { if (permission.cannot(action as any, subject)) {
return ( return (
<div <div
className={twMerge( className={twMerge(
+1 -12
View File
@@ -15,7 +15,6 @@ import {
} from "@app/hooks/api/dashboard/types"; } from "@app/hooks/api/dashboard/types";
import { OrderByDirection } from "@app/hooks/api/generic/types"; import { OrderByDirection } from "@app/hooks/api/generic/types";
import { mergePersonalSecrets } from "@app/hooks/api/secrets/queries"; import { mergePersonalSecrets } from "@app/hooks/api/secrets/queries";
import { unique } from "@app/lib/fn/array";
export const dashboardKeys = { export const dashboardKeys = {
all: () => ["dashboard"] as const, all: () => ["dashboard"] as const,
@@ -155,20 +154,10 @@ export const useGetProjectSecretsOverview = (
}, },
select: useCallback((data: Awaited<ReturnType<typeof fetchProjectSecretsOverview>>) => { select: useCallback((data: Awaited<ReturnType<typeof fetchProjectSecretsOverview>>) => {
const { secrets, ...select } = data; const { secrets, ...select } = data;
const uniqueSecrets = secrets ? unique(secrets, (i) => i.secretKey) : [];
const uniqueFolders = select.folders ? unique(select.folders, (i) => i.name) : [];
const uniqueDynamicSecrets = select.dynamicSecrets
? unique(select.dynamicSecrets, (i) => i.name)
: [];
return { return {
...select, ...select,
secrets: secrets ? mergePersonalSecrets(secrets) : undefined, secrets: secrets ? mergePersonalSecrets(secrets) : undefined
totalUniqueSecretsInPage: uniqueSecrets.length,
totalUniqueDynamicSecretsInPage: uniqueDynamicSecrets.length,
totalUniqueFoldersInPage: uniqueFolders.length
}; };
}, []), }, []),
keepPreviousData: true keepPreviousData: true
@@ -12,9 +12,6 @@ export type DashboardProjectSecretsOverviewResponse = {
totalFolderCount?: number; totalFolderCount?: number;
totalDynamicSecretCount?: number; totalDynamicSecretCount?: number;
totalCount: number; totalCount: number;
totalUniqueSecretsInPage: number;
totalUniqueDynamicSecretsInPage: number;
totalUniqueFoldersInPage: number;
}; };
export type DashboardProjectSecretsDetailsResponse = { export type DashboardProjectSecretsDetailsResponse = {
@@ -1,3 +1,4 @@
import { packRules } from "@casl/ability/extra";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
@@ -15,7 +16,10 @@ export const useCreateProjectUserAdditionalPrivilege = () => {
return useMutation<{ privilege: TProjectUserPrivilege }, {}, TCreateProjectUserPrivilegeDTO>({ return useMutation<{ privilege: TProjectUserPrivilege }, {}, TCreateProjectUserPrivilegeDTO>({
mutationFn: async (dto) => { mutationFn: async (dto) => {
const { data } = await apiRequest.post("/api/v1/additional-privilege/users/permanent", dto); const { data } = await apiRequest.post("/api/v1/additional-privilege/users/permanent", {
...dto,
permissions: packRules(dto.permissions)
});
return data.privilege; return data.privilege;
}, },
onSuccess: (_, { projectMembershipId }) => { onSuccess: (_, { projectMembershipId }) => {
@@ -31,7 +35,7 @@ export const useUpdateProjectUserAdditionalPrivilege = () => {
mutationFn: async (dto) => { mutationFn: async (dto) => {
const { data } = await apiRequest.patch( const { data } = await apiRequest.patch(
`/api/v1/additional-privilege/users/${dto.privilegeId}`, `/api/v1/additional-privilege/users/${dto.privilegeId}`,
dto { ...dto, permissions: dto.permissions ? packRules(dto.permissions) : undefined }
); );
return data.privilege; return data.privilege;
}, },
@@ -1,3 +1,4 @@
import { PackRule, unpackRules } from "@casl/ability/extra";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
@@ -17,7 +18,10 @@ const fetchProjectUserPrivilegeDetails = async (privilegeId: string) => {
} = await apiRequest.get<{ } = await apiRequest.get<{
privilege: Omit<TProjectUserPrivilege, "permissions"> & { permissions: unknown }; privilege: Omit<TProjectUserPrivilege, "permissions"> & { permissions: unknown };
}>(`/api/v1/additional-privilege/users/${privilegeId}`); }>(`/api/v1/additional-privilege/users/${privilegeId}`);
return privilege; return {
...privilege,
permissions: unpackRules(privilege.permissions as PackRule<TProjectPermission>[])
};
}; };
export const useGetProjectUserPrivilegeDetails = (privilegeId: string) => { export const useGetProjectUserPrivilegeDetails = (privilegeId: string) => {
@@ -40,7 +44,7 @@ export const useListProjectUserPrivileges = (projectMembershipId: string) => {
}>("/api/v1/additional-privilege/users", { params: { projectMembershipId } }); }>("/api/v1/additional-privilege/users", { params: { projectMembershipId } });
return privileges.map((el) => ({ return privileges.map((el) => ({
...el, ...el,
permissions: el.permissions as TProjectPermission[] permissions: unpackRules(el.permissions as PackRule<TProjectPermission>[])
})); }));
} }
}); });
@@ -4,15 +4,6 @@ export enum ProjectUserAdditionalPrivilegeTemporaryMode {
Relative = "relative" Relative = "relative"
} }
export type TProjectSpecificPrivilegePermission = {
conditions: {
environment: string;
secretPath?: { $glob: string };
};
actions: string[];
subject: string;
};
export type TProjectUserPrivilege = { export type TProjectUserPrivilege = {
projectMembershipId: string; projectMembershipId: string;
slug: string; slug: string;
@@ -21,21 +12,21 @@ export type TProjectUserPrivilege = {
updatedAt: Date; updatedAt: Date;
permissions?: TProjectPermission[]; permissions?: TProjectPermission[];
} & ( } & (
| { | {
isTemporary: true; isTemporary: true;
temporaryMode: string; temporaryMode: string;
temporaryRange: string; temporaryRange: string;
temporaryAccessStartTime: string; temporaryAccessStartTime: string;
temporaryAccessEndTime?: string; temporaryAccessEndTime?: string;
} }
| { | {
isTemporary: false; isTemporary: false;
temporaryMode?: null; temporaryMode?: null;
temporaryRange?: null; temporaryRange?: null;
temporaryAccessStartTime?: null; temporaryAccessStartTime?: null;
temporaryAccessEndTime?: null; temporaryAccessEndTime?: null;
} }
); );
export type TCreateProjectUserPrivilegeDTO = { export type TCreateProjectUserPrivilegeDTO = {
projectMembershipId: string; projectMembershipId: string;
@@ -44,7 +35,7 @@ export type TCreateProjectUserPrivilegeDTO = {
temporaryMode?: ProjectUserAdditionalPrivilegeTemporaryMode; temporaryMode?: ProjectUserAdditionalPrivilegeTemporaryMode;
temporaryRange?: string; temporaryRange?: string;
temporaryAccessStartTime?: string; temporaryAccessStartTime?: string;
permissions: TProjectSpecificPrivilegePermission; permissions: TProjectPermission[];
}; };
export type TUpdateProjectUserPrivlegeDTO = { export type TUpdateProjectUserPrivlegeDTO = {
+3 -3
View File
@@ -22,7 +22,7 @@ export const useCreateProjectRole = () => {
mutationFn: async ({ projectSlug, ...dto }: TCreateProjectRoleDTO) => { mutationFn: async ({ projectSlug, ...dto }: TCreateProjectRoleDTO) => {
const { const {
data: { role } data: { role }
} = await apiRequest.post(`/api/v2/workspace/${projectSlug}/roles`, dto); } = await apiRequest.post(`/api/v1/workspace/${projectSlug}/roles`, dto);
return role; return role;
}, },
onSuccess: (_, { projectSlug }) => { onSuccess: (_, { projectSlug }) => {
@@ -38,7 +38,7 @@ export const useUpdateProjectRole = () => {
mutationFn: async ({ id, projectSlug, ...dto }: TUpdateProjectRoleDTO) => { mutationFn: async ({ id, projectSlug, ...dto }: TUpdateProjectRoleDTO) => {
const { const {
data: { role } data: { role }
} = await apiRequest.patch(`/api/v2/workspace/${projectSlug}/roles/${id}`, dto); } = await apiRequest.patch(`/api/v1/workspace/${projectSlug}/roles/${id}`, dto);
return role; return role;
}, },
onSuccess: (_, { projectSlug }) => { onSuccess: (_, { projectSlug }) => {
@@ -53,7 +53,7 @@ export const useDeleteProjectRole = () => {
mutationFn: async ({ projectSlug, id }: TDeleteProjectRoleDTO) => { mutationFn: async ({ projectSlug, id }: TDeleteProjectRoleDTO) => {
const { const {
data: { role } data: { role }
} = await apiRequest.delete(`/api/v2/workspace/${projectSlug}/roles/${id}`); } = await apiRequest.delete(`/api/v1/workspace/${projectSlug}/roles/${id}`);
return role; return role;
}, },
onSuccess: (_, { projectSlug }) => { onSuccess: (_, { projectSlug }) => {
+4 -30
View File
@@ -7,8 +7,6 @@ import picomatch from "picomatch";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { OrgPermissionSet } from "@app/context/OrgPermissionContext/types"; import { OrgPermissionSet } from "@app/context/OrgPermissionContext/types";
import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext/types"; import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext/types";
import { groupBy } from "@app/lib/fn/array";
import { omit } from "@app/lib/fn/object";
import { OrgUser, TProjectMembership } from "../users/types"; import { OrgUser, TProjectMembership } from "../users/types";
import { import {
@@ -51,7 +49,7 @@ export const roleQueryKeys = {
export const getProjectRoles = async (projectId: string) => { export const getProjectRoles = async (projectId: string) => {
const { data } = await apiRequest.get<{ roles: Array<Omit<TProjectRole, "permissions">> }>( const { data } = await apiRequest.get<{ roles: Array<Omit<TProjectRole, "permissions">> }>(
`/api/v2/workspace/${projectId}/roles` `/api/v1/workspace/${projectId}/roles`
); );
return data.roles; return data.roles;
}; };
@@ -68,7 +66,7 @@ export const useGetProjectRoleBySlug = (projectSlug: string, roleSlug: string) =
queryKey: roleQueryKeys.getProjectRoleBySlug(projectSlug, roleSlug), queryKey: roleQueryKeys.getProjectRoleBySlug(projectSlug, roleSlug),
queryFn: async () => { queryFn: async () => {
const { data } = await apiRequest.get<{ role: TProjectRole }>( const { data } = await apiRequest.get<{ role: TProjectRole }>(
`/api/v2/workspace/${projectSlug}/roles/slug/${roleSlug}` `/api/v1/workspace/${projectSlug}/roles/slug/${roleSlug}`
); );
return data.role; return data.role;
}, },
@@ -136,7 +134,7 @@ const getUserProjectPermissions = async ({ workspaceId }: TGetUserProjectPermiss
permissions: PackRule<RawRuleOf<MongoAbility<OrgPermissionSet>>>[]; permissions: PackRule<RawRuleOf<MongoAbility<OrgPermissionSet>>>[];
membership: Omit<TProjectMembership, "roles"> & { roles: { role: string }[] }; membership: Omit<TProjectMembership, "roles"> & { roles: { role: string }[] };
}; };
}>(`/api/v2/workspace/${workspaceId}/permissions`, {}); }>(`/api/v1/workspace/${workspaceId}/permissions`, {});
return data.data; return data.data;
}; };
@@ -148,32 +146,8 @@ export const useGetUserProjectPermissions = ({ workspaceId }: TGetUserProjectPer
enabled: Boolean(workspaceId), enabled: Boolean(workspaceId),
select: (data) => { select: (data) => {
const rule = unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>(data.permissions); const rule = unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>(data.permissions);
const negatedRules = groupBy( const ability = createMongoAbility<ProjectPermissionSet>(rule, { conditionsMatcher });
rule.filter((i) => i.inverted && i.conditions),
(i) => `${i.subject}-${JSON.stringify(i.conditions)}`
);
const ability = createMongoAbility<ProjectPermissionSet>(rule, {
// this allows in frontend to skip some rules using *
conditionsMatcher: (rules) => {
return (entity) => {
// skip validation if its negated rules
const isNegatedRule =
// eslint-disable-next-line no-underscore-dangle
negatedRules?.[`${entity.__caslSubjectType__}-${JSON.stringify(rules)}`];
if (isNegatedRule) {
const baseMatcher = conditionsMatcher(rules);
return baseMatcher(entity);
}
const rulesStrippedOfWildcard = omit(
rules,
Object.keys(entity).filter((el) => entity[el]?.includes("*"))
);
const baseMatcher = conditionsMatcher(rulesStrippedOfWildcard);
return baseMatcher(entity);
};
}
});
const membership = { const membership = {
...data.membership, ...data.membership,
roles: data.membership.roles.map(({ role }) => role) roles: data.membership.roles.map(({ role }) => role)
-1
View File
@@ -40,7 +40,6 @@ export type TPermission = {
export type TProjectPermission = { export type TProjectPermission = {
conditions?: Record<string, any>; conditions?: Record<string, any>;
inverted?: boolean;
action: string | string[]; action: string | string[];
subject: string | string[]; subject: string | string[];
}; };
-19
View File
@@ -13,22 +13,3 @@ export const groupBy = <T, Key extends string | number | symbol>(
acc[groupId].push(item); acc[groupId].push(item);
return acc; return acc;
}, {} as Record<Key, T[]>); }, {} as Record<Key, T[]>);
/**
* Given a list of items returns a new list with only
* unique items. Accepts an optional identity function
* to convert each item in the list to a comparable identity
* value
*/
export const unique = <T, K extends string | number | symbol>(
array: readonly T[],
toKey?: (item: T) => K
): T[] => {
const valueMap = array.reduce((acc, item) => {
const key = toKey ? toKey(item) : (item as unknown as string | number | symbol);
if (acc[key]) return acc;
acc[key] = item;
return acc;
}, {} as Record<string | number | symbol, T>);
return Object.values(valueMap);
};
-20
View File
@@ -1,20 +0,0 @@
/**
* Omit a list of properties from an object
* returning a new object with the properties
* that remain
*/
export const omit = <T, TKeys extends keyof T>(obj: T, keys: TKeys[]): Omit<T, TKeys> => {
if (!obj) return {} as Omit<T, TKeys>;
if (!keys || keys.length === 0) return obj as Omit<T, TKeys>;
return keys.reduce(
(acc, key) => {
// Gross, I know, it's mutating the object, but we
// are allowing it in this very limited scope due
// to the performance implications of an omit func.
// Not a pattern or practice to use elsewhere.
delete acc[key];
return acc;
},
{ ...obj }
);
};
@@ -184,20 +184,20 @@ export const SpecificPrivilegeSecretForm = ({
{ action: ProjectPermissionActions.Delete, allowed: data.delete }, { action: ProjectPermissionActions.Delete, allowed: data.delete },
{ action: ProjectPermissionActions.Edit, allowed: data.edit } { action: ProjectPermissionActions.Edit, allowed: data.edit }
]; ];
const conditions: { environment: string; secretPath?: { $glob: string } } = { const conditions: Record<string, any> = { environment: data.environmentSlug };
environment: data.environmentSlug
};
if (data.secretPath) { if (data.secretPath) {
conditions.secretPath = { $glob: removeTrailingSlash(data.secretPath) }; conditions.secretPath = { $glob: removeTrailingSlash(data.secretPath) };
} }
await updateUserPrivilege.mutateAsync({ await updateUserPrivilege.mutateAsync({
privilegeId: privilege.id, privilegeId: privilege.id,
...data.temporaryAccess, ...data.temporaryAccess,
permissions: { permissions: actions
subject: ProjectPermissionSub.Secrets, .filter(({ allowed }) => allowed)
conditions, .map(({ action }) => ({
actions: actions.filter((i) => i.allowed).map((i) => i.action) action,
}, subject: [ProjectPermissionSub.Secrets],
conditions
})),
projectMembershipId: privilege.projectMembershipId projectMembershipId: privilege.projectMembershipId
}); });
createNotification({ createNotification({
@@ -642,13 +642,15 @@ export const SpecificPrivilegeSection = ({ membershipId }: Props) => {
if (createUserPrivilege.isLoading) return; if (createUserPrivilege.isLoading) return;
try { try {
await createUserPrivilege.mutateAsync({ await createUserPrivilege.mutateAsync({
permissions: { permissions: [
actions: [ProjectPermissionActions.Read], {
subject: ProjectPermissionSub.Secrets, action: ProjectPermissionActions.Read,
conditions: { subject: [ProjectPermissionSub.Secrets],
environment: currentWorkspace?.environments?.[0].slug || "" conditions: {
environment: currentWorkspace?.environments?.[0].slug
}
} }
}, ],
projectMembershipId: membershipId projectMembershipId: membershipId
}); });
createNotification({ createNotification({
@@ -7,7 +7,6 @@ import {
} from "@app/context"; } from "@app/context";
import { import {
PermissionConditionOperators, PermissionConditionOperators,
ProjectPermissionDynamicSecretActions,
TPermissionCondition, TPermissionCondition,
TPermissionConditionOperators TPermissionConditionOperators
} from "@app/context/ProjectPermissionContext/types"; } from "@app/context/ProjectPermissionContext/types";
@@ -29,12 +28,8 @@ const CmekPolicyActionSchema = z.object({
decrypt: z.boolean().optional() decrypt: z.boolean().optional()
}); });
const DynamicSecretPolicyActionSchema = z.object({ const SecretFolderPolicyActionSchema = z.object({
[ProjectPermissionDynamicSecretActions.ReadRootCredential]: z.boolean().optional(), read: z.boolean().optional()
[ProjectPermissionDynamicSecretActions.EditRootCredential]: z.boolean().optional(),
[ProjectPermissionDynamicSecretActions.DeleteRootCredential]: z.boolean().optional(),
[ProjectPermissionDynamicSecretActions.CreateRootCredential]: z.boolean().optional(),
[ProjectPermissionDynamicSecretActions.Lease]: z.boolean().optional()
}); });
const SecretRollbackPolicyActionSchema = z.object({ const SecretRollbackPolicyActionSchema = z.object({
@@ -47,29 +42,11 @@ const WorkspacePolicyActionSchema = z.object({
delete: z.boolean().optional() delete: z.boolean().optional()
}); });
const ConditionSchema = z const ConditionSchema = z.object({
.object({ operator: z.string(),
operator: z.string(), lhs: z.string(),
lhs: z.string(), rhs: z.string().min(1)
rhs: z.string().min(1) });
})
.array()
.optional()
.default([])
.refine(
(el) => {
const lhsOperatorSet = new Set<string>();
for (let i = 0; i < el.length; i += 1) {
const { lhs, operator } = el[i];
if (lhsOperatorSet.has(`${lhs}-${operator}`)) {
return false;
}
lhsOperatorSet.add(`${lhs}-${operator}`);
}
return true;
},
{ message: "Duplicate operator found for a condition" }
);
export const formSchema = z.object({ export const formSchema = z.object({
name: z.string().trim(), name: z.string().trim(),
@@ -82,29 +59,27 @@ export const formSchema = z.object({
permissions: z permissions: z
.object({ .object({
[ProjectPermissionSub.Secrets]: GeneralPolicyActionSchema.extend({ [ProjectPermissionSub.Secrets]: GeneralPolicyActionSchema.extend({
inverted: z.boolean().optional(), conditions: ConditionSchema.array()
conditions: ConditionSchema .optional()
}) .default([])
.array() .refine(
.default([]), (el) => {
[ProjectPermissionSub.SecretFolders]: GeneralPolicyActionSchema.extend({ const lhsOperatorSet = new Set<string>();
inverted: z.boolean().optional(), for (let i = 0; i < el.length; i += 1) {
conditions: ConditionSchema const { lhs, operator } = el[i];
}) if (lhsOperatorSet.has(`${lhs}-${operator}`)) {
.array() return false;
.default([]), }
[ProjectPermissionSub.SecretImports]: GeneralPolicyActionSchema.extend({ lhsOperatorSet.add(`${lhs}-${operator}`);
inverted: z.boolean().optional(), }
conditions: ConditionSchema return true;
}) },
.array() { message: "Duplicate operator found for a condition" }
.default([]), )
[ProjectPermissionSub.DynamicSecrets]: DynamicSecretPolicyActionSchema.extend({
inverted: z.boolean().optional(),
conditions: ConditionSchema
}) })
.array() .array()
.default([]), .default([]),
[ProjectPermissionSub.SecretFolders]: SecretFolderPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Member]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Member]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Groups]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Groups]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Identity]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Identity]: GeneralPolicyActionSchema.array().default([]),
@@ -123,7 +98,7 @@ export const formSchema = z.object({
[ProjectPermissionSub.CertificateTemplates]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.CertificateTemplates]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretApproval]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretRollback]: SecretRollbackPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Project]: WorkspacePolicyActionSchema.array().default([]), [ProjectPermissionSub.Workspace]: WorkspacePolicyActionSchema.array().default([]),
[ProjectPermissionSub.Tags]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Tags]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretRotation]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.SecretRotation]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]), [ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]),
@@ -135,22 +110,8 @@ export const formSchema = z.object({
export type TFormSchema = z.infer<typeof formSchema>; export type TFormSchema = z.infer<typeof formSchema>;
type TConditionalFields =
| ProjectPermissionSub.Secrets
| ProjectPermissionSub.SecretFolders
| ProjectPermissionSub.SecretImports
| ProjectPermissionSub.DynamicSecrets;
export const isConditionalSubjects = (
subject: ProjectPermissionSub
): subject is TConditionalFields =>
subject === (ProjectPermissionSub.Secrets as const) ||
subject === ProjectPermissionSub.DynamicSecrets ||
subject === ProjectPermissionSub.SecretImports ||
subject === ProjectPermissionSub.SecretFolders;
const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => { const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition) => {
const formConditions: z.infer<typeof ConditionSchema> = []; const formConditions: z.infer<typeof ConditionSchema>[] = [];
Object.entries(caslConditions).forEach(([type, condition]) => { Object.entries(caslConditions).forEach(([type, condition]) => {
if (typeof condition === "string") { if (typeof condition === "string") {
formConditions.push({ formConditions.push({
@@ -177,15 +138,12 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
const formVal: Partial<TFormSchema["permissions"]> = {}; const formVal: Partial<TFormSchema["permissions"]> = {};
permissions.forEach((permission) => { permissions.forEach((permission) => {
const { subject: caslSub, action, conditions, inverted } = permission; const { subject: caslSub, action, conditions } = permission;
const subject = (typeof caslSub === "string" ? caslSub : caslSub[0]) as ProjectPermissionSub; const subject = (typeof caslSub === "string" ? caslSub : caslSub[0]) as ProjectPermissionSub;
if ( if (
[ [
ProjectPermissionSub.Secrets, ProjectPermissionSub.Secrets,
ProjectPermissionSub.DynamicSecrets,
ProjectPermissionSub.SecretFolders,
ProjectPermissionSub.SecretImports,
ProjectPermissionSub.Member, ProjectPermissionSub.Member,
ProjectPermissionSub.Groups, ProjectPermissionSub.Groups,
ProjectPermissionSub.Identity, ProjectPermissionSub.Identity,
@@ -208,67 +166,37 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
ProjectPermissionSub.Kms ProjectPermissionSub.Kms
].includes(subject) ].includes(subject)
) { ) {
const canRead = action.includes(ProjectPermissionActions.Read);
const canEdit = action.includes(ProjectPermissionActions.Edit);
const canDelete = action.includes(ProjectPermissionActions.Delete);
const canCreate = action.includes(ProjectPermissionActions.Create);
// from above statement we are sure it won't be undefined // from above statement we are sure it won't be undefined
if (isConditionalSubjects(subject)) { if (subject === ProjectPermissionSub.Secrets) {
if (!formVal[subject]) formVal[subject] = []; if (!formVal[subject]) formVal[subject] = [];
formVal[subject]!.push({
if (subject === ProjectPermissionSub.DynamicSecrets) { read: canRead,
const canRead = action.includes(ProjectPermissionDynamicSecretActions.ReadRootCredential); create: canCreate,
const canEdit = action.includes(ProjectPermissionDynamicSecretActions.EditRootCredential); edit: canEdit,
const canDelete = action.includes( delete: canDelete,
ProjectPermissionDynamicSecretActions.DeleteRootCredential conditions: conditions ? convertCaslConditionToFormOperator(conditions) : []
); });
const canCreate = action.includes(
ProjectPermissionDynamicSecretActions.CreateRootCredential
);
const canLease = action.includes(ProjectPermissionDynamicSecretActions.Lease);
// from above statement we are sure it won't be undefined
formVal[subject]!.push({
[ProjectPermissionDynamicSecretActions.ReadRootCredential]: canRead,
[ProjectPermissionDynamicSecretActions.CreateRootCredential]: canCreate,
[ProjectPermissionDynamicSecretActions.EditRootCredential]: canEdit,
[ProjectPermissionDynamicSecretActions.DeleteRootCredential]: canDelete,
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
inverted,
[ProjectPermissionDynamicSecretActions.Lease]: canLease
});
} else {
// for other subjects
const canRead = action.includes(ProjectPermissionActions.Read);
const canEdit = action.includes(ProjectPermissionActions.Edit);
const canDelete = action.includes(ProjectPermissionActions.Delete);
const canCreate = action.includes(ProjectPermissionActions.Create);
formVal[subject]!.push({
read: canRead,
create: canCreate,
edit: canEdit,
delete: canDelete,
conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [],
inverted
});
}
} else { } else {
// deduplicate multiple rules for other policies // deduplicate multiple rules for other policies
// because they don't have condition it doesn't make sense for multiple rules // because they don't have condition it doesn't make sense for multiple rules
const canRead = action.includes(ProjectPermissionActions.Read);
const canEdit = action.includes(ProjectPermissionActions.Edit);
const canDelete = action.includes(ProjectPermissionActions.Delete);
const canCreate = action.includes(ProjectPermissionActions.Create);
if (!formVal[subject]) formVal[subject] = [{}]; if (!formVal[subject]) formVal[subject] = [{}];
if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true; if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true;
if (canEdit) formVal[subject as ProjectPermissionSub.Member]![0].edit = true; if (canEdit) formVal[subject as ProjectPermissionSub.Member]![0].edit = true;
if (canCreate) formVal[subject as ProjectPermissionSub.Member]![0].create = true; if (canCreate) formVal[subject as ProjectPermissionSub.Member]![0].create = true;
if (canDelete) formVal[subject as ProjectPermissionSub.Member]![0].delete = true; if (canDelete) formVal[subject as ProjectPermissionSub.Member]![0].delete = true;
} }
} else if (subject === ProjectPermissionSub.Project) { } else if (subject === ProjectPermissionSub.Workspace) {
const canEdit = action.includes(ProjectPermissionActions.Edit); const canEdit = action.includes(ProjectPermissionActions.Edit);
const canDelete = action.includes(ProjectPermissionActions.Delete); const canDelete = action.includes(ProjectPermissionActions.Delete);
if (!formVal[subject]) formVal[subject] = [{}]; if (!formVal[subject]) formVal[subject] = [{}];
// from above statement we are sure it won't be undefined // from above statement we are sure it won't be undefined
if (canEdit) formVal[subject as ProjectPermissionSub.Project]![0].edit = true; if (canEdit) formVal[subject as ProjectPermissionSub.Workspace]![0].edit = true;
if (canDelete) formVal[subject as ProjectPermissionSub.Member]![0].delete = true; if (canDelete) formVal[subject as ProjectPermissionSub.Member]![0].delete = true;
} else if (subject === ProjectPermissionSub.SecretRollback) { } else if (subject === ProjectPermissionSub.SecretRollback) {
const canRead = action.includes(ProjectPermissionActions.Read); const canRead = action.includes(ProjectPermissionActions.Read);
@@ -278,6 +206,12 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
// from above statement we are sure it won't be undefined // from above statement we are sure it won't be undefined
if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true; if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true;
if (canCreate) formVal[subject as ProjectPermissionSub.Member]![0].create = true; if (canCreate) formVal[subject as ProjectPermissionSub.Member]![0].create = true;
} else if (subject === ProjectPermissionSub.SecretFolders) {
const canRead = action.includes(ProjectPermissionActions.Read);
if (!formVal[subject]) formVal[subject] = [{}];
// from above statement we are sure it won't be undefined
if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true;
} else if (subject === ProjectPermissionSub.Cmek) { } else if (subject === ProjectPermissionSub.Cmek) {
const canRead = action.includes(ProjectPermissionCmekActions.Read); const canRead = action.includes(ProjectPermissionCmekActions.Read);
const canEdit = action.includes(ProjectPermissionCmekActions.Edit); const canEdit = action.includes(ProjectPermissionCmekActions.Edit);
@@ -330,7 +264,7 @@ export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => {
Object.entries(formVal || {}).forEach(([subject, rules]) => { Object.entries(formVal || {}).forEach(([subject, rules]) => {
rules.forEach((actions) => { rules.forEach((actions) => {
const caslActions = Object.keys(actions).filter( const caslActions = Object.keys(actions).filter(
(el) => actions?.[el as keyof typeof actions] && el !== "conditions" && el !== "inverted" (el) => actions?.[el as keyof typeof actions] && el !== "conditions"
); );
const caslConditions = const caslConditions =
"conditions" in actions "conditions" in actions
@@ -340,7 +274,6 @@ export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => {
permissions.push({ permissions.push({
action: caslActions, action: caslActions,
subject, subject,
inverted: (actions as { inverted?: boolean })?.inverted,
conditions: caslConditions conditions: caslConditions
}); });
}); });
@@ -355,7 +288,7 @@ export type TProjectPermissionObject = {
label: string; label: string;
value: keyof Omit< value: keyof Omit<
NonNullable<NonNullable<TFormSchema["permissions"]>[K]>[number], NonNullable<NonNullable<TFormSchema["permissions"]>[K]>[number],
"conditions" | "inverted" "conditions"
>; >;
}[]; }[];
}; };
@@ -373,42 +306,7 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
}, },
[ProjectPermissionSub.SecretFolders]: { [ProjectPermissionSub.SecretFolders]: {
title: "Secret Folders", title: "Secret Folders",
actions: [ actions: [{ label: "Read Only", value: "read" }]
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.SecretImports]: {
title: "Secret Imports",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" }
]
},
[ProjectPermissionSub.DynamicSecrets]: {
title: "Dynamic Secrets",
actions: [
{
label: "Read root credentials",
value: ProjectPermissionDynamicSecretActions.ReadRootCredential
},
{
label: "Create root credentials",
value: ProjectPermissionDynamicSecretActions.CreateRootCredential
},
{
label: "Modify root credentials",
value: ProjectPermissionDynamicSecretActions.EditRootCredential
},
{
label: "Remove root credentials",
value: ProjectPermissionDynamicSecretActions.DeleteRootCredential
},
{ label: "Manage Leases", value: ProjectPermissionDynamicSecretActions.Lease }
]
}, },
[ProjectPermissionSub.Cmek]: { [ProjectPermissionSub.Cmek]: {
title: "KMS", title: "KMS",
@@ -434,7 +332,7 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
{ label: "Remove", value: "delete" } { label: "Remove", value: "delete" }
] ]
}, },
[ProjectPermissionSub.Project]: { [ProjectPermissionSub.Workspace]: {
title: "Project", title: "Project",
actions: [ actions: [
{ label: "Update project details", value: "edit" }, { label: "Update project details", value: "edit" },
@@ -10,15 +10,13 @@ import { ProjectPermissionSub, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api"; import { useGetProjectRoleBySlug, useUpdateProjectRole } from "@app/hooks/api";
import { GeneralPermissionConditions } from "./components/GeneralPermissionConditions"; import { GeneralPermissionOptions } from "./components/GeneralPermissionOptions";
import { GeneralPermissionPolicies } from "./components/GeneralPermissionPolicies";
import { NewPermissionRule } from "./components/NewPermissionRule"; import { NewPermissionRule } from "./components/NewPermissionRule";
import { SecretPermissionConditions } from "./components/SecretPermissionConditions"; import { SecretPermissionConditions } from "./components/SecretPermissionConditions";
import { PermissionEmptyState } from "./PermissionEmptyState"; import { PermissionEmptyState } from "./PermissionEmptyState";
import { import {
formRolePermission2API, formRolePermission2API,
formSchema, formSchema,
isConditionalSubjects,
PROJECT_PERMISSION_OBJECT, PROJECT_PERMISSION_OBJECT,
rolePermission2Form, rolePermission2Form,
TFormSchema TFormSchema
@@ -29,17 +27,6 @@ type Props = {
isDisabled?: boolean; isDisabled?: boolean;
}; };
const renderConditionalComponents = (subject: ProjectPermissionSub, isDisabled?: boolean) => {
if (subject === ProjectPermissionSub.Secrets)
return <SecretPermissionConditions isDisabled={isDisabled} />;
if (isConditionalSubjects(subject)) {
return <GeneralPermissionConditions isDisabled={isDisabled} type={subject} />;
}
return undefined;
};
export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => { export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const); const { popUp, handlePopUpToggle } = usePopUp(["createPolicy"] as const);
@@ -143,15 +130,17 @@ export const RolePermissionsSection = ({ roleSlug, isDisabled }: Props) => {
<div className="py-4"> <div className="py-4">
{!isLoading && <PermissionEmptyState />} {!isLoading && <PermissionEmptyState />}
{(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => ( {(Object.keys(PROJECT_PERMISSION_OBJECT) as ProjectPermissionSub[]).map((subject) => (
<GeneralPermissionPolicies <GeneralPermissionOptions
subject={subject} subject={subject}
actions={PROJECT_PERMISSION_OBJECT[subject].actions} actions={PROJECT_PERMISSION_OBJECT[subject].actions}
title={PROJECT_PERMISSION_OBJECT[subject].title} title={PROJECT_PERMISSION_OBJECT[subject].title}
key={`project-permission-${subject}`} key={`project-permission-${subject}`}
isDisabled={isDisabled} isDisabled={isDisabled}
> >
{renderConditionalComponents(subject, isDisabled)} {subject === ProjectPermissionSub.Secrets ? (
</GeneralPermissionPolicies> <SecretPermissionConditions isDisabled={isDisabled} />
) : undefined}
</GeneralPermissionOptions>
))} ))}
</div> </div>
</FormProvider> </FormProvider>
@@ -1,176 +0,0 @@
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
FormControl,
IconButton,
Input,
Select,
SelectItem,
Tooltip
} from "@app/components/v2";
import {
PermissionConditionOperators,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { TFormSchema } from "../ProjectRoleModifySection.utils";
import {
getConditionOperatorHelperInfo,
renderOperatorSelectItems
} from "./PermissionConditionHelpers";
type Props = {
position?: number;
isDisabled?: boolean;
type:
| ProjectPermissionSub.DynamicSecrets
| ProjectPermissionSub.SecretFolders
| ProjectPermissionSub.SecretImports;
};
export const GeneralPermissionConditions = ({ position = 0, isDisabled, type }: Props) => {
const {
control,
watch,
formState: { errors }
} = useFormContext<TFormSchema>();
const items = useFieldArray({
control,
name: `permissions.${type}.${position}.conditions`
});
return (
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2">
<p className="mt-2 text-gray-300">Conditions</p>
<p className="mb-2 text-sm text-mineshaft-400">
When this policy should apply (always if no conditions are added).
</p>
<div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => {
const condition =
(watch(`permissions.${type}.${position}.conditions.${index}`) as {
lhs: string;
rhs: string;
operator: string;
}) || {};
return (
<div
key={el.id}
className="flex gap-2 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md"
>
<div className="w-1/4">
<Controller
control={control}
name={`permissions.${type}.${position}.conditions.${index}.lhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
<SelectItem value="environment">Environment Slug</SelectItem>
<SelectItem value="secretPath">Secret Path</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex w-36 items-center space-x-2">
<Controller
control={control}
name={`permissions.${type}.${position}.conditions.${index}.operator`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => field.onChange(e)}
className="w-full"
>
{renderOperatorSelectItems(condition.lhs)}
</Select>
</FormControl>
)}
/>
<div>
<Tooltip
asChild
content={getConditionOperatorHelperInfo(
condition?.operator as PermissionConditionOperators
)}
className="max-w-xs"
>
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="text-gray-400" />
</Tooltip>
</div>
</div>
<div className="flex-grow">
<Controller
control={control}
name={`permissions.${type}.${position}.conditions.${index}.rhs`}
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
className="mb-0 flex-grow"
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div>
<IconButton
ariaLabel="plus"
variant="outline_bg"
className="p-2.5"
onClick={() => items.remove(index)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
</div>
);
})}
</div>
{errors?.permissions?.[type]?.[position]?.conditions?.message && (
<div className="flex items-center space-x-2 py-2 text-sm text-gray-400">
<FontAwesomeIcon icon={faWarning} className="text-red" />
<span>{errors?.permissions?.[type]?.[position]?.conditions?.message}</span>
</div>
)}
<div>{}</div>
<div>
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
variant="star"
size="xs"
className="mt-3"
isDisabled={isDisabled}
onClick={() =>
items.append({
lhs: "environment",
operator: PermissionConditionOperators.$EQ,
rhs: ""
})
}
>
Add Condition
</Button>
</div>
</div>
);
};
@@ -1,24 +1,14 @@
import { cloneElement } from "react"; import { cloneElement } from "react";
import { Controller, useFieldArray, useFormContext } from "react-hook-form"; import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import { import { faChevronDown, faChevronRight, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
faChevronDown,
faChevronRight,
faInfoCircle,
faPlus,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { Button, Checkbox, Select, SelectItem, Tag, Tooltip } from "@app/components/v2"; import { Button, Checkbox, Tag } from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionSub } from "@app/context";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
import { import { TFormSchema, TProjectPermissionObject } from "../ProjectRoleModifySection.utils";
isConditionalSubjects,
TFormSchema,
TProjectPermissionObject
} from "../ProjectRoleModifySection.utils";
type Props<T extends ProjectPermissionSub> = { type Props<T extends ProjectPermissionSub> = {
title: string; title: string;
@@ -28,7 +18,7 @@ type Props<T extends ProjectPermissionSub> = {
isDisabled?: boolean; isDisabled?: boolean;
}; };
export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchema["permissions"]>>({ export const GeneralPermissionOptions = <T extends keyof NonNullable<TFormSchema["permissions"]>>({
subject, subject,
actions, actions,
children, children,
@@ -73,44 +63,6 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
<div key={`select-${subject}-type`} className="flex flex-col space-y-4 bg-bunker-800 p-6"> <div key={`select-${subject}-type`} className="flex flex-col space-y-4 bg-bunker-800 p-6">
{items.fields.map((el, rootIndex) => ( {items.fields.map((el, rootIndex) => (
<div key={el.id} className="bg-mineshaft-800 p-5 first:rounded-t-md last:rounded-b-md"> <div key={el.id} className="bg-mineshaft-800 p-5 first:rounded-t-md last:rounded-b-md">
{isConditionalSubjects(subject) && (
<div className="mt-4 mb-6 flex w-full items-center text-gray-300">
<div className="w-1/4">Permission</div>
<div className="mr-4 w-1/4">
<Controller
defaultValue={false as any}
name={`permissions.${subject}.${rootIndex}.inverted`}
render={({ field }) => (
<Select
value={String(field.value)}
onValueChange={(val) => field.onChange(val === "true")}
containerClassName="w-full"
className="w-full"
>
<SelectItem value="false">Allow</SelectItem>
<SelectItem value="true">Forbid</SelectItem>
</Select>
)}
/>
</div>
<div>
<Tooltip
asChild
content={
<>
<p>
Whether to allow or forbid the selected actions when the following
conditions (if any) are met.
</p>
<p className="mt-2">Forbid rules must come after allow rules.</p>
</>
}
>
<FontAwesomeIcon icon={faInfoCircle} size="sm" className="text-gray-400" />
</Tooltip>
</div>
</div>
)}
<div className="flex text-gray-300"> <div className="flex text-gray-300">
<div className="w-1/4">Actions</div> <div className="w-1/4">Actions</div>
<div className="flex flex-grow flex-wrap justify-start gap-8"> <div className="flex flex-grow flex-wrap justify-start gap-8">
@@ -146,10 +98,10 @@ export const GeneralPermissionPolicies = <T extends keyof NonNullable<TFormSchem
<div <div
className={twMerge( className={twMerge(
"mt-4 flex justify-start space-x-4", "mt-4 flex justify-start space-x-4",
isConditionalSubjects(subject) && "justify-end" subject === ProjectPermissionSub.Secrets && "justify-end"
)} )}
> >
{!isDisabled && isConditionalSubjects(subject) && ( {!isDisabled && subject === ProjectPermissionSub.Secrets && (
<Button <Button
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
variant="star" variant="star"
@@ -15,7 +15,6 @@ import { ProjectPermissionSub } from "@app/context";
import { import {
formSchema, formSchema,
isConditionalSubjects,
PROJECT_PERMISSION_OBJECT, PROJECT_PERMISSION_OBJECT,
TFormSchema TFormSchema
} from "../ProjectRoleModifySection.utils"; } from "../ProjectRoleModifySection.utils";
@@ -90,7 +89,7 @@ export const NewPermissionRule = ({ onClose }: Props) => {
<Button <Button
onClick={form.handleSubmit((el) => { onClick={form.handleSubmit((el) => {
const rootPolicyValue = rootForm.getValues("permissions")?.[el.type]; const rootPolicyValue = rootForm.getValues("permissions")?.[el.type];
if (rootPolicyValue && isConditionalSubjects(selectedSubject)) { if (rootPolicyValue && selectedSubject === ProjectPermissionSub.Secrets) {
rootForm.setValue( rootForm.setValue(
`permissions.${el.type}`, `permissions.${el.type}`,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -1,32 +0,0 @@
import { GlobPermissionInfo } from "@app/components/permissions";
import { SelectItem } from "@app/components/v2";
import { PermissionConditionOperators } from "@app/context/ProjectPermissionContext/types";
export const getConditionOperatorHelperInfo = (type: PermissionConditionOperators) => {
switch (type) {
case PermissionConditionOperators.$EQ:
return "Value should equal specified value.";
case PermissionConditionOperators.$NEQ:
return "Value should not equal specified value.";
case PermissionConditionOperators.$IN:
return "List of comma-separated values that match a given value.";
case PermissionConditionOperators.$GLOB:
return <GlobPermissionInfo />;
default:
return "";
}
};
export const renderOperatorSelectItems = (type: string) => {
if (type === "secretTags") {
return <SelectItem value={PermissionConditionOperators.$IN}>Contains</SelectItem>;
}
return (
<>
<SelectItem value={PermissionConditionOperators.$EQ}>Equal</SelectItem>
<SelectItem value={PermissionConditionOperators.$NEQ}>Not Equal</SelectItem>
<SelectItem value={PermissionConditionOperators.$GLOB}>Glob Match</SelectItem>
<SelectItem value={PermissionConditionOperators.$IN}>Contains</SelectItem>
</>
);
};
@@ -1,34 +1,27 @@
import { Controller, useFieldArray, useFormContext } from "react-hook-form"; import { Controller, useFieldArray, useFormContext } from "react-hook-form";
import { faInfoCircle, faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons"; import { faPlus, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2";
Button,
FormControl,
IconButton,
Input,
Select,
SelectItem,
Tooltip
} from "@app/components/v2";
import { PermissionConditionOperators } from "@app/context/ProjectPermissionContext/types"; import { PermissionConditionOperators } from "@app/context/ProjectPermissionContext/types";
import { TFormSchema } from "../ProjectRoleModifySection.utils"; import { TFormSchema } from "../ProjectRoleModifySection.utils";
import {
getConditionOperatorHelperInfo,
renderOperatorSelectItems
} from "./PermissionConditionHelpers";
type Props = { type Props = {
position?: number; position?: number;
isDisabled?: boolean; isDisabled?: boolean;
}; };
const getValueLabel = (type: string) => {
if (type === "environment") return "Environment slug";
if (type === "secretPath") return "Folder path";
return "";
};
export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props) => { export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props) => {
const { const {
control, control,
watch, watch,
setValue,
formState: { errors } formState: { errors }
} = useFormContext<TFormSchema>(); } = useFormContext<TFormSchema>();
const items = useFieldArray({ const items = useFieldArray({
@@ -37,18 +30,10 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props)
}); });
return ( return (
<div className="mt-6 border-t border-t-mineshaft-600 bg-mineshaft-800 pt-2"> <div className="mt-6 border-t border-t-gray-800 bg-mineshaft-800 pt-2">
<p className="mt-2 text-gray-300">Conditions</p>
<p className="mb-2 text-sm text-mineshaft-400">
When this policy should apply (always if no conditions are added).
</p>
<div className="mt-2 flex flex-col space-y-2"> <div className="mt-2 flex flex-col space-y-2">
{items.fields.map((el, index) => { {items.fields.map((el, index) => {
const condition = watch(`permissions.secrets.${position}.conditions.${index}`) as { const lhs = watch(`permissions.secrets.${position}.conditions.${index}.lhs`);
lhs: string;
rhs: string;
operator: string;
};
return ( return (
<div <div
key={el.id} key={el.id}
@@ -67,25 +52,17 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props)
<Select <Select
defaultValue={field.value} defaultValue={field.value}
{...field} {...field}
onValueChange={(e) => { onValueChange={(e) => field.onChange(e)}
setValue(
`permissions.secrets.${position}.conditions.${index}.operator`,
PermissionConditionOperators.$IN as never
);
field.onChange(e);
}}
className="w-full" className="w-full"
> >
<SelectItem value="environment">Environment Slug</SelectItem> <SelectItem value="environment">Environment Slug</SelectItem>
<SelectItem value="secretPath">Secret Path</SelectItem> <SelectItem value="secretPath">Secret Path</SelectItem>
<SelectItem value="secretName">Secret Name</SelectItem>
<SelectItem value="secretTags">Secret Tags</SelectItem>
</Select> </Select>
</FormControl> </FormControl>
)} )}
/> />
</div> </div>
<div className="flex w-36 items-center space-x-2"> <div className="w-36">
<Controller <Controller
control={control} control={control}
name={`permissions.secrets.${position}.conditions.${index}.operator`} name={`permissions.secrets.${position}.conditions.${index}.operator`}
@@ -101,22 +78,16 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props)
onValueChange={(e) => field.onChange(e)} onValueChange={(e) => field.onChange(e)}
className="w-full" className="w-full"
> >
{renderOperatorSelectItems(condition.lhs)} <SelectItem value={PermissionConditionOperators.$EQ}>Equal</SelectItem>
<SelectItem value={PermissionConditionOperators.$NEQ}>Not Equal</SelectItem>
<SelectItem value={PermissionConditionOperators.$GLOB}>
Glob Match
</SelectItem>
<SelectItem value={PermissionConditionOperators.$IN}>Contains</SelectItem>
</Select> </Select>
</FormControl> </FormControl>
)} )}
/> />
<div>
<Tooltip
asChild
content={getConditionOperatorHelperInfo(
condition?.operator as PermissionConditionOperators
)}
className="max-w-xs"
>
<FontAwesomeIcon icon={faInfoCircle} size="xs" className="text-gray-400" />
</Tooltip>
</div>
</div> </div>
<div className="flex-grow"> <div className="flex-grow">
<Controller <Controller
@@ -128,7 +99,7 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props)
errorText={error?.message} errorText={error?.message}
className="mb-0 flex-grow" className="mb-0 flex-grow"
> >
<Input {...field} /> <Input {...field} placeholder={getValueLabel(lhs)} />
</FormControl> </FormControl>
)} )}
/> />
@@ -153,6 +124,7 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props)
<span>{errors?.permissions?.secrets?.[position]?.conditions?.message}</span> <span>{errors?.permissions?.secrets?.[position]?.conditions?.message}</span>
</div> </div>
)} )}
<div>{}</div>
<div> <div>
<Button <Button
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
@@ -168,7 +140,7 @@ export const SecretPermissionConditions = ({ position = 0, isDisabled }: Props)
}) })
} }
> >
Add Condition New Condition
</Button> </Button>
</div> </div>
</div> </div>
@@ -12,7 +12,6 @@ import { PermissionDeniedBanner } from "@app/components/permissions";
import { ContentLoader, Pagination } from "@app/components/v2"; import { ContentLoader, Pagination } from "@app/components/v2";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionDynamicSecretActions,
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission, useProjectPermission,
useWorkspace useWorkspace
@@ -38,7 +37,7 @@ import { ActionBar } from "./components/ActionBar";
import { CreateSecretForm } from "./components/CreateSecretForm"; import { CreateSecretForm } from "./components/CreateSecretForm";
import { PitDrawer } from "./components/PitDrawer"; import { PitDrawer } from "./components/PitDrawer";
import { SecretDropzone } from "./components/SecretDropzone"; import { SecretDropzone } from "./components/SecretDropzone";
import { SecretListView, SecretNoAccessListView } from "./components/SecretListView"; import { SecretListView } from "./components/SecretListView";
import { SnapshotView } from "./components/SnapshotView"; import { SnapshotView } from "./components/SnapshotView";
import { StoreProvider } from "./SecretMainPage.store"; import { StoreProvider } from "./SecretMainPage.store";
import { Filter, RowType } from "./SecretMainPage.types"; import { Filter, RowType } from "./SecretMainPage.types";
@@ -80,24 +79,8 @@ export const SecretMainPage = () => {
const secretPath = (router.query.secretPath as string) || "/"; const secretPath = (router.query.secretPath as string) || "/";
const canReadSecret = permission.can( const canReadSecret = permission.can(
ProjectPermissionActions.Read, ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { subject(ProjectPermissionSub.Secrets, { environment, secretPath })
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})
); );
const canReadSecretImports = permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.SecretImports, { environment, secretPath })
);
const canReadDynamicSecret = permission.can(
ProjectPermissionDynamicSecretActions.ReadRootCredential,
subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })
);
const canDoReadRollback = permission.can( const canDoReadRollback = permission.can(
ProjectPermissionActions.Read, ProjectPermissionActions.Read,
ProjectPermissionSub.SecretRollback ProjectPermissionSub.SecretRollback
@@ -106,12 +89,11 @@ export const SecretMainPage = () => {
const defaultFilterState = { const defaultFilterState = {
tags: {}, tags: {},
searchFilter: (router.query.searchFilter as string) || "", searchFilter: (router.query.searchFilter as string) || "",
// these should always be on by default for the UI, they will be disabled for the query below based off permissions
include: { include: {
[RowType.Folder]: true, [RowType.Folder]: true,
[RowType.Import]: true, [RowType.Import]: canReadSecret,
[RowType.DynamicSecret]: true, [RowType.DynamicSecret]: canReadSecret,
[RowType.Secret]: true [RowType.Secret]: canReadSecret
} }
}; };
@@ -119,6 +101,19 @@ export const SecretMainPage = () => {
const [debouncedSearchFilter, setDebouncedSearchFilter] = useDebounce(filter.searchFilter); const [debouncedSearchFilter, setDebouncedSearchFilter] = useDebounce(filter.searchFilter);
const [filterHistory, setFilterHistory] = useState<Map<string, Filter>>(new Map()); const [filterHistory, setFilterHistory] = useState<Map<string, Filter>>(new Map());
// change filters if permissions change at different paths/env
useEffect(() => {
setFilter((prev) => ({
...prev,
include: {
[RowType.Folder]: true,
[RowType.Import]: canReadSecret,
[RowType.DynamicSecret]: canReadSecret,
[RowType.Secret]: canReadSecret
}
}));
}, [canReadSecret]);
useEffect(() => { useEffect(() => {
if ( if (
!isWorkspaceLoading && !isWorkspaceLoading &&
@@ -146,9 +141,9 @@ export const SecretMainPage = () => {
orderBy, orderBy,
search: debouncedSearchFilter, search: debouncedSearchFilter,
orderDirection, orderDirection,
includeImports: canReadSecretImports && filter.include.import, includeImports: canReadSecret && filter.include.import,
includeFolders: filter.include.folder, includeFolders: filter.include.folder,
includeDynamicSecrets: canReadDynamicSecret && filter.include.dynamic, includeDynamicSecrets: canReadSecret && filter.include.dynamic,
includeSecrets: canReadSecret && filter.include.secret, includeSecrets: canReadSecret && filter.include.secret,
tags: filter.tags tags: filter.tags
}); });
@@ -210,20 +205,8 @@ export const SecretMainPage = () => {
isPaused: !canDoReadRollback isPaused: !canDoReadRollback
}); });
const noAccessSecretCount = Math.max(
(page * perPage > totalCount ? totalCount % perPage : perPage) -
(imports?.length || 0) -
(folders?.length || 0) -
(secrets?.length || 0) -
(dynamicSecrets?.length || 0),
0
);
const isNotEmpty = Boolean( const isNotEmpty = Boolean(
secrets?.length || secrets?.length || folders?.length || imports?.length || dynamicSecrets?.length
folders?.length ||
imports?.length ||
dynamicSecrets?.length ||
noAccessSecretCount
); );
const handleSortToggle = () => const handleSortToggle = () =>
@@ -315,6 +298,7 @@ export const SecretMainPage = () => {
setFilter(defaultFilterState); setFilter(defaultFilterState);
setDebouncedSearchFilter(""); setDebouncedSearchFilter("");
}; };
return ( return (
<StoreProvider> <StoreProvider>
<div className="container mx-auto flex flex-col px-6 text-mineshaft-50 dark:[color-scheme:dark]"> <div className="container mx-auto flex flex-col px-6 text-mineshaft-50 dark:[color-scheme:dark]">
@@ -378,7 +362,7 @@ export const SecretMainPage = () => {
<div className="flex-grow px-4 py-2">Value</div> <div className="flex-grow px-4 py-2">Value</div>
</div> </div>
)} )}
{canReadSecretImports && Boolean(imports?.length) && ( {canReadSecret && imports?.length && (
<SecretImportListView <SecretImportListView
searchTerm={debouncedSearchFilter} searchTerm={debouncedSearchFilter}
secretImports={imports} secretImports={imports}
@@ -389,7 +373,7 @@ export const SecretMainPage = () => {
importedSecrets={importedSecrets} importedSecrets={importedSecrets}
/> />
)} )}
{Boolean(folders?.length) && ( {folders?.length && (
<FolderListView <FolderListView
folders={folders} folders={folders}
environment={environment} environment={environment}
@@ -398,7 +382,7 @@ export const SecretMainPage = () => {
onNavigateToFolder={handleResetFilter} onNavigateToFolder={handleResetFilter}
/> />
)} )}
{canReadDynamicSecret && Boolean(dynamicSecrets?.length) && ( {canReadSecret && dynamicSecrets?.length && (
<DynamicSecretListView <DynamicSecretListView
environment={environment} environment={environment}
projectSlug={projectSlug} projectSlug={projectSlug}
@@ -406,7 +390,7 @@ export const SecretMainPage = () => {
dynamicSecrets={dynamicSecrets} dynamicSecrets={dynamicSecrets}
/> />
)} )}
{canReadSecret && Boolean(secrets?.length) && ( {canReadSecret && secrets?.length && (
<SecretListView <SecretListView
secrets={secrets} secrets={secrets}
tags={tags} tags={tags}
@@ -417,11 +401,7 @@ export const SecretMainPage = () => {
isProtectedBranch={isProtectedBranch} isProtectedBranch={isProtectedBranch}
/> />
)} )}
{canReadSecret && <SecretNoAccessListView count={noAccessSecretCount} />} {!canReadSecret && folders?.length === 0 && <PermissionDeniedBanner />}
{!canReadSecret &&
!canReadDynamicSecret &&
!canReadSecretImports &&
folders?.length === 0 && <PermissionDeniedBanner />}
</div> </div>
</div> </div>
{!isDetailsLoading && totalCount > 0 && ( {!isDetailsLoading && totalCount > 0 && (
@@ -47,8 +47,8 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionDynamicSecretActions,
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission,
useSubscription useSubscription
} from "@app/context"; } from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
@@ -125,6 +125,12 @@ export const ActionBar = ({
const { reset: resetSelectedSecret } = useSelectedSecretActions(); const { reset: resetSelectedSecret } = useSelectedSecretActions();
const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length); const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length);
const { permission } = useProjectPermission();
const shouldCheckFolderPermission = permission.rules.some((rule) =>
(rule.subject as ProjectPermissionSub[]).includes(ProjectPermissionSub.SecretFolders)
);
const handleFolderCreate = async (folderName: string) => { const handleFolderCreate = async (folderName: string) => {
try { try {
await createFolder({ await createFolder({
@@ -432,12 +438,7 @@ export const ActionBar = ({
<div className="flex items-center"> <div className="flex items-center">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Create} I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})}
> >
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
@@ -468,7 +469,12 @@ export const ActionBar = ({
<div className="flex flex-col space-y-1 p-1.5"> <div className="flex flex-col space-y-1 p-1.5">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Create} I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })} a={subject(
shouldCheckFolderPermission
? ProjectPermissionSub.SecretFolders
: ProjectPermissionSub.Secrets,
{ environment, secretPath }
)}
> >
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
@@ -487,13 +493,8 @@ export const ActionBar = ({
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.CreateRootCredential} I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.DynamicSecrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})}
> >
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
@@ -517,10 +518,7 @@ export const ActionBar = ({
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Create} I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.SecretImports, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath
})}
> >
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
@@ -560,12 +558,7 @@ export const ActionBar = ({
</div> </div>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Delete} I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})}
renderTooltip renderTooltip
allowedLabel="Move" allowedLabel="Move"
> >
@@ -584,12 +577,7 @@ export const ActionBar = ({
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Delete} I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})}
renderTooltip renderTooltip
allowedLabel="Delete" allowedLabel="Delete"
> >
@@ -26,7 +26,7 @@ import {
Tooltip, Tooltip,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { ProjectPermissionDynamicSecretActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useGetDynamicSecretLeases, useRevokeDynamicSecretLease } from "@app/hooks/api"; import { useGetDynamicSecretLeases, useRevokeDynamicSecretLease } from "@app/hooks/api";
import { DynamicSecretLeaseStatus } from "@app/hooks/api/dynamicSecretLease/types"; import { DynamicSecretLeaseStatus } from "@app/hooks/api/dynamicSecretLease/types";
@@ -60,6 +60,7 @@ export const DynamicSecretLease = ({
path: secretPath, path: secretPath,
dynamicSecretName dynamicSecretName
}); });
const deleteDynamicSecretLease = useRevokeDynamicSecretLease(); const deleteDynamicSecretLease = useRevokeDynamicSecretLease();
@@ -139,8 +140,8 @@ export const DynamicSecretLease = ({
<Td> <Td>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.Lease} I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })} a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip renderTooltip
allowedLabel="Renew" allowedLabel="Renew"
> >
@@ -158,8 +159,8 @@ export const DynamicSecretLease = ({
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.Lease} I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })} a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip renderTooltip
allowedLabel="Delete" allowedLabel="Delete"
> >
@@ -178,11 +179,8 @@ export const DynamicSecretLease = ({
</ProjectPermissionCan> </ProjectPermissionCan>
{status === DynamicSecretLeaseStatus.FailedDeletion && ( {status === DynamicSecretLeaseStatus.FailedDeletion && (
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.Lease} I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.DynamicSecrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath
})}
renderTooltip renderTooltip
allowedLabel="Force Delete. This action will remove the secret from internal storage, but it will remain in external systems." allowedLabel="Force Delete. This action will remove the secret from internal storage, but it will remain in external systems."
> >
@@ -211,19 +209,9 @@ export const DynamicSecretLease = ({
</TableContainer> </TableContainer>
{!isLeaseLoading && Boolean(leases?.length) && ( {!isLeaseLoading && Boolean(leases?.length) && (
<div className="mt-6 flex items-center space-x-4"> <div className="mt-6 flex items-center space-x-4">
<ProjectPermissionCan <Button onClick={onClickNewLease} size="xs">
I={ProjectPermissionDynamicSecretActions.Lease} New Lease
a={subject(ProjectPermissionSub.DynamicSecrets, { </Button>
environment,
secretPath
})}
>
{(isAllowed) => (
<Button onClick={onClickNewLease} size="xs" isDisabled={!isAllowed}>
New Lease
</Button>
)}
</ProjectPermissionCan>
<Button onClick={onClose} variant="plain" colorSchema="secondary" size="xs"> <Button onClick={onClose} variant="plain" colorSchema="secondary" size="xs">
Close Close
</Button> </Button>
@@ -18,7 +18,7 @@ import {
Tag, Tag,
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { ProjectPermissionDynamicSecretActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useDeleteDynamicSecret } from "@app/hooks/api"; import { useDeleteDynamicSecret } from "@app/hooks/api";
import { import {
@@ -132,27 +132,17 @@ export const DynamicSecretListView = ({
)} )}
</div> </div>
<div className="flex items-center space-x-2 px-4 py-2"> <div className="flex items-center space-x-2 px-4 py-2">
<ProjectPermissionCan <Button
I={ProjectPermissionDynamicSecretActions.Lease} size="xs"
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })} className="m-0 py-0.5 px-2 opacity-0 group-hover:opacity-100"
renderTooltip isDisabled={isRevoking}
allowedLabel="Edit" onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("createDynamicSecretLease", secret);
}}
> >
{(isAllowed) => ( Generate
<Button </Button>
size="xs"
className="m-0 py-0.5 px-2 opacity-0 group-hover:opacity-100"
isDisabled={isRevoking || !isAllowed}
onClick={(evt) => {
evt.stopPropagation();
handlePopUpOpen("createDynamicSecretLease", secret);
}}
>
Generate
</Button>
)}
</ProjectPermissionCan>
{secret.status === DynamicSecretStatus.FailedDeletion && ( {secret.status === DynamicSecretStatus.FailedDeletion && (
<Tooltip content="This action will remove the secret from internal storage, but it will remain in external systems. Use this option only after you've confirmed that your external leases are handled."> <Tooltip content="This action will remove the secret from internal storage, but it will remain in external systems. Use this option only after you've confirmed that your external leases are handled.">
<Button <Button
@@ -175,8 +165,8 @@ export const DynamicSecretListView = ({
</div> </div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3"> <div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.EditRootCredential} I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })} a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip renderTooltip
allowedLabel="Edit" allowedLabel="Edit"
> >
@@ -197,8 +187,8 @@ export const DynamicSecretListView = ({
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.DeleteRootCredential} I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.DynamicSecrets, { environment, secretPath })} a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip renderTooltip
allowedLabel="Delete" allowedLabel="Delete"
> >
@@ -6,7 +6,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, IconButton, Modal, ModalContent } from "@app/components/v2"; import { DeleteActionModal, IconButton, Modal, ModalContent } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api"; import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
import { TSecretFolder } from "@app/hooks/api/secretFolders/types"; import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
@@ -33,6 +33,11 @@ export const FolderListView = ({
"deleteFolder" "deleteFolder"
] as const); ] as const);
const router = useRouter(); const router = useRouter();
const { permission } = useProjectPermission();
const shouldCheckFolderPermission = permission.rules.some((rule) =>
(rule.subject as ProjectPermissionSub[]).includes(ProjectPermissionSub.SecretFolders)
);
const { mutateAsync: updateFolder } = useUpdateFolder(); const { mutateAsync: updateFolder } = useUpdateFolder();
const { mutateAsync: deleteFolder } = useDeleteFolder(); const { mutateAsync: deleteFolder } = useDeleteFolder();
@@ -121,7 +126,12 @@ export const FolderListView = ({
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3"> <div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })} a={subject(
shouldCheckFolderPermission
? ProjectPermissionSub.SecretFolders
: ProjectPermissionSub.Secrets,
{ environment, secretPath }
)}
renderTooltip renderTooltip
allowedLabel="Edit" allowedLabel="Edit"
> >
@@ -140,7 +150,12 @@ export const FolderListView = ({
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Delete} I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })} a={subject(
shouldCheckFolderPermission
? ProjectPermissionSub.SecretFolders
: ProjectPermissionSub.Secrets,
{ environment, secretPath }
)}
renderTooltip renderTooltip
allowedLabel="Delete" allowedLabel="Delete"
> >
@@ -142,12 +142,7 @@ export const CopySecretsFromBoard = ({
<div> <div>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Create} I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})}
> >
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
@@ -250,12 +250,7 @@ export const SecretDropzone = ({
</div> </div>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Create} I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})}
> >
{(isAllowed) => ( {(isAllowed) => (
<input <input
@@ -292,12 +287,7 @@ export const SecretDropzone = ({
{!isSmaller && ( {!isSmaller && (
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Create} I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})}
> >
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
@@ -67,7 +67,7 @@ export const SecretImportItem = ({
isReplicationExpand, isReplicationExpand,
importedSecrets = [], importedSecrets = [],
searchTerm = "", searchTerm = "",
secretPath = "/", secretPath,
environment, environment,
secretImport, secretImport,
onExpandReplicateSecrets: onExpandReplicate onExpandReplicateSecrets: onExpandReplicate
@@ -209,7 +209,7 @@ export const SecretImportItem = ({
{isReplication && ( {isReplication && (
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.SecretImports, { environment, secretPath })} a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip renderTooltip
allowedLabel="Resync replicated secrets" allowedLabel="Resync replicated secrets"
> >
@@ -235,10 +235,7 @@ export const SecretImportItem = ({
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-4 py-2"> <div className="flex items-center space-x-4 border-l border-mineshaft-600 px-4 py-2">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.SecretImports, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath: secretPath || "/"
})}
renderTooltip renderTooltip
allowedLabel="Change order" allowedLabel="Change order"
> >
@@ -259,7 +256,7 @@ export const SecretImportItem = ({
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Delete} I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.SecretImports, { environment, secretPath })} a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip renderTooltip
allowedLabel="Delete" allowedLabel="Delete"
> >
@@ -84,41 +84,26 @@ export const SecretDetailSidebar = ({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
values: secret values: secret
}); });
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
const cannotEditSecret = permission.cannot(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
);
const isReadOnly =
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
) && cannotEditSecret;
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
control, control,
name: "tags" name: "tags"
}); });
const secretKey = secret?.key || "";
const selectedTags = watch("tags", []) || []; const selectedTags = watch("tags", []) || [];
const selectedTagsGroupById = selectedTags.reduce<Record<string, boolean>>( const selectedTagsGroupById = selectedTags.reduce<Record<string, boolean>>(
(prev, curr) => ({ ...prev, [curr.id]: true }), (prev, curr) => ({ ...prev, [curr.id]: true }),
{} {}
); );
const selectTagSlugs = selectedTags.map((i) => i.slug);
const cannotEditSecret = permission.cannot(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: secretKey,
secretTags: selectTagSlugs
})
);
const isReadOnly =
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: secretKey,
secretTags: selectTagSlugs
})
) && cannotEditSecret;
const overrideAction = watch("overrideAction"); const overrideAction = watch("overrideAction");
const isOverridden = const isOverridden =
@@ -209,12 +194,7 @@ export const SecretDetailSidebar = ({
</FormControl> </FormControl>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath,
secretName: secretKey,
secretTags: selectTagSlugs
})}
> >
{(isAllowed) => ( {(isAllowed) => (
<Controller <Controller
@@ -241,12 +221,7 @@ export const SecretDetailSidebar = ({
<div className="mb-2 border-b border-mineshaft-600 pb-4"> <div className="mb-2 border-b border-mineshaft-600 pb-4">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath,
secretName: secretKey,
secretTags: selectTagSlugs
})}
> >
{(isAllowed) => ( {(isAllowed) => (
<Switch <Switch
@@ -302,12 +277,7 @@ export const SecretDetailSidebar = ({
<DropdownMenu> <DropdownMenu>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath,
secretName: secretKey,
secretTags: selectTagSlugs
})}
> >
{(isAllowed) => ( {(isAllowed) => (
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -397,7 +367,6 @@ export const SecretDetailSidebar = ({
variant="outline_bg" variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faClock} />} leftIcon={<FontAwesomeIcon icon={faClock} />}
onClick={() => setCreateReminderFormOpen.on()} onClick={() => setCreateReminderFormOpen.on()}
isDisabled={cannotEditSecret}
> >
Create Reminder Create Reminder
</Button> </Button>
@@ -419,12 +388,7 @@ export const SecretDetailSidebar = ({
render={({ field: { value, onChange, onBlur } }) => ( render={({ field: { value, onChange, onBlur } }) => (
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath,
secretName: secretKey,
secretTags: selectTagSlugs
})}
> >
{(isAllowed) => ( {(isAllowed) => (
<Switch <Switch
@@ -486,12 +450,7 @@ export const SecretDetailSidebar = ({
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath,
secretName: secretKey,
secretTags: selectTagSlugs
})}
> >
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
@@ -506,12 +465,7 @@ export const SecretDetailSidebar = ({
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Delete} I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath,
secretName: secretKey,
secretTags: selectTagSlugs
})}
> >
{(isAllowed) => ( {(isAllowed) => (
<Button colorSchema="danger" isDisabled={!isAllowed} onClick={onDeleteSecret}> <Button colorSchema="danger" isDisabled={!isAllowed} onClick={onDeleteSecret}>
@@ -81,6 +81,15 @@ export const SecretItem = memo(
}: Props) => { }: Props) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
const isReadOnly =
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
) &&
permission.cannot(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
);
const { const {
handleSubmit, handleSubmit,
@@ -98,8 +107,6 @@ export const SecretItem = memo(
resolver: zodResolver(formSchema) resolver: zodResolver(formSchema)
}); });
const secretName = watch("key");
const secretReminderRepeatDays = watch("reminderRepeatDays"); const secretReminderRepeatDays = watch("reminderRepeatDays");
const secretReminderNote = watch("reminderNote"); const secretReminderNote = watch("reminderNote");
@@ -111,33 +118,11 @@ export const SecretItem = memo(
(prev, curr) => ({ ...prev, [curr.id]: true }), (prev, curr) => ({ ...prev, [curr.id]: true }),
{} {}
); );
const selectedTagSlugs = selectedTags.map((i) => i.slug);
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
control, control,
name: "tags" name: "tags"
}); });
const isReadOnly =
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName,
secretTags: selectedTagSlugs
})
) &&
permission.cannot(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName,
secretTags: selectedTagSlugs
})
);
const [isSecValueCopied, setIsSecValueCopied] = useToggle(false); const [isSecValueCopied, setIsSecValueCopied] = useToggle(false);
const [createReminderFormOpen, setCreateReminderFormOpen] = useToggle(false); const [createReminderFormOpen, setCreateReminderFormOpen] = useToggle(false);
useEffect(() => { useEffect(() => {
@@ -324,12 +309,7 @@ export const SecretItem = memo(
<DropdownMenu> <DropdownMenu>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath,
secretName,
secretTags: selectedTagSlugs
})}
> >
{(isAllowed) => ( {(isAllowed) => (
<DropdownMenuTrigger asChild disabled={!isAllowed}> <DropdownMenuTrigger asChild disabled={!isAllowed}>
@@ -404,12 +384,7 @@ export const SecretItem = memo(
</DropdownMenu> </DropdownMenu>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath,
secretName,
secretTags: selectedTagSlugs
})}
renderTooltip renderTooltip
allowedLabel="Override" allowedLabel="Override"
> >
@@ -465,12 +440,7 @@ export const SecretItem = memo(
<Popover> <Popover>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath,
secretName,
secretTags: selectedTagSlugs
})}
> >
{(isAllowed) => ( {(isAllowed) => (
<PopoverTrigger asChild disabled={!isAllowed}> <PopoverTrigger asChild disabled={!isAllowed}>
@@ -549,12 +519,7 @@ export const SecretItem = memo(
</Tooltip> </Tooltip>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Delete} I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath,
secretName,
secretTags: selectedTagSlugs
})}
renderTooltip renderTooltip
allowedLabel="Delete" allowedLabel="Delete"
> >
@@ -16,6 +16,7 @@ import { WsTag } from "@app/hooks/api/types";
import { AddShareSecretModal } from "@app/views/ShareSecretPage/components/AddShareSecretModal"; import { AddShareSecretModal } from "@app/views/ShareSecretPage/components/AddShareSecretModal";
import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store"; import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store";
import { Filter } from "../../SecretMainPage.types";
import { SecretDetailSidebar } from "./SecretDetaiSidebar"; import { SecretDetailSidebar } from "./SecretDetaiSidebar";
import { SecretItem } from "./SecretItem"; import { SecretItem } from "./SecretItem";
import { FontAwesomeSpriteSymbols } from "./SecretListView.utils"; import { FontAwesomeSpriteSymbols } from "./SecretListView.utils";
@@ -30,6 +31,16 @@ type Props = {
isProtectedBranch?: boolean; isProtectedBranch?: boolean;
}; };
export const filterSecrets = (secrets: SecretV3RawSanitized[], filter: Filter) =>
secrets.filter(({ key, value, tags }) => {
const isTagFilterActive = Boolean(Object.keys(filter.tags).length);
const searchTerm = filter.searchFilter.toLowerCase();
return (
(!isTagFilterActive || tags?.some(({ id }) => filter.tags?.[id])) &&
(key.toLowerCase().includes(searchTerm) || value?.toLowerCase().includes(searchTerm))
);
});
export const SecretListView = ({ export const SecretListView = ({
secrets = [], secrets = [],
environment, environment,
@@ -10,7 +10,6 @@ import {
faCopy, faCopy,
faEllipsis, faEllipsis,
faKey, faKey,
faLock,
faShare, faShare,
faTags faTags
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
@@ -72,8 +71,7 @@ export enum FontAwesomeSpriteName {
Close = "close", Close = "close",
CheckedCircle = "check-circle", CheckedCircle = "check-circle",
ReplicatedSecretKey = "secret-replicated", ReplicatedSecretKey = "secret-replicated",
ShareSecret = "share-secret", ShareSecret = "share-secret"
KeyLock = "key-lock"
} }
// this is an optimization technique // this is an optimization technique
@@ -90,6 +88,5 @@ export const FontAwesomeSpriteSymbols = [
{ icon: faClose, symbol: FontAwesomeSpriteName.Close }, { icon: faClose, symbol: FontAwesomeSpriteName.Close },
{ icon: faCheckCircle, symbol: FontAwesomeSpriteName.CheckedCircle }, { icon: faCheckCircle, symbol: FontAwesomeSpriteName.CheckedCircle },
{ icon: faClone, symbol: FontAwesomeSpriteName.ReplicatedSecretKey }, { icon: faClone, symbol: FontAwesomeSpriteName.ReplicatedSecretKey },
{ icon: faShare, symbol: FontAwesomeSpriteName.ShareSecret }, { icon: faShare, symbol: FontAwesomeSpriteName.ShareSecret }
{ icon: faLock, symbol: FontAwesomeSpriteName.KeyLock }
]; ];
@@ -1,49 +0,0 @@
import { FontAwesomeSymbol, Input, Tooltip } from "@app/components/v2";
import { FontAwesomeSpriteName } from "./SecretListView.utils";
type Props = {
count: number;
};
export const SecretNoAccessListView = ({ count }: Props) => {
return (
<>
{Array.from(Array(count)).map((_, i) => (
<Tooltip
className="max-w-sm"
asChild
content="You do not have permission to view this secret"
key={`no-access-secret-${i + 1}`}
>
<div className="flex border-b border-mineshaft-600 bg-mineshaft-800 shadow-none hover:bg-mineshaft-700">
<div className="flex h-11 w-11 items-center justify-center px-4 py-3">
<FontAwesomeSymbol
className="ml-3 block h-3.5 w-3.5"
symbolName={FontAwesomeSpriteName.KeyLock}
/>
</div>
<div className="flex h-11 w-80 flex-shrink-0 items-center px-4 py-2">
<Input
autoComplete="off"
isReadOnly
variant="plain"
value="NO ACCESS"
isDisabled
className="w-full px-0 blur-sm placeholder:text-red-500 focus:text-bunker-100 focus:ring-transparent"
/>
</div>
<div
className="flex w-80 flex-grow items-center border-x border-mineshaft-600 py-1 pl-4 pr-2"
tabIndex={0}
role="button"
>
<span className="blur">********</span>
</div>
</div>
</Tooltip>
))}
</>
);
};
@@ -1,2 +1 @@
export { SecretListView } from "./SecretListView"; export { SecretListView } from "./SecretListView";
export { SecretNoAccessListView } from "./SecretNoAccessListView";
@@ -71,10 +71,7 @@ import { SecretType, TSecretFolder } from "@app/hooks/api/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils"; import { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils";
import { SecretOverviewDynamicSecretRow } from "@app/views/SecretOverviewPage/components/SecretOverviewDynamicSecretRow"; import { SecretOverviewDynamicSecretRow } from "@app/views/SecretOverviewPage/components/SecretOverviewDynamicSecretRow";
import { import { SecretOverviewTableRow } from "@app/views/SecretOverviewPage/components/SecretOverviewTableRow";
SecretNoAccessOverviewTableRow,
SecretOverviewTableRow
} from "@app/views/SecretOverviewPage/components/SecretOverviewTableRow";
import { SecretTableResourceCount } from "@app/views/SecretOverviewPage/components/SecretTableResourceCount"; import { SecretTableResourceCount } from "@app/views/SecretOverviewPage/components/SecretTableResourceCount";
import { FolderForm } from "../SecretMainPage/components/ActionBar/FolderForm"; import { FolderForm } from "../SecretMainPage/components/ActionBar/FolderForm";
@@ -242,10 +239,7 @@ export const SecretOverviewPage = () => {
totalFolderCount, totalFolderCount,
totalSecretCount, totalSecretCount,
totalDynamicSecretCount, totalDynamicSecretCount,
totalCount = 0, totalCount = 0
totalUniqueFoldersInPage,
totalUniqueSecretsInPage,
totalUniqueDynamicSecretsInPage
} = overview ?? {}; } = overview ?? {};
useEffect(() => { useEffect(() => {
@@ -309,7 +303,7 @@ export const SecretOverviewPage = () => {
if ( if (
permission.can( permission.can(
ProjectPermissionActions.Edit, ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.SecretFolders, { environment: env.slug, secretPath }) subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath })
) )
) { ) {
const folder = getFolderByNameAndEnv(oldFolderName, env.slug); const folder = getFolderByNameAndEnv(oldFolderName, env.slug);
@@ -512,13 +506,20 @@ export const SecretOverviewPage = () => {
const pathSegment = secretPath.split("/").filter(Boolean); const pathSegment = secretPath.split("/").filter(Boolean);
const parentPath = `/${pathSegment.slice(0, -1).join("/")}`; const parentPath = `/${pathSegment.slice(0, -1).join("/")}`;
const folderName = pathSegment.at(-1); const folderName = pathSegment.at(-1);
const canCreateFolder = permission.can( const canCreateFolder = permission.rules.some((rule) =>
ProjectPermissionActions.Create, (rule.subject as ProjectPermissionSub[]).includes(ProjectPermissionSub.SecretFolders)
subject(ProjectPermissionSub.SecretFolders, { )
environment: slug, ? permission.can(
secretPath: parentPath ProjectPermissionActions.Create,
}) subject(ProjectPermissionSub.SecretFolders, {
); environment: slug,
secretPath: parentPath
})
)
: permission.can(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, { environment: slug, secretPath: parentPath })
);
if (folderName && parentPath && canCreateFolder) { if (folderName && parentPath && canCreateFolder) {
await createFolder({ await createFolder({
projectId: workspaceId, projectId: workspaceId,
@@ -770,7 +771,7 @@ export const SecretOverviewPage = () => {
<div className="flex flex-col space-y-1 p-1.5"> <div className="flex flex-col space-y-1 p-1.5">
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Create} I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.SecretFolders} a={subject(ProjectPermissionSub.Secrets, { secretPath })}
> >
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
@@ -974,16 +975,6 @@ export const SecretOverviewPage = () => {
expandableColWidth={expandableTableWidth} expandableColWidth={expandableTableWidth}
/> />
))} ))}
<SecretNoAccessOverviewTableRow
environments={visibleEnvs}
count={Math.max(
(page * perPage > totalCount ? totalCount % perPage : perPage) -
(totalUniqueFoldersInPage || 0) -
(totalUniqueDynamicSecretsInPage || 0) -
(totalUniqueSecretsInPage || 0),
0
)}
/>
</> </>
)} )}
</TBody> </TBody>
@@ -93,14 +93,23 @@ export const CreateSecretForm = ({
const pathSegment = secretPath.split("/").filter(Boolean); const pathSegment = secretPath.split("/").filter(Boolean);
const parentPath = `/${pathSegment.slice(0, -1).join("/")}`; const parentPath = `/${pathSegment.slice(0, -1).join("/")}`;
const folderName = pathSegment.at(-1); const folderName = pathSegment.at(-1);
const canCreateFolder = permission.can( const canCreateFolder = permission.rules.some((rule) =>
ProjectPermissionActions.Create, (rule.subject as ProjectPermissionSub[]).includes(ProjectPermissionSub.SecretFolders)
subject(ProjectPermissionSub.SecretFolders, { )
environment: env.slug, ? permission.can(
secretPath: parentPath ProjectPermissionActions.Create,
}) subject(ProjectPermissionSub.SecretFolders, {
); environment: env.slug,
secretPath: parentPath
})
)
: permission.can(
ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, {
environment: env.slug,
secretPath: parentPath
})
);
if (folderName && parentPath && canCreateFolder) { if (folderName && parentPath && canCreateFolder) {
await createFolder({ await createFolder({
projectId: workspaceId, projectId: workspaceId,
@@ -241,9 +250,7 @@ export const CreateSecretForm = ({
ProjectPermissionActions.Create, ProjectPermissionActions.Create,
subject(ProjectPermissionSub.Secrets, { subject(ProjectPermissionSub.Secrets, {
environment: environmentSlug.slug, environment: environmentSlug.slug,
secretPath, secretPath
secretName: "*",
secretTags: ["*"]
}) })
) )
) )
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react"; import { useCallback,useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import { faCheck, faCopy, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons"; import { faCheck, faCopy, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
@@ -7,7 +7,7 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, IconButton, Tooltip } from "@app/components/v2"; import { DeleteActionModal,IconButton, Tooltip } from "@app/components/v2";
import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
@@ -63,8 +63,8 @@ export const SecretEditRow = ({
const [isModalOpen, setIsModalOpen] = useState<boolean>(false); const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const toggleModal = useCallback(() => { const toggleModal = useCallback(() => {
setIsModalOpen((prev) => !prev); setIsModalOpen((prev) => !prev)
}, []); }, [])
const handleFormReset = () => { const handleFormReset = () => {
reset(); reset();
@@ -114,6 +114,7 @@ export const SecretEditRow = ({
return ( return (
<div className="group flex w-full cursor-text items-center space-x-2"> <div className="group flex w-full cursor-text items-center space-x-2">
<DeleteActionModal <DeleteActionModal
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={toggleModal} onClose={toggleModal}
@@ -150,13 +151,8 @@ export const SecretEditRow = ({
{isDirty ? ( {isDirty ? (
<> <>
<ProjectPermissionCan <ProjectPermissionCan
I={isCreatable ? ProjectPermissionActions.Create : ProjectPermissionActions.Edit} I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, { a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
environment,
secretPath,
secretName,
secretTags: ["*"]
})}
> >
{(isAllowed) => ( {(isAllowed) => (
<div> <div>
@@ -205,12 +201,7 @@ export const SecretEditRow = ({
</div> </div>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Delete} I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { a={ProjectPermissionSub.Secrets}
environment,
secretPath,
secretName,
secretTags: ["*"]
})}
> >
{(isAllowed) => ( {(isAllowed) => (
<div className="opacity-0 group-hover:opacity-100"> <div className="opacity-0 group-hover:opacity-100">
@@ -1,51 +0,0 @@
import { faCircle } from "@fortawesome/free-regular-svg-icons";
import { faLock } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Td, Tooltip, Tr } from "@app/components/v2";
type Props = {
environments: { name: string; slug: string }[];
count: number;
};
export const SecretNoAccessOverviewTableRow = ({ environments = [], count }: Props) => {
return (
<>
{Array.from(Array(count)).map((_, j) => (
<Tr key={`no-access-secret-overview-${j + 1}`} isHoverable isSelectable className="group">
<Td className="sticky left-0 z-10 bg-mineshaft-800 bg-clip-padding py-0 px-0 group-hover:bg-mineshaft-700">
<div className="h-full w-full border-r border-mineshaft-600 py-2.5 px-5">
<Tooltip
asChild
content="You do not have permission to view this secret"
className="max-w-sm"
>
<div className="flex items-center space-x-5">
<div className="text-bunker-300">
<FontAwesomeIcon className="block" icon={faLock} />
</div>
<div className="blur-sm">NO ACCESS</div>
</div>
</Tooltip>
</div>
</Td>
{environments.map(({ slug }, i) => {
return (
<Td
key={`sec-overview-${slug}-${i + 1}-value`}
className="py-0 px-0 group-hover:bg-mineshaft-700"
>
<div className="h-full w-full border-r border-mineshaft-600 py-[0.85rem] px-5">
<div className="flex justify-center">
<FontAwesomeIcon icon={faCircle} />
</div>
</div>
</Td>
);
})}
</Tr>
))}
</>
);
};
@@ -18,7 +18,7 @@ import {
} from "@app/context"; } from "@app/context";
import { useToggle } from "@app/hooks"; import { useToggle } from "@app/hooks";
import { useUpdateSecretV3 } from "@app/hooks/api"; import { useUpdateSecretV3 } from "@app/hooks/api";
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/types"; import { SecretType,SecretV3RawSanitized } from "@app/hooks/api/types";
import { SecretActionType } from "@app/views/SecretMainPage/components/SecretListView/SecretListView.utils"; import { SecretActionType } from "@app/views/SecretMainPage/components/SecretListView/SecretListView.utils";
type Props = { type Props = {
@@ -42,16 +42,15 @@ function SecretRenameRow({ environments, getSecretByKey, secretKey, secretPath }
const isReadOnly = environments.some((env) => { const isReadOnly = environments.some((env) => {
const environment = env.slug; const environment = env.slug;
const secretDetails = getSecretByKey(environment, secretKey);
const secretPermissionSubject = subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: secretKey,
secretTags: (secretDetails?.tags || []).map((i) => i.slug)
});
const isSecretInEnvReadOnly = const isSecretInEnvReadOnly =
permission.can(ProjectPermissionActions.Read, secretPermissionSubject) && permission.can(
permission.cannot(ProjectPermissionActions.Edit, secretPermissionSubject); ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
) &&
permission.cannot(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
);
if (isSecretInEnvReadOnly) { if (isSecretInEnvReadOnly) {
return true; return true;
} }
@@ -1,2 +1 @@
export { SecretNoAccessOverviewTableRow } from "./SecretNoAccessOverviewTableRow";
export { SecretOverviewTableRow } from "./SecretOverviewTableRow"; export { SecretOverviewTableRow } from "./SecretOverviewTableRow";
@@ -49,9 +49,9 @@ export const SelectionPanel = ({
"bulkDeleteEntries" "bulkDeleteEntries"
] as const); ] as const);
const selectedFolderCount = Object.keys(selectedEntries.folder).length; const selectedFolderCount = Object.keys(selectedEntries.folder).length
const selectedKeysCount = Object.keys(selectedEntries.secret).length; const selectedKeysCount = Object.keys(selectedEntries.secret).length
const selectedCount = selectedFolderCount + selectedKeysCount; const selectedCount = selectedFolderCount + selectedKeysCount
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || ""; const workspaceId = currentWorkspace?.id || "";
@@ -65,12 +65,7 @@ export const SelectionPanel = ({
const shouldShowDelete = userAvailableEnvs.some((env) => const shouldShowDelete = userAvailableEnvs.some((env) =>
permission.can( permission.can(
ProjectPermissionActions.Delete, ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.Secrets, { subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath })
environment: env.slug,
secretPath,
secretName: "*",
secretTags: ["*"]
})
) )
); );
@@ -82,50 +77,41 @@ export const SelectionPanel = ({
return "Do you want to delete the selected secrets across environments?"; return "Do you want to delete the selected secrets across environments?";
} }
return "Do you want to delete the selected folders across environments?"; return "Do you want to delete the selected folders across environments?";
}; }
const handleBulkDelete = async () => { const handleBulkDelete = async () => {
let processedEntries = 0; let processedEntries = 0;
const promises = userAvailableEnvs.map(async (env) => { const promises = userAvailableEnvs.map(async (env) => {
// additional check: ensure that bulk delete is only executed on envs that user has access to // additional check: ensure that bulk delete is only executed on envs that user has access to
if ( if (
permission.can( permission.cannot(
ProjectPermissionActions.Delete, ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.SecretFolders, { environment: env.slug, secretPath }) subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath })
) )
) { ) {
await Promise.all( return;
Object.keys(selectedEntries.folder).map(async (folderName) => {
const folder = getFolderByNameAndEnv(folderName, env.slug);
if (folder) {
processedEntries += 1;
await deleteFolder({
folderId: folder?.id,
path: secretPath,
environment: env.slug,
projectId: workspaceId
});
}
})
);
} }
await Promise.all(
Object.keys(selectedEntries.folder).map(async (folderName) => {
const folder = getFolderByNameAndEnv(folderName, env.slug);
if (folder) {
processedEntries += 1;
await deleteFolder({
folderId: folder?.id,
path: secretPath,
environment: env.slug,
projectId: workspaceId
});
}
})
);
const secretsToDelete = Object.keys(selectedEntries.secret).reduce( const secretsToDelete = Object.keys(selectedEntries.secret).reduce(
(accum: TDeleteSecretBatchDTO["secrets"], secretName) => { (accum: TDeleteSecretBatchDTO["secrets"], secretName) => {
const entry = getSecretByKey(env.slug, secretName); const entry = getSecretByKey(env.slug, secretName);
const canDeleteSecret = permission.can( if (entry) {
ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.Secrets, {
environment: env.slug,
secretPath,
secretName,
secretTags: (entry?.tags || []).map((i) => i.slug)
})
);
if (entry && canDeleteSecret) {
return [ return [
...accum, ...accum,
{ {
@@ -136,7 +136,10 @@ export const DeleteProjectSection = () => {
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-4 text-xl font-semibold text-mineshaft-100">Danger Zone</p> <p className="mb-4 text-xl font-semibold text-mineshaft-100">Danger Zone</p>
<div className="space-x-4"> <div className="space-x-4">
<ProjectPermissionCan I={ProjectPermissionActions.Delete} a={ProjectPermissionSub.Project}> <ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Workspace}
>
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
isLoading={isDeleting} isLoading={isDeleting}
@@ -318,7 +318,10 @@ export const EncryptionTab = () => {
/> />
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Project}> <ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Workspace}
>
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
colorSchema="secondary" colorSchema="secondary"
@@ -22,6 +22,7 @@ const formSchema = yup.object({
type FormData = yup.InferType<typeof formSchema>; type FormData = yup.InferType<typeof formSchema>;
export const ProjectNameChangeSection = () => { export const ProjectNameChangeSection = () => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { mutateAsync, isLoading } = useRenameWorkspace(); const { mutateAsync, isLoading } = useRenameWorkspace();
@@ -82,7 +83,7 @@ export const ProjectNameChangeSection = () => {
</div> </div>
</div> </div>
<div className="max-w-md"> <div className="max-w-md">
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Project}> <ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Workspace}>
{(isAllowed) => ( {(isAllowed) => (
<Controller <Controller
defaultValue="" defaultValue=""
@@ -102,7 +103,7 @@ export const ProjectNameChangeSection = () => {
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Project}> <ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Workspace}>
{(isAllowed) => ( {(isAllowed) => (
<Button <Button
colorSchema="secondary" colorSchema="secondary"