Merge branch 'main' into feat/addMoreVisibilityToServerAdmins

This commit is contained in:
carlosmonastyrski
2025-03-10 11:57:15 -03:00
83 changed files with 2124 additions and 1219 deletions
@@ -92,7 +92,7 @@ jobs:
exit 1 exit 1
fi fi
- name: Install openapi-diff - name: Install openapi-diff
run: go install github.com/tufin/oasdiff@latest run: go install github.com/oasdiff/oasdiff@latest
- name: Running OpenAPI Spec diff action - name: Running OpenAPI Spec diff action
run: oasdiff breaking https://app.infisical.com/api/docs/json http://localhost:4000/api/docs/json --fail-on ERR run: oasdiff breaking https://app.infisical.com/api/docs/json http://localhost:4000/api/docs/json --fail-on ERR
- name: cleanup - name: cleanup
+6 -2
View File
@@ -34,7 +34,10 @@ jobs:
working-directory: backend working-directory: backend
- name: Start postgres and redis - name: Start postgres and redis
run: touch .env && docker compose -f docker-compose.dev.yml up -d db redis run: touch .env && docker compose -f docker-compose.dev.yml up -d db redis
- name: Start integration test - name: Run unit test
run: npm run test:unit
working-directory: backend
- name: Run integration test
run: npm run test:e2e run: npm run test:e2e
working-directory: backend working-directory: backend
env: env:
@@ -44,4 +47,5 @@ jobs:
ENCRYPTION_KEY: 4bnfe4e407b8921c104518903515b218 ENCRYPTION_KEY: 4bnfe4e407b8921c104518903515b218
- name: cleanup - name: cleanup
run: | run: |
docker compose -f "docker-compose.dev.yml" down docker compose -f "docker-compose.dev.yml" down
+1
View File
@@ -40,6 +40,7 @@
"type:check": "tsc --noEmit", "type:check": "tsc --noEmit",
"lint:fix": "eslint --fix --ext js,ts ./src", "lint:fix": "eslint --fix --ext js,ts ./src",
"lint": "eslint 'src/**/*.ts'", "lint": "eslint 'src/**/*.ts'",
"test:unit": "vitest run -c vitest.unit.config.ts",
"test:e2e": "vitest run -c vitest.e2e.config.ts --bail=1", "test:e2e": "vitest run -c vitest.e2e.config.ts --bail=1",
"test:e2e-watch": "vitest -c vitest.e2e.config.ts --bail=1", "test:e2e-watch": "vitest -c vitest.e2e.config.ts --bail=1",
"test:e2e-coverage": "vitest run --coverage -c vitest.e2e.config.ts", "test:e2e-coverage": "vitest run --coverage -c vitest.e2e.config.ts",
+30 -13
View File
@@ -3,7 +3,7 @@ import slugify from "@sindresorhus/slugify";
import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas"; import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas";
import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
@@ -87,9 +87,14 @@ export const groupServiceFactory = ({
actorOrgId actorOrgId
); );
const isCustomRole = Boolean(customRole); const isCustomRole = Boolean(customRole);
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission);
if (!hasRequiredPriviledges) const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
throw new ForbiddenRequestError({ message: "Failed to create a more privileged group" }); if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({
name: "PermissionBoundaryError",
message: "Failed to create a more privileged group",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
const group = await groupDAL.transaction(async (tx) => { const group = await groupDAL.transaction(async (tx) => {
const existingGroup = await groupDAL.findOne({ orgId: actorOrgId, name }, tx); const existingGroup = await groupDAL.findOne({ orgId: actorOrgId, name }, tx);
@@ -156,9 +161,13 @@ export const groupServiceFactory = ({
); );
const isCustomRole = Boolean(customOrgRole); const isCustomRole = Boolean(customOrgRole);
const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission); const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!hasRequiredNewRolePermission) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ message: "Failed to create a more privileged group" }); throw new ForbiddenRequestError({
name: "PermissionBoundaryError",
message: "Failed to update a more privileged group",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
if (isCustomRole) customRole = customOrgRole; if (isCustomRole) customRole = customOrgRole;
} }
@@ -329,9 +338,13 @@ export const groupServiceFactory = ({
const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId);
// check if user has broader or equal to privileges than group // check if user has broader or equal to privileges than group
const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission); const permissionBoundary = validatePermissionBoundary(permission, groupRolePermission);
if (!hasRequiredPrivileges) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ message: "Failed to add user to more privileged group" }); throw new ForbiddenRequestError({
name: "PermissionBoundaryError",
message: "Failed to add user to more privileged group",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
const user = await userDAL.findOne({ username }); const user = await userDAL.findOne({ username });
if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` }); if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` });
@@ -396,9 +409,13 @@ export const groupServiceFactory = ({
const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId); const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId);
// check if user has broader or equal to privileges than group // check if user has broader or equal to privileges than group
const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, groupRolePermission); const permissionBoundary = validatePermissionBoundary(permission, groupRolePermission);
if (!hasRequiredPrivileges) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ message: "Failed to delete user from more privileged group" }); throw new ForbiddenRequestError({
name: "PermissionBoundaryError",
message: "Failed to delete user from more privileged group",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
const user = await userDAL.findOne({ username }); const user = await userDAL.findOne({ username });
if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` }); if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` });
@@ -3,7 +3,7 @@ import { packRules } from "@casl/ability/extra";
import ms from "ms"; import ms from "ms";
import { ActionProjectType, TableName } from "@app/db/schemas"; import { ActionProjectType, TableName } from "@app/db/schemas";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { unpackPermissions } from "@app/server/routes/sanitizedSchema/permission"; import { unpackPermissions } from "@app/server/routes/sanitizedSchema/permission";
import { ActorType } from "@app/services/auth/auth-type"; import { ActorType } from "@app/services/auth/auth-type";
@@ -79,9 +79,13 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
// we need to validate that the privilege given is not higher than the assigning users permission // we need to validate that the privilege given is not higher than the assigning users permission
// @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules
targetIdentityPermission.update(targetIdentityPermission.rules.concat(customPermission)); targetIdentityPermission.update(targetIdentityPermission.rules.concat(customPermission));
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetIdentityPermission); const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission);
if (!hasRequiredPriviledges) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); throw new ForbiddenRequestError({
name: "PermissionBoundaryError",
message: "Failed to update more privileged identity",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({
slug, slug,
@@ -161,9 +165,13 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
// we need to validate that the privilege given is not higher than the assigning users permission // we need to validate that the privilege given is not higher than the assigning users permission
// @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules
targetIdentityPermission.update(targetIdentityPermission.rules.concat(data.permissions || [])); targetIdentityPermission.update(targetIdentityPermission.rules.concat(data.permissions || []));
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetIdentityPermission); const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission);
if (!hasRequiredPriviledges) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); throw new ForbiddenRequestError({
name: "PermissionBoundaryError",
message: "Failed to update more privileged identity",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
if (data?.slug) { if (data?.slug) {
const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({
@@ -239,9 +247,13 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.Any actionProjectType: ActionProjectType.Any
}); });
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission);
if (!hasRequiredPriviledges) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); throw new ForbiddenRequestError({
name: "PermissionBoundaryError",
message: "Failed to update more privileged identity",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id); const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id);
return { return {
@@ -3,7 +3,7 @@ import { PackRule, packRules, unpackRules } from "@casl/ability/extra";
import ms from "ms"; import ms from "ms";
import { ActionProjectType } from "@app/db/schemas"; import { ActionProjectType } from "@app/db/schemas";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission"; import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission";
import { ActorType } from "@app/services/auth/auth-type"; import { ActorType } from "@app/services/auth/auth-type";
@@ -88,9 +88,13 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
// we need to validate that the privilege given is not higher than the assigning users permission // we need to validate that the privilege given is not higher than the assigning users permission
// @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules
targetIdentityPermission.update(targetIdentityPermission.rules.concat(customPermission)); targetIdentityPermission.update(targetIdentityPermission.rules.concat(customPermission));
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetIdentityPermission); const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission);
if (!hasRequiredPriviledges) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); throw new ForbiddenRequestError({
name: "PermissionBoundaryError",
message: "Failed to update more privileged identity",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({ const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({
slug, slug,
@@ -172,9 +176,13 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
// we need to validate that the privilege given is not higher than the assigning users permission // we need to validate that the privilege given is not higher than the assigning users permission
// @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules
targetIdentityPermission.update(targetIdentityPermission.rules.concat(data.permissions || [])); targetIdentityPermission.update(targetIdentityPermission.rules.concat(data.permissions || []));
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetIdentityPermission); const permissionBoundary = validatePermissionBoundary(permission, targetIdentityPermission);
if (!hasRequiredPriviledges) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); throw new ForbiddenRequestError({
name: "PermissionBoundaryError",
message: "Failed to update more privileged identity",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({
slug, slug,
@@ -268,9 +276,13 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.Any actionProjectType: ActionProjectType.Any
}); });
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission);
if (!hasRequiredPriviledges) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ message: "Failed to edit more privileged identity" }); throw new ForbiddenRequestError({
name: "PermissionBoundaryError",
message: "Failed to edit more privileged identity",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({ const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({
slug, slug,
@@ -5,22 +5,6 @@ import { PermissionConditionOperators } from "@app/lib/casl";
export const PermissionConditionSchema = { export const PermissionConditionSchema = {
[PermissionConditionOperators.$IN]: z.string().trim().min(1).array(), [PermissionConditionOperators.$IN]: z.string().trim().min(1).array(),
[PermissionConditionOperators.$ALL]: z.string().trim().min(1).array(),
[PermissionConditionOperators.$REGEX]: z
.string()
.min(1)
.refine(
(el) => {
try {
// eslint-disable-next-line no-new
new RegExp(el);
return true;
} catch {
return false;
}
},
{ message: "Invalid regex pattern" }
),
[PermissionConditionOperators.$EQ]: z.string().min(1), [PermissionConditionOperators.$EQ]: z.string().min(1),
[PermissionConditionOperators.$NEQ]: z.string().min(1), [PermissionConditionOperators.$NEQ]: z.string().min(1),
[PermissionConditionOperators.$GLOB]: z [PermissionConditionOperators.$GLOB]: z
@@ -3,7 +3,7 @@ import { PackRule, packRules, unpackRules } from "@casl/ability/extra";
import ms from "ms"; import ms from "ms";
import { ActionProjectType, TableName } from "@app/db/schemas"; import { ActionProjectType, TableName } from "@app/db/schemas";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission"; import { UnpackedPermissionSchema } from "@app/server/routes/sanitizedSchema/permission";
import { ActorType } from "@app/services/auth/auth-type"; import { ActorType } from "@app/services/auth/auth-type";
@@ -76,9 +76,13 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
// we need to validate that the privilege given is not higher than the assigning users permission // we need to validate that the privilege given is not higher than the assigning users permission
// @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules
targetUserPermission.update(targetUserPermission.rules.concat(customPermission)); targetUserPermission.update(targetUserPermission.rules.concat(customPermission));
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetUserPermission); const permissionBoundary = validatePermissionBoundary(permission, targetUserPermission);
if (!hasRequiredPriviledges) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); throw new ForbiddenRequestError({
name: "PermissionBoundaryError",
message: "Failed to update more privileged user",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({
slug, slug,
@@ -163,9 +167,13 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
// we need to validate that the privilege given is not higher than the assigning users permission // we need to validate that the privilege given is not higher than the assigning users permission
// @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules // @ts-expect-error this is expected error because of one being really accurate rule definition other being a bit more broader. Both are valid casl rules
targetUserPermission.update(targetUserPermission.rules.concat(dto.permissions || [])); targetUserPermission.update(targetUserPermission.rules.concat(dto.permissions || []));
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, targetUserPermission); const permissionBoundary = validatePermissionBoundary(permission, targetUserPermission);
if (!hasRequiredPriviledges) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" }); throw new ForbiddenRequestError({
name: "PermissionBoundaryError",
message: "Failed to update more privileged identity",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
if (dto?.slug) { if (dto?.slug) {
const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({
@@ -503,7 +503,7 @@ export const secretApprovalRequestServiceFactory = ({
if (!hasMinApproval && !isSoftEnforcement) if (!hasMinApproval && !isSoftEnforcement)
throw new BadRequestError({ message: "Doesn't have minimum approvals needed" }); throw new BadRequestError({ message: "Doesn't have minimum approvals needed" });
const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); const { botKey, shouldUseSecretV2Bridge, project } = await projectBotService.getBotKey(projectId);
let mergeStatus; let mergeStatus;
if (shouldUseSecretV2Bridge) { if (shouldUseSecretV2Bridge) {
// this cycle if for bridged secrets // this cycle if for bridged secrets
@@ -861,7 +861,6 @@ export const secretApprovalRequestServiceFactory = ({
if (isSoftEnforcement) { if (isSoftEnforcement) {
const cfg = getConfig(); const cfg = getConfig();
const project = await projectDAL.findProjectById(projectId);
const env = await projectEnvDAL.findOne({ id: policy.envId }); const env = await projectEnvDAL.findOne({ id: policy.envId });
const requestedByUser = await userDAL.findOne({ id: actorId }); const requestedByUser = await userDAL.findOne({ id: actorId });
const approverUsers = await userDAL.find({ const approverUsers = await userDAL.find({
@@ -1156,7 +1155,8 @@ export const secretApprovalRequestServiceFactory = ({
environment: env.name, environment: env.name,
secretPath, secretPath,
projectId, projectId,
requestId: secretApprovalRequest.id requestId: secretApprovalRequest.id,
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretName) ?? []))]
} }
} }
}); });
@@ -1456,7 +1456,8 @@ export const secretApprovalRequestServiceFactory = ({
environment: env.name, environment: env.name,
secretPath, secretPath,
projectId, projectId,
requestId: secretApprovalRequest.id requestId: secretApprovalRequest.id,
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))]
} }
} }
}); });
+2 -1
View File
@@ -459,7 +459,8 @@ export const PROJECTS = {
workspaceId: "The ID of the project to update.", workspaceId: "The ID of the project to update.",
name: "The new name of the project.", name: "The new name of the project.",
projectDescription: "An optional description label for the project.", projectDescription: "An optional description label for the project.",
autoCapitalization: "Disable or enable auto-capitalization for the project." autoCapitalization: "Disable or enable auto-capitalization for the project.",
slug: "An optional slug for the project. (must be unique within the organization)"
}, },
GET_KEY: { GET_KEY: {
workspaceId: "The ID of the project to get the key from." workspaceId: "The ID of the project to get the key from."
+669
View File
@@ -0,0 +1,669 @@
import { createMongoAbility } from "@casl/ability";
import { PermissionConditionOperators } from ".";
import { validatePermissionBoundary } from "./boundary";
describe("Validate Permission Boundary Function", () => {
test.each([
{
title: "child with equal privilege",
parentPermission: createMongoAbility([
{
action: ["create", "edit", "delete", "read"],
subject: "secrets"
}
]),
childPermission: createMongoAbility([
{
action: ["create", "edit", "delete", "read"],
subject: "secrets"
}
]),
expectValid: true,
missingPermissions: []
},
{
title: "child with less privilege",
parentPermission: createMongoAbility([
{
action: ["create", "edit", "delete", "read"],
subject: "secrets"
}
]),
childPermission: createMongoAbility([
{
action: ["create", "edit"],
subject: "secrets"
}
]),
expectValid: true,
missingPermissions: []
},
{
title: "child with more privilege",
parentPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets"
}
]),
childPermission: createMongoAbility([
{
action: ["create", "edit"],
subject: "secrets"
}
]),
expectValid: false,
missingPermissions: [{ action: "edit", subject: "secrets" }]
},
{
title: "parent with multiple and child with multiple",
parentPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets"
},
{
action: ["create", "edit"],
subject: "members"
}
]),
childPermission: createMongoAbility([
{
action: ["create"],
subject: "members"
},
{
action: ["create"],
subject: "secrets"
}
]),
expectValid: true,
missingPermissions: []
},
{
title: "Child with no access",
parentPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets"
},
{
action: ["create", "edit"],
subject: "members"
}
]),
childPermission: createMongoAbility([]),
expectValid: true,
missingPermissions: []
},
{
title: "Parent and child disjoint set",
parentPermission: createMongoAbility([
{
action: ["create", "edit", "delete", "read"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$EQ]: "dev" }
}
}
]),
childPermission: createMongoAbility([
{
action: ["create", "edit", "delete", "read"],
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$EQ]: "dev" }
}
}
]),
expectValid: false,
missingPermissions: ["create", "edit", "delete", "read"].map((el) => ({
action: el,
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$EQ]: "dev" }
}
}))
},
{
title: "Parent with inverted rules",
parentPermission: createMongoAbility([
{
action: ["create", "edit", "delete", "read"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$EQ]: "dev" }
}
},
{
action: "read",
subject: "secrets",
inverted: true,
conditions: {
environment: { [PermissionConditionOperators.$EQ]: "dev" },
secretPath: { [PermissionConditionOperators.$GLOB]: "/hello/**" }
}
}
]),
childPermission: createMongoAbility([
{
action: "read",
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$EQ]: "dev" },
secretPath: { [PermissionConditionOperators.$EQ]: "/" }
}
}
]),
expectValid: true,
missingPermissions: []
},
{
title: "Parent with inverted rules - child accessing invalid one",
parentPermission: createMongoAbility([
{
action: ["create", "edit", "delete", "read"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$EQ]: "dev" }
}
},
{
action: "read",
subject: "secrets",
inverted: true,
conditions: {
environment: { [PermissionConditionOperators.$EQ]: "dev" },
secretPath: { [PermissionConditionOperators.$GLOB]: "/hello/**" }
}
}
]),
childPermission: createMongoAbility([
{
action: "read",
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$EQ]: "dev" },
secretPath: { [PermissionConditionOperators.$EQ]: "/hello/world" }
}
}
]),
expectValid: false,
missingPermissions: [
{
action: "read",
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$EQ]: "dev" },
secretPath: { [PermissionConditionOperators.$EQ]: "/hello/world" }
}
}
]
}
])("Check permission: $title", ({ parentPermission, childPermission, expectValid, missingPermissions }) => {
const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission);
if (expectValid) {
expect(permissionBoundary.isValid).toBeTruthy();
} else {
expect(permissionBoundary.isValid).toBeFalsy();
expect(permissionBoundary.missingPermissions).toEqual(expect.arrayContaining(missingPermissions));
}
});
});
describe("Validate Permission Boundary: Checking Parent $eq operator", () => {
const parentPermission = createMongoAbility([
{
action: ["create", "read"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$EQ]: "dev" }
}
}
]);
test.each([
{
operator: PermissionConditionOperators.$EQ,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$EQ]: "dev" }
}
}
])
},
{
operator: PermissionConditionOperators.$IN,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$IN]: ["dev"] }
}
}
])
},
{
operator: PermissionConditionOperators.$GLOB,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$GLOB]: "dev" }
}
}
])
}
])("Child $operator truthy cases", ({ childPermission }) => {
const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission);
expect(permissionBoundary.isValid).toBeTruthy();
});
test.each([
{
operator: PermissionConditionOperators.$EQ,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$EQ]: "prod" }
}
}
])
},
{
operator: PermissionConditionOperators.$IN,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$IN]: ["dev", "prod"] }
}
}
])
},
{
operator: PermissionConditionOperators.$GLOB,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$GLOB]: "dev**" }
}
}
])
},
{
operator: PermissionConditionOperators.$NEQ,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$GLOB]: "staging" }
}
}
])
}
])("Child $operator falsy cases", ({ childPermission }) => {
const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission);
expect(permissionBoundary.isValid).toBeFalsy();
});
});
describe("Validate Permission Boundary: Checking Parent $neq operator", () => {
const parentPermission = createMongoAbility([
{
action: ["create", "read"],
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$NEQ]: "/hello" }
}
}
]);
test.each([
{
operator: PermissionConditionOperators.$EQ,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$EQ]: "/" }
}
}
])
},
{
operator: PermissionConditionOperators.$NEQ,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$NEQ]: "/hello" }
}
}
])
},
{
operator: PermissionConditionOperators.$IN,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$IN]: ["/", "/staging"] }
}
}
])
},
{
operator: PermissionConditionOperators.$GLOB,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$GLOB]: "/dev**" }
}
}
])
}
])("Child $operator truthy cases", ({ childPermission }) => {
const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission);
expect(permissionBoundary.isValid).toBeTruthy();
});
test.each([
{
operator: PermissionConditionOperators.$EQ,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$EQ]: "/hello" }
}
}
])
},
{
operator: PermissionConditionOperators.$NEQ,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$NEQ]: "/" }
}
}
])
},
{
operator: PermissionConditionOperators.$IN,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$IN]: ["/", "/hello"] }
}
}
])
},
{
operator: PermissionConditionOperators.$GLOB,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$GLOB]: "/hello**" }
}
}
])
}
])("Child $operator falsy cases", ({ childPermission }) => {
const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission);
expect(permissionBoundary.isValid).toBeFalsy();
});
});
describe("Validate Permission Boundary: Checking Parent $IN operator", () => {
const parentPermission = createMongoAbility([
{
action: ["edit"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$IN]: ["dev", "staging"] }
}
}
]);
test.each([
{
operator: PermissionConditionOperators.$EQ,
childPermission: createMongoAbility([
{
action: ["edit"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$EQ]: "dev" }
}
}
])
},
{
operator: PermissionConditionOperators.$IN,
childPermission: createMongoAbility([
{
action: ["edit"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$IN]: ["dev"] }
}
}
])
},
{
operator: `${PermissionConditionOperators.$IN} - 2`,
childPermission: createMongoAbility([
{
action: ["edit"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$IN]: ["dev", "staging"] }
}
}
])
},
{
operator: PermissionConditionOperators.$GLOB,
childPermission: createMongoAbility([
{
action: ["edit"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$GLOB]: "dev" }
}
}
])
}
])("Child $operator truthy cases", ({ childPermission }) => {
const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission);
expect(permissionBoundary.isValid).toBeTruthy();
});
test.each([
{
operator: PermissionConditionOperators.$EQ,
childPermission: createMongoAbility([
{
action: ["edit"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$EQ]: "prod" }
}
}
])
},
{
operator: PermissionConditionOperators.$NEQ,
childPermission: createMongoAbility([
{
action: ["edit"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$NEQ]: "dev" }
}
}
])
},
{
operator: PermissionConditionOperators.$IN,
childPermission: createMongoAbility([
{
action: ["edit"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$IN]: ["dev", "prod"] }
}
}
])
},
{
operator: PermissionConditionOperators.$GLOB,
childPermission: createMongoAbility([
{
action: ["edit"],
subject: "secrets",
conditions: {
environment: { [PermissionConditionOperators.$GLOB]: "dev**" }
}
}
])
}
])("Child $operator falsy cases", ({ childPermission }) => {
const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission);
expect(permissionBoundary.isValid).toBeFalsy();
});
});
describe("Validate Permission Boundary: Checking Parent $GLOB operator", () => {
const parentPermission = createMongoAbility([
{
action: ["create", "read"],
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$GLOB]: "/hello/**" }
}
}
]);
test.each([
{
operator: PermissionConditionOperators.$EQ,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$EQ]: "/hello/world" }
}
}
])
},
{
operator: PermissionConditionOperators.$IN,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$IN]: ["/hello/world", "/hello/world2"] }
}
}
])
},
{
operator: PermissionConditionOperators.$GLOB,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$GLOB]: "/hello/**/world" }
}
}
])
}
])("Child $operator truthy cases", ({ childPermission }) => {
const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission);
expect(permissionBoundary.isValid).toBeTruthy();
});
test.each([
{
operator: PermissionConditionOperators.$EQ,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$EQ]: "/print" }
}
}
])
},
{
operator: PermissionConditionOperators.$NEQ,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$NEQ]: "/hello/world" }
}
}
])
},
{
operator: PermissionConditionOperators.$IN,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$IN]: ["/", "/hello"] }
}
}
])
},
{
operator: PermissionConditionOperators.$GLOB,
childPermission: createMongoAbility([
{
action: ["create"],
subject: "secrets",
conditions: {
secretPath: { [PermissionConditionOperators.$GLOB]: "/hello**" }
}
}
])
}
])("Child $operator falsy cases", ({ childPermission }) => {
const permissionBoundary = validatePermissionBoundary(parentPermission, childPermission);
expect(permissionBoundary.isValid).toBeFalsy();
});
});
+249
View File
@@ -0,0 +1,249 @@
import { MongoAbility } from "@casl/ability";
import { MongoQuery } from "@ucast/mongo2js";
import picomatch from "picomatch";
import { PermissionConditionOperators } from "./index";
type TMissingPermission = {
action: string;
subject: string;
conditions?: MongoQuery;
};
type TPermissionConditionShape = {
[PermissionConditionOperators.$EQ]: string;
[PermissionConditionOperators.$NEQ]: string;
[PermissionConditionOperators.$GLOB]: string;
[PermissionConditionOperators.$IN]: string[];
};
const getPermissionSetID = (action: string, subject: string) => `${action}:${subject}`;
const invertTheOperation = (shouldInvert: boolean, operation: boolean) => (shouldInvert ? !operation : operation);
const formatConditionOperator = (condition: TPermissionConditionShape | string) => {
return (
typeof condition === "string" ? { [PermissionConditionOperators.$EQ]: condition } : condition
) as TPermissionConditionShape;
};
const isOperatorsASubset = (parentSet: TPermissionConditionShape, subset: TPermissionConditionShape) => {
// we compute each operator against each other in left hand side and right hand side
if (subset[PermissionConditionOperators.$EQ] || subset[PermissionConditionOperators.$NEQ]) {
const subsetOperatorValue = subset[PermissionConditionOperators.$EQ] || subset[PermissionConditionOperators.$NEQ];
const isInverted = !subset[PermissionConditionOperators.$EQ];
if (
parentSet[PermissionConditionOperators.$EQ] &&
invertTheOperation(isInverted, parentSet[PermissionConditionOperators.$EQ] !== subsetOperatorValue)
) {
return false;
}
if (
parentSet[PermissionConditionOperators.$NEQ] &&
invertTheOperation(isInverted, parentSet[PermissionConditionOperators.$NEQ] === subsetOperatorValue)
) {
return false;
}
if (
parentSet[PermissionConditionOperators.$IN] &&
invertTheOperation(isInverted, !parentSet[PermissionConditionOperators.$IN].includes(subsetOperatorValue))
) {
return false;
}
// ne and glob cannot match each other
if (parentSet[PermissionConditionOperators.$GLOB] && isInverted) {
return false;
}
if (
parentSet[PermissionConditionOperators.$GLOB] &&
!picomatch.isMatch(subsetOperatorValue, parentSet[PermissionConditionOperators.$GLOB], { strictSlashes: false })
) {
return false;
}
}
if (subset[PermissionConditionOperators.$IN]) {
const subsetOperatorValue = subset[PermissionConditionOperators.$IN];
if (
parentSet[PermissionConditionOperators.$EQ] &&
(subsetOperatorValue.length !== 1 || subsetOperatorValue[0] !== parentSet[PermissionConditionOperators.$EQ])
) {
return false;
}
if (
parentSet[PermissionConditionOperators.$NEQ] &&
subsetOperatorValue.includes(parentSet[PermissionConditionOperators.$NEQ])
) {
return false;
}
if (
parentSet[PermissionConditionOperators.$IN] &&
!subsetOperatorValue.every((el) => parentSet[PermissionConditionOperators.$IN].includes(el))
) {
return false;
}
if (
parentSet[PermissionConditionOperators.$GLOB] &&
!subsetOperatorValue.every((el) =>
picomatch.isMatch(el, parentSet[PermissionConditionOperators.$GLOB], {
strictSlashes: false
})
)
) {
return false;
}
}
if (subset[PermissionConditionOperators.$GLOB]) {
const subsetOperatorValue = subset[PermissionConditionOperators.$GLOB];
const { isGlob } = picomatch.scan(subsetOperatorValue);
// if it's glob, all other fixed operators would make this superset because glob is powerful. like eq
// example: $in [dev, prod] => glob: dev** could mean anything starting with dev: thus is bigger
if (
isGlob &&
Object.keys(parentSet).some(
(el) => el !== PermissionConditionOperators.$GLOB && el !== PermissionConditionOperators.$NEQ
)
) {
return false;
}
if (
parentSet[PermissionConditionOperators.$EQ] &&
parentSet[PermissionConditionOperators.$EQ] !== subsetOperatorValue
) {
return false;
}
if (
parentSet[PermissionConditionOperators.$NEQ] &&
picomatch.isMatch(parentSet[PermissionConditionOperators.$NEQ], subsetOperatorValue, {
strictSlashes: false
})
) {
return false;
}
// if parent set is IN, glob cannot be used for children - It's a bigger scope
if (
parentSet[PermissionConditionOperators.$IN] &&
!parentSet[PermissionConditionOperators.$IN].includes(subsetOperatorValue)
) {
return false;
}
if (
parentSet[PermissionConditionOperators.$GLOB] &&
!picomatch.isMatch(subsetOperatorValue, parentSet[PermissionConditionOperators.$GLOB], {
strictSlashes: false
})
) {
return false;
}
}
return true;
};
const isSubsetForSamePermissionSubjectAction = (
parentSetRules: ReturnType<MongoAbility["possibleRulesFor"]>,
subsetRules: ReturnType<MongoAbility["possibleRulesFor"]>,
appendToMissingPermission: (condition?: MongoQuery) => void
) => {
const isMissingConditionInParent = parentSetRules.every((el) => !el.conditions);
if (isMissingConditionInParent) return true;
// all subset rules must pass in comparison to parent rul
return subsetRules.every((subsetRule) => {
const subsetRuleConditions = subsetRule.conditions as Record<string, TPermissionConditionShape | string>;
// compare subset rule with all parent rules
const isSubsetOfNonInvertedParentSet = parentSetRules
.filter((el) => !el.inverted)
.some((parentSetRule) => {
// get conditions and iterate
const parentSetRuleConditions = parentSetRule?.conditions as Record<string, TPermissionConditionShape | string>;
if (!parentSetRuleConditions) return true;
return Object.keys(parentSetRuleConditions).every((parentConditionField) => {
// if parent condition is missing then it's never a subset
if (!subsetRuleConditions?.[parentConditionField]) return false;
// standardize the conditions plain string operator => $eq function
const parentRuleConditionOperators = formatConditionOperator(parentSetRuleConditions[parentConditionField]);
const selectedSubsetRuleCondition = subsetRuleConditions?.[parentConditionField];
const subsetRuleConditionOperators = formatConditionOperator(selectedSubsetRuleCondition);
return isOperatorsASubset(parentRuleConditionOperators, subsetRuleConditionOperators);
});
});
const invertedParentSetRules = parentSetRules.filter((el) => el.inverted);
const isNotSubsetOfInvertedParentSet = invertedParentSetRules.length
? !invertedParentSetRules.some((parentSetRule) => {
// get conditions and iterate
const parentSetRuleConditions = parentSetRule?.conditions as Record<
string,
TPermissionConditionShape | string
>;
if (!parentSetRuleConditions) return true;
return Object.keys(parentSetRuleConditions).every((parentConditionField) => {
// if parent condition is missing then it's never a subset
if (!subsetRuleConditions?.[parentConditionField]) return false;
// standardize the conditions plain string operator => $eq function
const parentRuleConditionOperators = formatConditionOperator(parentSetRuleConditions[parentConditionField]);
const selectedSubsetRuleCondition = subsetRuleConditions?.[parentConditionField];
const subsetRuleConditionOperators = formatConditionOperator(selectedSubsetRuleCondition);
return isOperatorsASubset(parentRuleConditionOperators, subsetRuleConditionOperators);
});
})
: true;
const isSubset = isSubsetOfNonInvertedParentSet && isNotSubsetOfInvertedParentSet;
if (!isSubset) {
appendToMissingPermission(subsetRule.conditions);
}
return isSubset;
});
};
export const validatePermissionBoundary = (parentSetPermissions: MongoAbility, subsetPermissions: MongoAbility) => {
const checkedPermissionRules = new Set<string>();
const missingPermissions: TMissingPermission[] = [];
subsetPermissions.rules.forEach((subsetPermissionRules) => {
const subsetPermissionSubject = subsetPermissionRules.subject.toString();
let subsetPermissionActions: string[] = [];
// actions can be string or string[]
if (typeof subsetPermissionRules.action === "string") {
subsetPermissionActions.push(subsetPermissionRules.action);
} else {
subsetPermissionRules.action.forEach((subsetPermissionAction) => {
subsetPermissionActions.push(subsetPermissionAction);
});
}
// if action is already processed ignore
subsetPermissionActions = subsetPermissionActions.filter(
(el) => !checkedPermissionRules.has(getPermissionSetID(el, subsetPermissionSubject))
);
if (!subsetPermissionActions.length) return;
subsetPermissionActions.forEach((subsetPermissionAction) => {
const parentSetRulesOfSubset = parentSetPermissions.possibleRulesFor(
subsetPermissionAction,
subsetPermissionSubject
);
const nonInveretedOnes = parentSetRulesOfSubset.filter((el) => !el.inverted);
if (!nonInveretedOnes.length) {
missingPermissions.push({ action: subsetPermissionAction, subject: subsetPermissionSubject });
return;
}
const subsetRules = subsetPermissions.possibleRulesFor(subsetPermissionAction, subsetPermissionSubject);
isSubsetForSamePermissionSubjectAction(parentSetRulesOfSubset, subsetRules, (conditions) => {
missingPermissions.push({ action: subsetPermissionAction, subject: subsetPermissionSubject, conditions });
});
});
subsetPermissionActions.forEach((el) =>
checkedPermissionRules.add(getPermissionSetID(el, subsetPermissionSubject))
);
});
if (missingPermissions.length) {
return { isValid: false as const, missingPermissions };
}
return { isValid: true };
};
+1 -38
View File
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { buildMongoQueryMatcher, MongoAbility } from "@casl/ability"; import { buildMongoQueryMatcher } from "@casl/ability";
import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js"; import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js";
import picomatch from "picomatch"; import picomatch from "picomatch";
@@ -20,45 +20,8 @@ const glob: JsInterpreter<FieldCondition<string>> = (node, object, context) => {
export const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob }); export const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob });
/**
* Extracts and formats permissions from a CASL Ability object or a raw permission set.
*/
const extractPermissions = (ability: MongoAbility) => {
const permissions: string[] = [];
ability.rules.forEach((permission) => {
if (typeof permission.action === "string") {
permissions.push(`${permission.action}_${permission.subject as string}`);
} else {
permission.action.forEach((permissionAction) => {
permissions.push(`${permissionAction}_${permission.subject as string}`);
});
}
});
return permissions;
};
/**
* Compares two sets of permissions to determine if the first set is at least as privileged as the second set.
* The function checks if all permissions in the second set are contained within the first set and if the first set has equal or more permissions.
*
*/
export const isAtLeastAsPrivileged = (permissions1: MongoAbility, permissions2: MongoAbility) => {
const set1 = new Set(extractPermissions(permissions1));
const set2 = new Set(extractPermissions(permissions2));
for (const perm of set2) {
if (!set1.has(perm)) {
return false;
}
}
return set1.size >= set2.size;
};
export enum PermissionConditionOperators { export enum PermissionConditionOperators {
$IN = "$in", $IN = "$in",
$ALL = "$all",
$REGEX = "$regex",
$EQ = "$eq", $EQ = "$eq",
$NEQ = "$ne", $NEQ = "$ne",
$GLOB = "$glob" $GLOB = "$glob"
+9 -1
View File
@@ -52,10 +52,18 @@ export class ForbiddenRequestError extends Error {
error: unknown; error: unknown;
constructor({ name, error, message }: { message?: string; name?: string; error?: unknown } = {}) { details?: unknown;
constructor({
name,
error,
message,
details
}: { message?: string; name?: string; error?: unknown; details?: unknown } = {}) {
super(message ?? "You are not allowed to access this resource"); super(message ?? "You are not allowed to access this resource");
this.name = name || "ForbiddenError"; this.name = name || "ForbiddenError";
this.error = error; this.error = error;
this.details = details;
} }
} }
+52 -38
View File
@@ -96,6 +96,7 @@ export const pingGatewayAndVerify = async ({
error: err as Error error: err as Error
}); });
}); });
for (let attempt = 1; attempt <= maxRetries; attempt += 1) { for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
try { try {
const stream = quicClient.connection.newStream("bidi"); const stream = quicClient.connection.newStream("bidi");
@@ -108,17 +109,13 @@ export const pingGatewayAndVerify = async ({
const { value, done } = await reader.read(); const { value, done } = await reader.read();
if (done) { if (done) {
throw new BadRequestError({ throw new Error("Gateway closed before receiving PONG");
message: "Gateway closed before receiving PONG"
});
} }
const response = Buffer.from(value).toString(); const response = Buffer.from(value).toString();
if (response !== "PONG\n" && response !== "PONG") { if (response !== "PONG\n" && response !== "PONG") {
throw new BadRequestError({ throw new Error(`Failed to Ping. Unexpected response: ${response}`);
message: `Failed to Ping. Unexpected response: ${response}`
});
} }
reader.releaseLock(); reader.releaseLock();
@@ -146,6 +143,7 @@ interface TProxyServer {
server: net.Server; server: net.Server;
port: number; port: number;
cleanup: () => Promise<void>; cleanup: () => Promise<void>;
getProxyError: () => string;
} }
const setupProxyServer = async ({ const setupProxyServer = async ({
@@ -170,6 +168,7 @@ const setupProxyServer = async ({
error: err as Error error: err as Error
}); });
}); });
const proxyErrorMsg = [""];
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const server = net.createServer(); const server = net.createServer();
@@ -185,31 +184,33 @@ const setupProxyServer = async ({
const forwardWriter = stream.writable.getWriter(); const forwardWriter = stream.writable.getWriter();
await forwardWriter.write(Buffer.from(`FORWARD-TCP ${targetHost}:${targetPort}\n`)); await forwardWriter.write(Buffer.from(`FORWARD-TCP ${targetHost}:${targetPort}\n`));
forwardWriter.releaseLock(); forwardWriter.releaseLock();
/* eslint-disable @typescript-eslint/no-misused-promises */
// Set up bidirectional copy // Set up bidirectional copy
const setupCopy = async () => { const setupCopy = () => {
// Client to QUIC // Client to QUIC
// eslint-disable-next-line // eslint-disable-next-line
(async () => { (async () => {
try { const writer = stream.writable.getWriter();
const writer = stream.writable.getWriter();
// Create a handler for client data // Create a handler for client data
clientConn.on("data", async (chunk) => { clientConn.on("data", (chunk) => {
await writer.write(chunk); writer.write(chunk).catch((err) => {
proxyErrorMsg.push((err as Error)?.message);
}); });
});
// Handle client connection close // Handle client connection close
clientConn.on("end", async () => { clientConn.on("end", () => {
await writer.close(); writer.close().catch((err) => {
logger.error(err);
}); });
});
clientConn.on("error", async (err) => { clientConn.on("error", (clientConnErr) => {
await writer.abort(err); writer.abort(clientConnErr?.message).catch((err) => {
proxyErrorMsg.push((err as Error)?.message);
}); });
} catch (err) { });
clientConn.destroy();
}
})(); })();
// QUIC to Client // QUIC to Client
@@ -238,15 +239,18 @@ const setupProxyServer = async ({
} }
} }
} catch (err) { } catch (err) {
proxyErrorMsg.push((err as Error)?.message);
clientConn.destroy(); clientConn.destroy();
} }
})(); })();
}; };
await setupCopy();
// setupCopy();
// Handle connection closure // Handle connection closure
clientConn.on("close", async () => { clientConn.on("close", () => {
await stream.destroy(); stream.destroy().catch((err) => {
proxyErrorMsg.push((err as Error)?.message);
});
}); });
const cleanup = async () => { const cleanup = async () => {
@@ -254,13 +258,18 @@ const setupProxyServer = async ({
await stream.destroy(); await stream.destroy();
}; };
clientConn.on("error", (err) => { clientConn.on("error", (clientConnErr) => {
logger.error(err, "Client socket error"); logger.error(clientConnErr, "Client socket error");
void cleanup(); cleanup().catch((err) => {
reject(err); logger.error(err, "Client conn cleanup");
});
}); });
clientConn.on("end", cleanup); clientConn.on("end", () => {
cleanup().catch((err) => {
logger.error(err, "Client conn end");
});
});
} catch (err) { } catch (err) {
logger.error(err, "Failed to establish target connection:"); logger.error(err, "Failed to establish target connection:");
clientConn.end(); clientConn.end();
@@ -272,12 +281,12 @@ const setupProxyServer = async ({
reject(err); reject(err);
}); });
server.on("close", async () => { server.on("close", () => {
await quicClient?.destroy(); quicClient?.destroy().catch((err) => {
logger.error(err, "Failed to destroy quic client");
});
}); });
/* eslint-enable */
server.listen(0, () => { server.listen(0, () => {
const address = server.address(); const address = server.address();
if (!address || typeof address === "string") { if (!address || typeof address === "string") {
@@ -293,7 +302,8 @@ const setupProxyServer = async ({
cleanup: async () => { cleanup: async () => {
server.close(); server.close();
await quicClient?.destroy(); await quicClient?.destroy();
} },
getProxyError: () => proxyErrorMsg.join(",")
}); });
}); });
}); });
@@ -316,7 +326,7 @@ export const withGatewayProxy = async (
const { relayHost, relayPort, targetHost, targetPort, tlsOptions, identityId, orgId } = options; const { relayHost, relayPort, targetHost, targetPort, tlsOptions, identityId, orgId } = options;
// Setup the proxy server // Setup the proxy server
const { port, cleanup } = await setupProxyServer({ const { port, cleanup, getProxyError } = await setupProxyServer({
targetHost, targetHost,
targetPort, targetPort,
relayPort, relayPort,
@@ -330,8 +340,12 @@ export const withGatewayProxy = async (
// Execute the callback with the allocated port // Execute the callback with the allocated port
await callback(port); await callback(port);
} catch (err) { } catch (err) {
logger.error(err, "Failed to proxy"); const proxyErrorMessage = getProxyError();
throw new BadRequestError({ message: (err as Error)?.message }); if (proxyErrorMessage) {
logger.error(new Error(proxyErrorMessage), "Failed to proxy");
}
logger.error(err, "Failed to do gateway");
throw new BadRequestError({ message: proxyErrorMessage || (err as Error)?.message });
} finally { } finally {
// Ensure cleanup happens regardless of success or failure // Ensure cleanup happens regardless of success or failure
await cleanup(); await cleanup();
+1 -1
View File
@@ -1,6 +1,6 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
const TURN_TOKEN_TTL = 60 * 60 * 1000; // 24 hours in milliseconds const TURN_TOKEN_TTL = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
export const getTurnCredentials = (id: string, authSecret: string, ttl = TURN_TOKEN_TTL) => { export const getTurnCredentials = (id: string, authSecret: string, ttl = TURN_TOKEN_TTL) => {
const timestamp = Math.floor((Date.now() + ttl) / 1000); const timestamp = Math.floor((Date.now() + ttl) / 1000);
const username = `${timestamp}:${id}`; const username = `${timestamp}:${id}`;
+8
View File
@@ -83,6 +83,14 @@ const run = async () => {
process.exit(0); process.exit(0);
}); });
process.on("uncaughtException", (error) => {
logger.error(error, "CRITICAL ERROR: Uncaught Exception");
});
process.on("unhandledRejection", (error) => {
logger.error(error, "CRITICAL ERROR: Unhandled Promise Rejection");
});
await server.listen({ await server.listen({
port: envConfig.PORT, port: envConfig.PORT,
host: envConfig.HOST, host: envConfig.HOST,
+2 -1
View File
@@ -21,6 +21,7 @@ import {
TQueueSecretSyncSyncSecretsByIdDTO, TQueueSecretSyncSyncSecretsByIdDTO,
TQueueSendSecretSyncActionFailedNotificationsDTO TQueueSendSecretSyncActionFailedNotificationsDTO
} from "@app/services/secret-sync/secret-sync-types"; } from "@app/services/secret-sync/secret-sync-types";
import { TWebhookPayloads } from "@app/services/webhook/webhook-types";
export enum QueueName { export enum QueueName {
SecretRotation = "secret-rotation", SecretRotation = "secret-rotation",
@@ -107,7 +108,7 @@ export type TQueueJobTypes = {
}; };
[QueueName.SecretWebhook]: { [QueueName.SecretWebhook]: {
name: QueueJobs.SecWebhook; name: QueueJobs.SecWebhook;
payload: { projectId: string; environment: string; secretPath: string; depth?: number }; payload: TWebhookPayloads;
}; };
[QueueName.AccessTokenStatusUpdate]: [QueueName.AccessTokenStatusUpdate]:
+2 -1
View File
@@ -122,7 +122,8 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
reqId: req.id, reqId: req.id,
statusCode: HttpStatusCodes.Forbidden, statusCode: HttpStatusCodes.Forbidden,
message: error.message, message: error.message,
error: error.name error: error.name,
details: error?.details
}); });
} else if (error instanceof RateLimitError) { } else if (error instanceof RateLimitError) {
void res.status(HttpStatusCodes.TooManyRequests).send({ void res.status(HttpStatusCodes.TooManyRequests).send({
+13 -2
View File
@@ -307,7 +307,17 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
.max(256, { message: "Description must be 256 or fewer characters" }) .max(256, { message: "Description must be 256 or fewer characters" })
.optional() .optional()
.describe(PROJECTS.UPDATE.projectDescription), .describe(PROJECTS.UPDATE.projectDescription),
autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization) autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization),
slug: z
.string()
.trim()
.regex(
/^[a-z0-9]+(?:[_-][a-z0-9]+)*$/,
"Project slug can only contain lowercase letters and numbers, with optional single hyphens (-) or underscores (_) between words. Cannot start or end with a hyphen or underscore."
)
.max(64, { message: "Slug must be 64 characters or fewer" })
.optional()
.describe(PROJECTS.UPDATE.slug)
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -325,7 +335,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
update: { update: {
name: req.body.name, name: req.body.name,
description: req.body.description, description: req.body.description,
autoCapitalization: req.body.autoCapitalization autoCapitalization: req.body.autoCapitalization,
slug: req.body.slug
}, },
actorAuthMethod: req.permission.authMethod, actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id, actorId: req.permission.id,
@@ -380,6 +380,48 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
} }
}); });
server.route({
method: "GET",
url: "/raw/id/:secretId",
config: {
rateLimit: secretsLimit
},
schema: {
params: z.object({
secretId: z.string()
}),
response: {
200: z.object({
secret: secretRawSchema.extend({
secretPath: z.string(),
tags: SecretTagsSchema.pick({
id: true,
slug: true,
color: true
})
.extend({ name: z.string() })
.array()
.optional(),
secretMetadata: ResourceMetadataSchema.optional()
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { secretId } = req.params;
const secret = await server.services.secret.getSecretByIdRaw({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
secretId
});
return { secret };
}
});
server.route({ server.route({
method: "GET", method: "GET",
url: "/raw/:secretName", url: "/raw/:secretName",
@@ -4,7 +4,7 @@ import ms from "ms";
import { ActionProjectType, ProjectMembershipRole, SecretKeyEncoding, TGroups } from "@app/db/schemas"; import { ActionProjectType, ProjectMembershipRole, SecretKeyEncoding, TGroups } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto";
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
@@ -102,11 +102,13 @@ export const groupProjectServiceFactory = ({
project.id project.id
); );
const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, rolePermission); const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!permissionBoundary.isValid)
if (!hasRequiredPrivileges) { throw new ForbiddenRequestError({
throw new ForbiddenRequestError({ message: "Failed to assign group to a more privileged role" }); name: "PermissionBoundaryError",
} message: "Failed to assign group to a more privileged role",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
} }
// validate custom roles input // validate custom roles input
@@ -267,12 +269,13 @@ export const groupProjectServiceFactory = ({
requestedRoleChange, requestedRoleChange,
project.id project.id
); );
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, rolePermission); if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({
if (!hasRequiredPrivileges) { name: "PermissionBoundaryError",
throw new ForbiddenRequestError({ message: "Failed to assign group to a more privileged role" }); message: "Failed to assign group to a more privileged role",
} details: { missingPermissions: permissionBoundary.missingPermissions }
});
} }
// validate custom roles input // validate custom roles input
@@ -7,7 +7,7 @@ import { IdentityAuthMethod } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
@@ -339,9 +339,12 @@ export const identityAwsAuthServiceFactory = ({
actorOrgId actorOrgId
); );
if (!isAtLeastAsPrivileged(permission, rolePermission)) const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "Failed to revoke aws auth of identity with more privileged role" name: "PermissionBoundaryError",
message: "Failed to revoke aws auth of identity with more privileged role",
details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
const revokedIdentityAwsAuth = await identityAwsAuthDAL.transaction(async (tx) => { const revokedIdentityAwsAuth = await identityAwsAuthDAL.transaction(async (tx) => {
@@ -5,7 +5,7 @@ import { IdentityAuthMethod } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
@@ -312,9 +312,12 @@ export const identityAzureAuthServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
if (!isAtLeastAsPrivileged(permission, rolePermission)) const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "Failed to revoke azure auth of identity with more privileged role" name: "PermissionBoundaryError",
message: "Failed to revoke azure auth of identity with more privileged role",
details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
const revokedIdentityAzureAuth = await identityAzureAuthDAL.transaction(async (tx) => { const revokedIdentityAzureAuth = await identityAzureAuthDAL.transaction(async (tx) => {
@@ -5,7 +5,7 @@ import { IdentityAuthMethod } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
@@ -358,9 +358,12 @@ export const identityGcpAuthServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
if (!isAtLeastAsPrivileged(permission, rolePermission)) const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "Failed to revoke gcp auth of identity with more privileged role" name: "PermissionBoundaryError",
message: "Failed to revoke gcp auth of identity with more privileged role",
details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
const revokedIdentityGcpAuth = await identityGcpAuthDAL.transaction(async (tx) => { const revokedIdentityGcpAuth = await identityGcpAuthDAL.transaction(async (tx) => {
@@ -7,7 +7,7 @@ import { IdentityAuthMethod, TIdentityJwtAuthsUpdate } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
@@ -508,11 +508,13 @@ export const identityJwtAuthServiceFactory = ({
actorOrgId actorOrgId
); );
if (!isAtLeastAsPrivileged(permission, rolePermission)) { const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "Failed to revoke JWT auth of identity with more privileged role" name: "PermissionBoundaryError",
message: "Failed to revoke jwt auth of identity with more privileged role",
details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
}
const revokedIdentityJwtAuth = await identityJwtAuthDAL.transaction(async (tx) => { const revokedIdentityJwtAuth = await identityJwtAuthDAL.transaction(async (tx) => {
const deletedJwtAuth = await identityJwtAuthDAL.delete({ identityId }, tx); const deletedJwtAuth = await identityJwtAuthDAL.delete({ identityId }, tx);
@@ -7,7 +7,7 @@ import { IdentityAuthMethod, TIdentityKubernetesAuthsUpdate } from "@app/db/sche
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
@@ -487,9 +487,12 @@ export const identityKubernetesAuthServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
if (!isAtLeastAsPrivileged(permission, rolePermission)) const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "Failed to revoke kubernetes auth of identity with more privileged role" name: "PermissionBoundaryError",
message: "Failed to revoke kubernetes auth of identity with more privileged role",
details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
const revokedIdentityKubernetesAuth = await identityKubernetesAuthDAL.transaction(async (tx) => { const revokedIdentityKubernetesAuth = await identityKubernetesAuthDAL.transaction(async (tx) => {
@@ -8,7 +8,7 @@ import { IdentityAuthMethod, TIdentityOidcAuthsUpdate } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
@@ -428,11 +428,13 @@ export const identityOidcAuthServiceFactory = ({
actorOrgId actorOrgId
); );
if (!isAtLeastAsPrivileged(permission, rolePermission)) { const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "Failed to revoke OIDC auth of identity with more privileged role" name: "PermissionBoundaryError",
message: "Failed to revoke oidc auth of identity with more privileged role",
details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
}
const revokedIdentityOidcAuth = await identityOidcAuthDAL.transaction(async (tx) => { const revokedIdentityOidcAuth = await identityOidcAuthDAL.transaction(async (tx) => {
const deletedOidcAuth = await identityOidcAuthDAL.delete({ identityId }, tx); const deletedOidcAuth = await identityOidcAuthDAL.delete({ identityId }, tx);
@@ -4,7 +4,7 @@ import ms from "ms";
import { ActionProjectType, ProjectMembershipRole } from "@app/db/schemas"; import { ActionProjectType, ProjectMembershipRole } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn"; import { groupBy } from "@app/lib/fn";
@@ -91,11 +91,13 @@ export const identityProjectServiceFactory = ({
projectId projectId
); );
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!permissionBoundary.isValid)
if (!hasRequiredPriviledges) { throw new ForbiddenRequestError({
throw new ForbiddenRequestError({ message: "Failed to change to a more privileged role" }); name: "PermissionBoundaryError",
} message: "Failed to assign to a more privileged role",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
} }
// validate custom roles input // validate custom roles input
@@ -185,9 +187,13 @@ export const identityProjectServiceFactory = ({
projectId projectId
); );
if (!isAtLeastAsPrivileged(permission, rolePermission)) { const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
throw new ForbiddenRequestError({ message: "Failed to change to a more privileged role" }); if (!permissionBoundary.isValid)
} throw new ForbiddenRequestError({
name: "PermissionBoundaryError",
message: "Failed to change to a more privileged role",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
} }
// validate custom roles input // validate custom roles input
@@ -277,8 +283,13 @@ export const identityProjectServiceFactory = ({
actorOrgId, actorOrgId,
actionProjectType: ActionProjectType.Any actionProjectType: ActionProjectType.Any
}); });
if (!isAtLeastAsPrivileged(permission, identityRolePermission)) const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission);
throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({
name: "PermissionBoundaryError",
message: "Failed to remove more privileged identity",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
const [deletedIdentity] = await identityProjectDAL.delete({ identityId, projectId }); const [deletedIdentity] = await identityProjectDAL.delete({ identityId, projectId });
return deletedIdentity; return deletedIdentity;
@@ -5,7 +5,7 @@ import { IdentityAuthMethod, TableName } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
@@ -245,11 +245,13 @@ export const identityTokenAuthServiceFactory = ({
actorOrgId actorOrgId
); );
if (!isAtLeastAsPrivileged(permission, rolePermission)) { const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "Failed to revoke Token Auth of identity with more privileged role" name: "PermissionBoundaryError",
message: "Failed to revoke token auth of identity with more privileged role",
details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
}
const revokedIdentityTokenAuth = await identityTokenAuthDAL.transaction(async (tx) => { const revokedIdentityTokenAuth = await identityTokenAuthDAL.transaction(async (tx) => {
const deletedTokenAuth = await identityTokenAuthDAL.delete({ identityId }, tx); const deletedTokenAuth = await identityTokenAuthDAL.delete({ identityId }, tx);
@@ -295,10 +297,12 @@ export const identityTokenAuthServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!hasPriviledge) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "Failed to create token for identity with more privileged role" name: "PermissionBoundaryError",
message: "Failed to create token for identity with more privileged role",
details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId }); const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId });
@@ -415,10 +419,12 @@ export const identityTokenAuthServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!hasPriviledge) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "Failed to update token for identity with more privileged role" name: "PermissionBoundaryError",
message: "Failed to update token for identity with more privileged role",
details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
const [token] = await identityAccessTokenDAL.update( const [token] = await identityAccessTokenDAL.update(
@@ -8,7 +8,7 @@ import { IdentityAuthMethod } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from "@app/lib/ip"; import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from "@app/lib/ip";
@@ -367,9 +367,12 @@ export const identityUaServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
if (!isAtLeastAsPrivileged(permission, rolePermission)) const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "Failed to revoke universal auth of identity with more privileged role" name: "PermissionBoundaryError",
message: "Failed to revoke universal auth of identity with more privileged role",
details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
const revokedIdentityUniversalAuth = await identityUaDAL.transaction(async (tx) => { const revokedIdentityUniversalAuth = await identityUaDAL.transaction(async (tx) => {
@@ -414,10 +417,12 @@ export const identityUaServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!hasPriviledge) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "Failed to add identity to project with more privileged role" name: "PermissionBoundaryError",
message: "Failed to create client secret for a more privileged identity.",
details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
const appCfg = getConfig(); const appCfg = getConfig();
@@ -475,9 +480,12 @@ export const identityUaServiceFactory = ({
actorOrgId actorOrgId
); );
if (!isAtLeastAsPrivileged(permission, rolePermission)) const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "Failed to add identity to project with more privileged role" name: "PermissionBoundaryError",
message: "Failed to get identity client secret with more privileged role",
details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
const identityUniversalAuth = await identityUaDAL.findOne({ const identityUniversalAuth = await identityUaDAL.findOne({
@@ -524,9 +532,12 @@ export const identityUaServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
if (!isAtLeastAsPrivileged(permission, rolePermission)) const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "Failed to read identity client secret of project with more privileged role" name: "PermissionBoundaryError",
message: "Failed to read identity client secret of identity with more privileged role",
details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
const clientSecret = await identityUaClientSecretDAL.findById(clientSecretId); const clientSecret = await identityUaClientSecretDAL.findById(clientSecretId);
@@ -566,10 +577,12 @@ export const identityUaServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!isAtLeastAsPrivileged(permission, rolePermission)) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "Failed to revoke identity client secret with more privileged role" name: "PermissionBoundaryError",
message: "Failed to revoke identity client secret with more privileged role",
details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
const clientSecret = await identityUaClientSecretDAL.updateById(clientSecretId, { const clientSecret = await identityUaClientSecretDAL.updateById(clientSecretId, {
@@ -4,7 +4,7 @@ import { OrgMembershipRole, TableName, TOrgRoles } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
@@ -58,9 +58,13 @@ export const identityServiceFactory = ({
orgId orgId
); );
const isCustomRole = Boolean(customRole); const isCustomRole = Boolean(customRole);
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!hasRequiredPriviledges) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ message: "Failed to create a more privileged identity" }); throw new ForbiddenRequestError({
name: "PermissionBoundaryError",
message: "Failed to create a more privileged identity",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
const plan = await licenseService.getPlan(orgId); const plan = await licenseService.getPlan(orgId);
@@ -129,9 +133,13 @@ export const identityServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission);
if (!hasRequiredPriviledges) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); throw new ForbiddenRequestError({
name: "PermissionBoundaryError",
message: "Failed to update a more privileged identity",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
let customRole: TOrgRoles | undefined; let customRole: TOrgRoles | undefined;
if (role) { if (role) {
@@ -141,9 +149,13 @@ export const identityServiceFactory = ({
); );
const isCustomRole = Boolean(customOrgRole); const isCustomRole = Boolean(customOrgRole);
const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission); const appliedRolePermissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!hasRequiredNewRolePermission) if (!appliedRolePermissionBoundary.isValid)
throw new ForbiddenRequestError({ message: "Failed to create a more privileged identity" }); throw new ForbiddenRequestError({
name: "PermissionBoundaryError",
message: "Failed to create a more privileged identity",
details: { missingPermissions: appliedRolePermissionBoundary.missingPermissions }
});
if (isCustomRole) customRole = customOrgRole; if (isCustomRole) customRole = customOrgRole;
} }
@@ -216,9 +228,13 @@ export const identityServiceFactory = ({
actorAuthMethod, actorAuthMethod,
actorOrgId actorOrgId
); );
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission); const permissionBoundary = validatePermissionBoundary(permission, identityRolePermission);
if (!hasRequiredPriviledges) if (!permissionBoundary.isValid)
throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); throw new ForbiddenRequestError({
name: "PermissionBoundaryError",
message: "Failed to delete more privileged identity",
details: { missingPermissions: permissionBoundary.missingPermissions }
});
const deletedIdentity = await identityDAL.deleteById(id); const deletedIdentity = await identityDAL.deleteById(id);
@@ -114,20 +114,27 @@ export const integrationAuthServiceFactory = ({
const listOrgIntegrationAuth = async ({ actorId, actor, actorOrgId, actorAuthMethod }: TGenericPermission) => { const listOrgIntegrationAuth = async ({ actorId, actor, actorOrgId, actorAuthMethod }: TGenericPermission) => {
const authorizations = await integrationAuthDAL.getByOrg(actorOrgId as string); const authorizations = await integrationAuthDAL.getByOrg(actorOrgId as string);
return Promise.all( const filteredAuthorizations = await Promise.all(
authorizations.filter(async (auth) => { authorizations.map(async (auth) => {
const { permission } = await permissionService.getProjectPermission({ try {
actor, const { permission } = await permissionService.getProjectPermission({
actorId, actor,
projectId: auth.projectId, actorId,
actorAuthMethod, projectId: auth.projectId,
actorOrgId, actorAuthMethod,
actionProjectType: ActionProjectType.SecretManager actorOrgId,
}); actionProjectType: ActionProjectType.SecretManager
});
return permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); return permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations) ? auth : null;
} catch (error) {
// user does not belong to the project that the integration auth belongs to
return null;
}
}) })
); );
return filteredAuthorizations.filter((auth): auth is NonNullable<typeof auth> => auth !== null);
}; };
const getIntegrationAuth = async ({ actor, id, actorId, actorAuthMethod, actorOrgId }: TGetIntegrationAuthDTO) => { const getIntegrationAuth = async ({ actor, id, actorId, actorAuthMethod, actorOrgId }: TGetIntegrationAuthDTO) => {
@@ -7,7 +7,7 @@ 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 { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; import { TProjectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { validatePermissionBoundary } from "@app/lib/casl/boundary";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn"; import { groupBy } from "@app/lib/fn";
@@ -274,13 +274,13 @@ export const projectMembershipServiceFactory = ({
projectId projectId
); );
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); const permissionBoundary = validatePermissionBoundary(permission, rolePermission);
if (!permissionBoundary.isValid)
if (!hasRequiredPriviledges) {
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: `Failed to change to a more privileged role ${requestedRoleChange}` name: "PermissionBoundaryError",
message: `Failed to change to a more privileged role ${requestedRoleChange}`,
details: { missingPermissions: permissionBoundary.missingPermissions }
}); });
}
} }
// validate custom roles input // validate custom roles input
@@ -563,11 +563,24 @@ export const projectServiceFactory = ({
}); });
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings);
if (update.slug) {
const existingProject = await projectDAL.findOne({
slug: update.slug,
orgId: actorOrgId
});
if (existingProject && existingProject.id !== project.id) {
throw new BadRequestError({
message: `Failed to update project slug. The project "${existingProject.name}" with the slug "${existingProject.slug}" already exists in your organization. Please choose a unique slug for your project.`
});
}
}
const updatedProject = await projectDAL.updateById(project.id, { const updatedProject = await projectDAL.updateById(project.id, {
name: update.name, name: update.name,
description: update.description, description: update.description,
autoCapitalization: update.autoCapitalization, autoCapitalization: update.autoCapitalization,
enforceCapitalization: update.autoCapitalization enforceCapitalization: update.autoCapitalization,
slug: update.slug
}); });
return updatedProject; return updatedProject;
@@ -82,6 +82,7 @@ export type TUpdateProjectDTO = {
name?: string; name?: string;
description?: string; description?: string;
autoCapitalization?: boolean; autoCapitalization?: boolean;
slug?: string;
}; };
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
@@ -613,6 +613,9 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
`${TableName.SecretTag}.id` `${TableName.SecretTag}.id`
) )
.leftJoin(TableName.SecretFolder, `${TableName.SecretV2}.folderId`, `${TableName.SecretFolder}.id`)
.leftJoin(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
.leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`)
.select(selectAllTableCols(TableName.SecretV2)) .select(selectAllTableCols(TableName.SecretV2))
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
@@ -622,12 +625,13 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue")
); )
.select(db.ref("projectId").withSchema(TableName.Environment).as("projectId"));
const docs = sqlNestRelationships({ const docs = sqlNestRelationships({
data: rawDocs, data: rawDocs,
key: "id", key: "id",
parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }), parentMapper: (el) => ({ _id: el.id, projectId: el.projectId, ...SecretsV2Schema.parse(el) }),
childrenMapper: [ childrenMapper: [
{ {
key: "tagId", key: "tagId",
@@ -28,6 +28,7 @@ import { KmsDataKey } from "../kms/kms-types";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal"; import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal";
import { TSecretQueueFactory } from "../secret/secret-queue"; import { TSecretQueueFactory } from "../secret/secret-queue";
import { TGetASecretByIdDTO } from "../secret/secret-types";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; 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";
@@ -73,7 +74,13 @@ type TSecretV2BridgeServiceFactoryDep = {
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs">; projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs">;
folderDAL: Pick< folderDAL: Pick<
TSecretFolderDALFactory, TSecretFolderDALFactory,
"findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" | "findBySecretPathMultiEnv" | "findBySecretPath"
| "updateById"
| "findById"
| "findByManySecretPath"
| "find"
| "findBySecretPathMultiEnv"
| "findSecretPathByFolderIds"
>; >;
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">; secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">;
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "handleSecretReminder" | "removeSecretReminder">; secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "handleSecretReminder" | "removeSecretReminder">;
@@ -955,6 +962,73 @@ export const secretV2BridgeServiceFactory = ({
}; };
}; };
const getSecretById = async ({ actorId, actor, actorOrgId, actorAuthMethod, secretId }: TGetASecretByIdDTO) => {
const secret = await secretDAL.findOneWithTags({
[`${TableName.SecretV2}.id` as "id"]: secretId
});
if (!secret) {
throw new NotFoundError({
message: `Secret with ID '${secretId}' not found`,
name: "GetSecretById"
});
}
const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(secret.projectId, [secret.folderId]);
if (!folderWithPath) {
throw new NotFoundError({
message: `Folder with id '${secret.folderId}' not found`,
name: "GetSecretById"
});
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: secret.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, {
environment: folderWithPath.environmentSlug,
secretPath: folderWithPath.path,
secretName: secret.key,
secretTags: secret.tags.map((i) => i.slug)
})
);
if (secret.type === SecretType.Personal && secret.userId !== actorId) {
throw new ForbiddenRequestError({
message: "You are not allowed to access this secret",
name: "GetSecretById"
});
}
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager,
projectId: secret.projectId
});
const secretValue = secret.encryptedValue
? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString()
: "";
const secretComment = secret.encryptedComment
? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString()
: "";
return reshapeBridgeSecret(secret.projectId, folderWithPath.environmentSlug, folderWithPath.path, {
...secret,
value: secretValue,
comment: secretComment
});
};
const getSecretByName = async ({ const getSecretByName = async ({
actorId, actorId,
actor, actor,
@@ -2237,6 +2311,7 @@ export const secretV2BridgeServiceFactory = ({
getSecretsCountMultiEnv, getSecretsCountMultiEnv,
getSecretsMultiEnv, getSecretsMultiEnv,
getSecretReferenceTree, getSecretReferenceTree,
getSecretsByFolderMappings getSecretsByFolderMappings,
getSecretById
}; };
}; };
+30 -4
View File
@@ -61,6 +61,7 @@ import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TUserDALFactory } from "../user/user-dal"; import { TUserDALFactory } from "../user/user-dal";
import { TWebhookDALFactory } from "../webhook/webhook-dal"; import { TWebhookDALFactory } from "../webhook/webhook-dal";
import { fnTriggerWebhook } from "../webhook/webhook-fns"; import { fnTriggerWebhook } from "../webhook/webhook-fns";
import { WebhookEvents } from "../webhook/webhook-types";
import { TSecretDALFactory } from "./secret-dal"; import { TSecretDALFactory } from "./secret-dal";
import { interpolateSecrets } from "./secret-fns"; import { interpolateSecrets } from "./secret-fns";
import { import {
@@ -623,7 +624,14 @@ export const secretQueueFactory = ({
await queueService.queue( await queueService.queue(
QueueName.SecretWebhook, QueueName.SecretWebhook,
QueueJobs.SecWebhook, QueueJobs.SecWebhook,
{ environment, projectId, secretPath }, {
type: WebhookEvents.SecretModified,
payload: {
environment,
projectId,
secretPath
}
},
{ {
jobId: `secret-webhook-${environment}-${projectId}-${secretPath}`, jobId: `secret-webhook-${environment}-${projectId}-${secretPath}`,
removeOnFail: { count: 5 }, removeOnFail: { count: 5 },
@@ -1055,6 +1063,8 @@ export const secretQueueFactory = ({
const organization = await orgDAL.findOrgByProjectId(projectId); const organization = await orgDAL.findOrgByProjectId(projectId);
const project = await projectDAL.findById(projectId); const project = await projectDAL.findById(projectId);
const secret = await secretV2BridgeDAL.findById(data.secretId);
const [folder] = await folderDAL.findSecretPathByFolderIds(project.id, [secret.folderId]);
if (!organization) { if (!organization) {
logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}] no organization found`); logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}] no organization found`);
@@ -1083,6 +1093,19 @@ export const secretQueueFactory = ({
organizationName: organization.name organizationName: organization.name
} }
}); });
await queueService.queue(QueueName.SecretWebhook, QueueJobs.SecWebhook, {
type: WebhookEvents.SecretReminderExpired,
payload: {
projectName: project.name,
projectId: project.id,
secretPath: folder?.path,
environment: folder?.environmentSlug || "",
reminderNote: data.note,
secretName: secret?.key,
secretId: data.secretId
}
});
}); });
const startSecretV2Migration = async (projectId: string) => { const startSecretV2Migration = async (projectId: string) => {
@@ -1490,14 +1513,17 @@ export const secretQueueFactory = ({
queueService.start(QueueName.SecretWebhook, async (job) => { queueService.start(QueueName.SecretWebhook, async (job) => {
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.SecretManager, type: KmsDataKey.SecretManager,
projectId: job.data.projectId projectId: job.data.payload.projectId
}); });
await fnTriggerWebhook({ await fnTriggerWebhook({
...job.data, projectId: job.data.payload.projectId,
environment: job.data.payload.environment,
secretPath: job.data.payload.secretPath || "/",
projectEnvDAL, projectEnvDAL,
webhookDAL,
projectDAL, projectDAL,
webhookDAL,
event: job.data,
secretManagerDecryptor: (value) => secretManagerDecryptor({ cipherTextBlob: value }).toString() secretManagerDecryptor: (value) => secretManagerDecryptor({ cipherTextBlob: value }).toString()
}); });
}); });
+16 -2
View File
@@ -71,6 +71,7 @@ import {
TDeleteManySecretRawDTO, TDeleteManySecretRawDTO,
TDeleteSecretDTO, TDeleteSecretDTO,
TDeleteSecretRawDTO, TDeleteSecretRawDTO,
TGetASecretByIdRawDTO,
TGetASecretDTO, TGetASecretDTO,
TGetASecretRawDTO, TGetASecretRawDTO,
TGetSecretAccessListDTO, TGetSecretAccessListDTO,
@@ -95,7 +96,7 @@ type TSecretServiceFactoryDep = {
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">; projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
folderDAL: Pick< folderDAL: Pick<
TSecretFolderDALFactory, TSecretFolderDALFactory,
"findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" "findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" | "findSecretPathByFolderIds"
>; >;
secretV2BridgeService: TSecretV2BridgeServiceFactory; secretV2BridgeService: TSecretV2BridgeServiceFactory;
secretBlindIndexDAL: TSecretBlindIndexDALFactory; secretBlindIndexDAL: TSecretBlindIndexDALFactory;
@@ -1382,6 +1383,18 @@ export const secretServiceFactory = ({
}; };
}; };
const getSecretByIdRaw = async ({ secretId, actorId, actor, actorOrgId, actorAuthMethod }: TGetASecretByIdRawDTO) => {
const secret = await secretV2BridgeService.getSecretById({
secretId,
actorId,
actor,
actorOrgId,
actorAuthMethod
});
return secret;
};
const getSecretByNameRaw = async ({ const getSecretByNameRaw = async ({
type, type,
path, path,
@@ -3088,6 +3101,7 @@ export const secretServiceFactory = ({
getSecretsRawMultiEnv, getSecretsRawMultiEnv,
getSecretReferenceTree, getSecretReferenceTree,
getSecretsRawByFolderMappings, getSecretsRawByFolderMappings,
getSecretAccessList getSecretAccessList,
getSecretByIdRaw
}; };
}; };
@@ -121,6 +121,10 @@ export type TGetASecretDTO = {
version?: number; version?: number;
} & TProjectPermission; } & TProjectPermission;
export type TGetASecretByIdDTO = {
secretId: string;
} & Omit<TProjectPermission, "projectId">;
export type TCreateBulkSecretDTO = { export type TCreateBulkSecretDTO = {
path: string; path: string;
environment: string; environment: string;
@@ -213,6 +217,10 @@ export type TGetASecretRawDTO = {
projectId?: string; projectId?: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TGetASecretByIdRawDTO = {
secretId: string;
} & Omit<TProjectPermission, "projectId">;
export type TCreateSecretRawDTO = TProjectPermission & { export type TCreateSecretRawDTO = TProjectPermission & {
secretName: string; secretName: string;
secretPath: string; secretPath: string;
+1
View File
@@ -50,6 +50,7 @@ const buildSlackPayload = (notification: TSlackNotification) => {
const messageBody = `A secret approval request has been opened by ${payload.userEmail}. const messageBody = `A secret approval request has been opened by ${payload.userEmail}.
*Environment*: ${payload.environment} *Environment*: ${payload.environment}
*Secret path*: ${payload.secretPath || "/"} *Secret path*: ${payload.secretPath || "/"}
*Secret Key${payload.secretKeys.length > 1 ? "s" : ""}*: ${payload.secretKeys.join(", ")}
View the complete details <${appCfg.SITE_URL}/secret-manager/${payload.projectId}/approval?requestId=${ View the complete details <${appCfg.SITE_URL}/secret-manager/${payload.projectId}/approval?requestId=${
payload.requestId payload.requestId
@@ -62,6 +62,7 @@ export type TSlackNotification =
secretPath: string; secretPath: string;
requestId: string; requestId: string;
projectId: string; projectId: string;
secretKeys: string[];
}; };
} }
| { | {
+81 -32
View File
@@ -11,7 +11,7 @@ import { logger } from "@app/lib/logger";
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 { TWebhookDALFactory } from "./webhook-dal"; import { TWebhookDALFactory } from "./webhook-dal";
import { WebhookType } from "./webhook-types"; import { TWebhookPayloads, WebhookEvents, WebhookType } from "./webhook-types";
const WEBHOOK_TRIGGER_TIMEOUT = 15 * 1000; const WEBHOOK_TRIGGER_TIMEOUT = 15 * 1000;
@@ -54,29 +54,64 @@ export const triggerWebhookRequest = async (
return req; return req;
}; };
export const getWebhookPayload = ( export const getWebhookPayload = (event: TWebhookPayloads) => {
eventName: string, if (event.type === WebhookEvents.SecretModified) {
details: { const { projectName, projectId, environment, secretPath, type } = event.payload;
workspaceName: string;
workspaceId: string; switch (type) {
environment: string; case WebhookType.SLACK:
secretPath?: string; return {
type?: string | null; text: "A secret value has been added or modified.",
attachments: [
{
color: "#E7F256",
fields: [
{
title: "Project",
value: projectName,
short: false
},
{
title: "Environment",
value: environment,
short: false
},
{
title: "Secret Path",
value: secretPath,
short: false
}
]
}
]
};
case WebhookType.GENERAL:
default:
return {
event: event.type,
project: {
workspaceId: projectId,
projectName,
environment,
secretPath
}
};
}
} }
) => {
const { workspaceName, workspaceId, environment, secretPath, type } = details; const { projectName, projectId, environment, secretPath, type, reminderNote, secretName } = event.payload;
switch (type) { switch (type) {
case WebhookType.SLACK: case WebhookType.SLACK:
return { return {
text: "A secret value has been added or modified.", text: "You have a secret reminder",
attachments: [ attachments: [
{ {
color: "#E7F256", color: "#E7F256",
fields: [ fields: [
{ {
title: "Project", title: "Project",
value: workspaceName, value: projectName,
short: false short: false
}, },
{ {
@@ -88,6 +123,16 @@ export const getWebhookPayload = (
title: "Secret Path", title: "Secret Path",
value: secretPath, value: secretPath,
short: false short: false
},
{
title: "Secret Name",
value: secretName,
short: false
},
{
title: "Reminder Note",
value: reminderNote,
short: false
} }
] ]
} }
@@ -96,11 +141,14 @@ export const getWebhookPayload = (
case WebhookType.GENERAL: case WebhookType.GENERAL:
default: default:
return { return {
event: eventName, event: event.type,
project: { project: {
workspaceId, workspaceId: projectId,
projectName,
environment, environment,
secretPath secretPath,
secretName,
reminderNote
} }
}; };
} }
@@ -110,6 +158,7 @@ export type TFnTriggerWebhookDTO = {
projectId: string; projectId: string;
secretPath: string; secretPath: string;
environment: string; environment: string;
event: TWebhookPayloads;
webhookDAL: Pick<TWebhookDALFactory, "findAllWebhooks" | "transaction" | "update" | "bulkUpdate">; webhookDAL: Pick<TWebhookDALFactory, "findAllWebhooks" | "transaction" | "update" | "bulkUpdate">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">; projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
projectDAL: Pick<TProjectDALFactory, "findById">; projectDAL: Pick<TProjectDALFactory, "findById">;
@@ -124,8 +173,9 @@ export const fnTriggerWebhook = async ({
projectId, projectId,
webhookDAL, webhookDAL,
projectEnvDAL, projectEnvDAL,
projectDAL, event,
secretManagerDecryptor secretManagerDecryptor,
projectDAL
}: TFnTriggerWebhookDTO) => { }: TFnTriggerWebhookDTO) => {
const webhooks = await webhookDAL.findAllWebhooks(projectId, environment); const webhooks = await webhookDAL.findAllWebhooks(projectId, environment);
const toBeTriggeredHooks = webhooks.filter( const toBeTriggeredHooks = webhooks.filter(
@@ -134,21 +184,20 @@ export const fnTriggerWebhook = async ({
); );
if (!toBeTriggeredHooks.length) return; if (!toBeTriggeredHooks.length) return;
logger.info({ environment, secretPath, projectId }, "Secret webhook job started"); logger.info({ environment, secretPath, projectId }, "Secret webhook job started");
const project = await projectDAL.findById(projectId); let { projectName } = event.payload;
if (!projectName) {
const project = await projectDAL.findById(event.payload.projectId);
projectName = project.name;
}
const webhooksTriggered = await Promise.allSettled( const webhooksTriggered = await Promise.allSettled(
toBeTriggeredHooks.map((hook) => toBeTriggeredHooks.map((hook) => {
triggerWebhookRequest( const formattedEvent = {
hook, type: event.type,
secretManagerDecryptor, payload: { ...event.payload, type: hook.type, projectName }
getWebhookPayload("secrets.modified", { } as TWebhookPayloads;
workspaceName: project.name, return triggerWebhookRequest(hook, secretManagerDecryptor, getWebhookPayload(formattedEvent));
workspaceId: projectId, })
environment,
secretPath,
type: hook.type
})
)
)
); );
// filter hooks by status // filter hooks by status
@@ -16,7 +16,8 @@ import {
TDeleteWebhookDTO, TDeleteWebhookDTO,
TListWebhookDTO, TListWebhookDTO,
TTestWebhookDTO, TTestWebhookDTO,
TUpdateWebhookDTO TUpdateWebhookDTO,
WebhookEvents
} from "./webhook-types"; } from "./webhook-types";
type TWebhookServiceFactoryDep = { type TWebhookServiceFactoryDep = {
@@ -144,12 +145,15 @@ export const webhookServiceFactory = ({
await triggerWebhookRequest( await triggerWebhookRequest(
webhook, webhook,
(value) => secretManagerDecryptor({ cipherTextBlob: value }).toString(), (value) => secretManagerDecryptor({ cipherTextBlob: value }).toString(),
getWebhookPayload("test", { getWebhookPayload({
workspaceName: project.name, type: "test" as WebhookEvents.SecretModified,
workspaceId: webhook.projectId, payload: {
environment: webhook.environment.slug, projectName: project.name,
secretPath: webhook.secretPath, projectId: webhook.projectId,
type: webhook.type environment: webhook.environment.slug,
secretPath: webhook.secretPath,
type: webhook.type
}
}) })
); );
} catch (err) { } catch (err) {
@@ -30,3 +30,36 @@ export enum WebhookType {
GENERAL = "general", GENERAL = "general",
SLACK = "slack" SLACK = "slack"
} }
export enum WebhookEvents {
SecretModified = "secrets.modified",
SecretReminderExpired = "secrets.reminder-expired",
TestEvent = "test"
}
type TWebhookSecretModifiedEventPayload = {
type: WebhookEvents.SecretModified;
payload: {
projectName?: string;
projectId: string;
environment: string;
secretPath?: string;
type?: string | null;
};
};
type TWebhookSecretReminderEventPayload = {
type: WebhookEvents.SecretReminderExpired;
payload: {
projectName?: string;
projectId: string;
environment: string;
secretPath?: string;
type?: string | null;
secretName: string;
secretId: string;
reminderNote?: string | null;
};
};
export type TWebhookPayloads = TWebhookSecretModifiedEventPayload | TWebhookSecretReminderEventPayload;
+17
View File
@@ -0,0 +1,17 @@
import path from "path";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
env: {
NODE_ENV: "test"
},
include: ["./src/**/*.test.ts"]
},
resolve: {
alias: {
"@app": path.resolve(__dirname, "./src")
}
}
});
+5 -5
View File
@@ -29,9 +29,9 @@ require (
github.com/spf13/cobra v1.6.1 github.com/spf13/cobra v1.6.1
github.com/spf13/viper v1.8.1 github.com/spf13/viper v1.8.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.35.0 golang.org/x/crypto v0.36.0
golang.org/x/sys v0.30.0 golang.org/x/sys v0.31.0
golang.org/x/term v0.29.0 golang.org/x/term v0.30.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )
@@ -115,8 +115,8 @@ require (
golang.org/x/mod v0.23.0 // indirect golang.org/x/mod v0.23.0 // indirect
golang.org/x/net v0.35.0 // indirect golang.org/x/net v0.35.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sync v0.11.0 // indirect golang.org/x/sync v0.12.0 // indirect
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.23.0 // indirect
golang.org/x/time v0.6.0 // indirect golang.org/x/time v0.6.0 // indirect
golang.org/x/tools v0.30.0 // indirect golang.org/x/tools v0.30.0 // indirect
google.golang.org/api v0.188.0 // indirect google.golang.org/api v0.188.0 // indirect
+10
View File
@@ -486,6 +486,8 @@ golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -592,6 +594,8 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -642,9 +646,13 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -656,6 +664,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+54 -55
View File
@@ -14,6 +14,7 @@ import (
"github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/api"
"github.com/Infisical/infisical-merge/packages/systemd" "github.com/Infisical/infisical-merge/packages/systemd"
"github.com/go-resty/resty/v2" "github.com/go-resty/resty/v2"
"github.com/pion/dtls/v3"
"github.com/pion/logging" "github.com/pion/logging"
"github.com/pion/turn/v4" "github.com/pion/turn/v4"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@@ -54,26 +55,6 @@ func (g *Gateway) ConnectWithRelay() error {
return err return err
} }
relayAddress, relayPort := strings.Split(relayDetails.TurnServerAddress, ":")[0], strings.Split(relayDetails.TurnServerAddress, ":")[1] relayAddress, relayPort := strings.Split(relayDetails.TurnServerAddress, ":")[0], strings.Split(relayDetails.TurnServerAddress, ":")[1]
var conn net.Conn
// Dial TURN Server
if relayPort == "5349" {
log.Info().Msgf("Provided relay port %s. Using TLS", relayPort)
conn, err = tls.Dial("tcp", relayDetails.TurnServerAddress, &tls.Config{
ServerName: relayAddress,
})
} else {
log.Info().Msgf("Provided relay port %s. Using non TLS connection.", relayPort)
peerAddr, errPeer := net.ResolveTCPAddr("tcp", relayDetails.TurnServerAddress)
if errPeer != nil {
return fmt.Errorf("Failed to parse turn server address: %w", err)
}
conn, err = net.DialTCP("tcp", nil, peerAddr)
}
if err != nil {
return fmt.Errorf("Failed to connect with relay server: %w", err)
}
// Start a new TURN Client and wrap our net.Conn in a STUNConn // Start a new TURN Client and wrap our net.Conn in a STUNConn
// This allows us to simulate datagram based communication over a net.Conn // This allows us to simulate datagram based communication over a net.Conn
@@ -81,17 +62,46 @@ func (g *Gateway) ConnectWithRelay() error {
if os.Getenv("LOG_LEVEL") == "debug" { if os.Getenv("LOG_LEVEL") == "debug" {
logger.DefaultLogLevel = logging.LogLevelDebug logger.DefaultLogLevel = logging.LogLevelDebug
} }
cfg := &turn.ClientConfig{
turnClientCfg := &turn.ClientConfig{
STUNServerAddr: relayDetails.TurnServerAddress, STUNServerAddr: relayDetails.TurnServerAddress,
TURNServerAddr: relayDetails.TurnServerAddress, TURNServerAddr: relayDetails.TurnServerAddress,
Conn: turn.NewSTUNConn(conn),
Username: relayDetails.TurnServerUsername, Username: relayDetails.TurnServerUsername,
Password: relayDetails.TurnServerPassword, Password: relayDetails.TurnServerPassword,
Realm: relayDetails.TurnServerRealm, Realm: relayDetails.TurnServerRealm,
LoggerFactory: logger, LoggerFactory: logger,
} }
client, err := turn.NewClient(cfg) turnAddr, err := net.ResolveUDPAddr("udp4", relayDetails.TurnServerAddress)
if err != nil {
return fmt.Errorf("Failed to parse turn server address: %w", err)
}
// Dial TURN Server
if relayPort == "5349" {
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM([]byte(g.config.CertificateChain))
log.Info().Msgf("Provided relay port %s. Using TLS", relayPort)
conn, err := dtls.Dial("udp", turnAddr, &dtls.Config{
ServerName: relayAddress,
RootCAs: caCertPool,
})
if err != nil {
return fmt.Errorf("Failed to connect with relay server: %w", err)
}
turnClientCfg.Conn = turn.NewSTUNConn(conn)
} else {
log.Info().Msgf("Provided relay port %s. Using non TLS connection.", relayPort)
conn, err := net.ListenPacket("udp4", turnAddr.String())
if err != nil {
return fmt.Errorf("Failed to connect with relay server: %w", err)
}
turnClientCfg.Conn = conn
}
client, err := turn.NewClient(turnClientCfg)
if err != nil { if err != nil {
return fmt.Errorf("Failed to create relay client: %w", err) return fmt.Errorf("Failed to create relay client: %w", err)
} }
@@ -168,7 +178,6 @@ func (g *Gateway) Listen(ctx context.Context) error {
ClientAuth: tls.RequireAndVerifyClientCert, ClientAuth: tls.RequireAndVerifyClientCert,
NextProtos: []string{"infisical-gateway"}, NextProtos: []string{"infisical-gateway"},
} }
// Setup QUIC listener on the relayConn // Setup QUIC listener on the relayConn
quicConfig := &quic.Config{ quicConfig := &quic.Config{
EnableDatagrams: true, EnableDatagrams: true,
@@ -176,7 +185,6 @@ func (g *Gateway) Listen(ctx context.Context) error {
KeepAlivePeriod: 2 * time.Second, KeepAlivePeriod: 2 * time.Second,
} }
g.registerRelayIsActive(ctx, errCh)
quicListener, err := quic.Listen(relayUdpConnection, tlsConfig, quicConfig) quicListener, err := quic.Listen(relayUdpConnection, tlsConfig, quicConfig)
if err != nil { if err != nil {
return fmt.Errorf("Failed to listen for QUIC: %w", err) return fmt.Errorf("Failed to listen for QUIC: %w", err)
@@ -185,6 +193,8 @@ func (g *Gateway) Listen(ctx context.Context) error {
log.Printf("Listener started on %s", quicListener.Addr()) log.Printf("Listener started on %s", quicListener.Addr())
g.registerRelayIsActive(ctx, quicListener.Addr().String(), errCh)
log.Info().Msg("Gateway started successfully") log.Info().Msg("Gateway started successfully")
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -320,13 +330,12 @@ func (g *Gateway) createPermissionForStaticIps(staticIps string) error {
return nil return nil
} }
func (g *Gateway) registerRelayIsActive(ctx context.Context, errCh chan error) error { func (g *Gateway) registerRelayIsActive(ctx context.Context, addr string, errCh chan error) error {
ticker := time.NewTicker(15 * time.Second) ticker := time.NewTicker(15 * time.Second)
maxFailures := 3 maxFailures := 3
failures := 0 failures := 0
log.Info().Msg("Starting relay connection health check") log.Info().Msg("Starting relay connection health check")
go func() { go func() {
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
for { for {
@@ -335,36 +344,26 @@ func (g *Gateway) registerRelayIsActive(ctx context.Context, errCh chan error) e
log.Info().Msg("Stopping relay connection health check") log.Info().Msg("Stopping relay connection health check")
return return
case <-ticker.C: case <-ticker.C:
func() { log.Debug().Msg("Performing relay connection health check")
log.Debug().Msg("Performing relay connection health check") ctxTimeout, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if g.client == nil { // Try to establish a QUIC connection
failures++ conn, err := quic.DialAddr(ctxTimeout, addr, &tls.Config{
log.Warn().Int("failures", failures).Msg("TURN client is nil") InsecureSkipVerify: false, // Skip certificate verification
if failures >= maxFailures { NextProtos: []string{"infisical-gateway"},
errCh <- fmt.Errorf("relay connection check failed: TURN client is nil") }, nil)
} if err != nil && !strings.Contains(err.Error(), "tls:") {
failures++
log.Warn().Err(err).Int("failures", failures).Msg("Failed to refresh TURN permissions")
if failures >= maxFailures {
errCh <- fmt.Errorf("relay connection check failed: %w", err)
return return
} }
continue
// we try to refresh permissions - this is a lightweight operation }
// that will fail immediately if the UDP connection is broken. good for health check if conn != nil {
log.Debug().Msg("Refreshing TURN permissions to verify connection") defer conn.CloseWithError(0, "All good")
if err := g.createPermissionForStaticIps(g.config.InfisicalStaticIp); err != nil { }
failures++
log.Warn().Err(err).Int("failures", failures).Msg("Failed to refresh TURN permissions")
if failures >= maxFailures {
errCh <- fmt.Errorf("relay connection check failed: %w", err)
}
return
}
log.Debug().Msg("Successfully refreshed TURN permissions - connection is healthy")
if failures > 0 {
log.Info().Int("previous_failures", failures).Msg("Relay connection restored")
failures = 0
}
}()
} }
} }
}() }()
+39 -41
View File
@@ -4,7 +4,6 @@
package gateway package gateway
import ( import (
"context"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"errors" "errors"
@@ -12,12 +11,13 @@ import (
"net" "net"
"os" "os"
"os/signal" "os/signal"
"runtime"
// "runtime"
"strconv" "strconv"
"syscall" "syscall"
udplistener "github.com/Infisical/infisical-merge/packages/gateway/udp_listener"
"github.com/Infisical/infisical-merge/packages/systemd" "github.com/Infisical/infisical-merge/packages/systemd"
"github.com/pion/dtls/v3"
"github.com/pion/logging" "github.com/pion/logging"
"github.com/pion/turn/v4" "github.com/pion/turn/v4"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@@ -108,7 +108,7 @@ func NewGatewayRelay(configFilePath string) (*GatewayRelay, error) {
} }
func (g *GatewayRelay) Run() error { func (g *GatewayRelay) Run() error {
addr, err := net.ResolveTCPAddr("tcp", "0.0.0.0:"+strconv.Itoa(g.Config.Port)) addr, err := net.ResolveUDPAddr("udp", "0.0.0.0:"+strconv.Itoa(g.Config.Port))
if err != nil { if err != nil {
return fmt.Errorf("Failed to parse server address: %s", err) return fmt.Errorf("Failed to parse server address: %s", err)
} }
@@ -117,13 +117,6 @@ func (g *GatewayRelay) Run() error {
// and process them yourself. // and process them yourself.
logger := logging.NewDefaultLeveledLoggerForScope("lt-creds", logging.LogLevelTrace, os.Stdout) logger := logging.NewDefaultLeveledLoggerForScope("lt-creds", logging.LogLevelTrace, os.Stdout)
// Create `numThreads` UDP listeners to pass into pion/turn
// pion/turn itself doesn't allocate any UDP sockets, but lets the user pass them in
// this allows us to add logging, storage or modify inbound/outbound traffic
// UDP listeners share the same local address:port with setting SO_REUSEPORT and the kernel
// will load-balance received packets per the IP 5-tuple
listenerConfig := udplistener.SetupListenerConfig()
publicIP := g.Config.PublicIP publicIP := g.Config.PublicIP
relayAddressGenerator := &turn.RelayAddressGeneratorPortRange{ relayAddressGenerator := &turn.RelayAddressGeneratorPortRange{
RelayAddress: net.ParseIP(publicIP), // Claim that we are listening on IP passed by user RelayAddress: net.ParseIP(publicIP), // Claim that we are listening on IP passed by user
@@ -132,49 +125,54 @@ func (g *GatewayRelay) Run() error {
MaxPort: g.Config.RelayMaxPort, MaxPort: g.Config.RelayMaxPort,
} }
threadNum := runtime.NumCPU()
listenerConfigs := make([]turn.ListenerConfig, threadNum)
var connAddress string
for i := 0; i < threadNum; i++ {
conn, listErr := listenerConfig.Listen(context.Background(), addr.Network(), addr.String())
if listErr != nil {
return fmt.Errorf("Failed to allocate TCP listener at %s:%s %s", addr.Network(), addr.String(), listErr)
}
listenerConfigs[i] = turn.ListenerConfig{
RelayAddressGenerator: relayAddressGenerator,
}
if g.Config.isTlsEnabled {
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM([]byte(g.Config.tlsCa))
listenerConfigs[i].Listener = tls.NewListener(conn, &tls.Config{
Certificates: []tls.Certificate{g.Config.tls},
ClientCAs: caCertPool,
})
} else {
listenerConfigs[i].Listener = conn
}
connAddress = conn.Addr().String()
}
loggerF := logging.NewDefaultLoggerFactory() loggerF := logging.NewDefaultLoggerFactory()
loggerF.DefaultLogLevel = logging.LogLevelDebug loggerF.DefaultLogLevel = logging.LogLevelDebug
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM([]byte(g.Config.tlsCa))
listenerConfigs := make([]turn.ListenerConfig, 0)
packetConfigs := make([]turn.PacketConnConfig, 0)
if g.Config.isTlsEnabled {
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM([]byte(g.Config.tlsCa))
dtlsServer, err := dtls.Listen("udp", addr, &dtls.Config{
Certificates: []tls.Certificate{g.Config.tls},
ClientCAs: caCertPool,
})
if err != nil {
return fmt.Errorf("Failed to start dtls server: %w", err)
}
listenerConfigs = append(listenerConfigs, turn.ListenerConfig{
RelayAddressGenerator: relayAddressGenerator,
Listener: dtlsServer,
})
} else {
udpListener, err := net.ListenPacket("udp4", "0.0.0.0:"+strconv.Itoa(g.Config.Port))
if err != nil {
return fmt.Errorf("Failed to relay udp listener: %w", err)
}
packetConfigs = append(packetConfigs, turn.PacketConnConfig{
RelayAddressGenerator: relayAddressGenerator,
PacketConn: udpListener,
})
}
server, err := turn.NewServer(turn.ServerConfig{ server, err := turn.NewServer(turn.ServerConfig{
Realm: g.Config.Realm, Realm: g.Config.Realm,
AuthHandler: turn.LongTermTURNRESTAuthHandler(g.Config.AuthSecret, logger), AuthHandler: turn.LongTermTURNRESTAuthHandler(g.Config.AuthSecret, logger),
// PacketConnConfigs is a list of UDP Listeners and the configuration around them // PacketConnConfigs is a list of UDP Listeners and the configuration around them
ListenerConfigs: listenerConfigs, ListenerConfigs: listenerConfigs,
LoggerFactory: loggerF, PacketConnConfigs: packetConfigs,
LoggerFactory: loggerF,
}) })
if err != nil { if err != nil {
return fmt.Errorf("Failed to start server: %w", err) return fmt.Errorf("Failed to start server: %w", err)
} }
log.Info().Msgf("Relay listening on %s\n", connAddress) log.Info().Msgf("Relay listening on %d\n", g.Config.Port)
// make this compatiable with systemd notify mode // make this compatiable with systemd notify mode
systemd.SdNotify(false, systemd.SdNotifyReady) systemd.SdNotify(false, systemd.SdNotifyReady)
+5 -1
View File
@@ -10,6 +10,10 @@ Being a remote-first company, we try to be as async as possible. When an issue a
In other words, we have almost no (recurring) meetings and prefer written communication or quick Slack huddles. In other words, we have almost no (recurring) meetings and prefer written communication or quick Slack huddles.
## Daily Standup
Towards the end of each day, everyone on the Engineering and GTM teams should document their progress in the respective Slack standup channels, ensuring the team stays informed of important updates. On the engineering side, if you are working on something that takes longer than 1-2 days, please add an estimated completion date (ECD) for that item in standup specifying when it will be pushed to production.
## Weekly All-hands ## Weekly All-hands
All-hands is the single recurring meeting that we run every Monday at 8:30am PT. Typically, we would discuss everything important that happened during the previous week and plan out the week ahead. This is also an opportunity to bring up any important topics in front of the whole company (but feel free to post those in Slack too). All-hands is the single recurring meeting that we run every Monday at 8:00am PT. Typically, we would discuss everything important that happened during the previous week and plan out the week ahead. This is also an opportunity to bring up any important topics in front of the whole company (but feel free to post those in Slack too).
@@ -0,0 +1,110 @@
---
title: "Gateway Security Architecture"
sidebarTitle: "Architecture"
description: "Understand the security model and tenant isolation of Infisical's Gateway"
---
# Gateway Security Architecture
The Infisical Gateway enables Infisical Cloud to securely interact with private resources using mutual TLS authentication and private PKI (Public Key Infrastructure) system to ensure secure, isolated communication between multiple tenants.
This document explains the internal security architecture and how tenant isolation is maintained.
## Security Model Overview
### Private PKI System
Each organization (tenant) in Infisical has its own private PKI system consisting of:
1. **Root CA**: The ultimate trust anchor for the organization
2. **Intermediate CAs**:
- Client CA: Issues certificates for cloud components
- Gateway CA: Issues certificates for gateway instances
This hierarchical structure ensures complete isolation between organizations as each has its own independent certificate chain.
### Certificate Hierarchy
```
Root CA (Organization Specific)
├── Client CA
│ └── Client Certificates (Cloud Components)
└── Gateway CA
└── Gateway Certificates (Gateway Instances)
```
## Communication Security
### 1. Gateway Registration
When a gateway is first deployed:
1. Establishes initial connection using machine identity token
2. Allocates a relay address for communication
3. Exchanges certificates through a secure handshake:
- Gateway receives a unique certificate signed by organization's Gateway CA along with certificate chain for verification
### 2. Mutual TLS Authentication
All communication between gateway and cloud uses mutual TLS (mTLS):
- **Gateway Authentication**:
- Presents certificate signed by organization's Gateway CA
- Certificate contains unique identifiers (Organization ID, Gateway ID)
- Cloud validates complete certificate chain
- **Cloud Authentication**:
- Presents certificate signed by organization's Client CA
- Certificate includes required organizational unit ("gateway-client")
- Gateway validates certificate chain back to organization's root CA
### 3. Relay Communication
The relay system provides secure tunneling:
1. **Connection Establishment**:
- Uses QUIC protocol over UDP for efficient, secure communication
- Provides built-in encryption, congestion control, and multiplexing
- Enables faster connection establishment and reduced latency
- Each organization's traffic is isolated using separate relay sessions
2. **Traffic Isolation**:
- Each gateway gets unique relay credentials
- Traffic is end-to-end encrypted using QUIC's TLS 1.3
- Organization's private keys never leave their environment
## Tenant Isolation
### Certificate-Based Isolation
- Each organization has unique root CA and intermediate CAs
- Certificates contain organization-specific identifiers
- Cross-tenant communication is cryptographically impossible
### Gateway-Project Mapping
- Gateways are explicitly mapped to specific projects
- Access controls enforce organization boundaries
- Project-level permissions determine resource accessibility
### Resource Access Control
1. **Project Verification**:
- Gateway verifies project membership
- Validates organization ownership
- Enforces project-level permissions
2. **Resource Restrictions**:
- Gateways only accept connections to approved resources
- Each connection requires explicit project authorization
- Resources remain private to their assigned organization
## Security Measures
### Certificate Lifecycle
- Certificates have limited validity periods
- Automatic certificate rotation
- Immediate certificate revocation capabilities
### Monitoring and Verification
1. **Continuous Verification**:
- Regular heartbeat checks
- Certificate chain validation
- Connection state monitoring
2. **Security Controls**:
- Automatic connection termination on verification failure
- Audit logging of all access attempts
- Machine identity based authentication
+15
View File
@@ -36,3 +36,18 @@ If the signature in the header matches the signature that you generated, then yo
"timestamp": "" "timestamp": ""
} }
``` ```
```json
{
"event": "secrets.reminder-expired",
"project": {
"workspaceId": "the workspace id",
"environment": "project environment",
"secretPath": "project folder path",
"secretName": "name of the secret",
"secretId": "id of the secret",
"reminderNote": "reminder note of the secret"
},
"timestamp": ""
}
```
+1 -1
View File
@@ -203,7 +203,7 @@
}, },
{ {
"group": "Gateway", "group": "Gateway",
"pages": ["documentation/platform/gateways/overview"] "pages": ["documentation/platform/gateways/overview", "documentation/platform/gateways/gateway-security"]
}, },
"documentation/platform/project-templates", "documentation/platform/project-templates",
{ {
@@ -9,9 +9,7 @@ import { Button, FormControl, Input, TextArea } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useUpdateProject } from "@app/hooks/api"; import { useUpdateProject } from "@app/hooks/api";
import { CopyButton } from "./CopyButton"; const baseFormSchema = z.object({
const formSchema = z.object({
name: z.string().min(1, "Required").max(64, "Too long, maximum length is 64 characters"), name: z.string().min(1, "Required").max(64, "Too long, maximum length is 64 characters"),
description: z description: z
.string() .string()
@@ -20,31 +18,55 @@ const formSchema = z.object({
.optional() .optional()
}); });
type FormData = z.infer<typeof formSchema>; const formSchemaWithSlug = baseFormSchema.extend({
slug: z
.string()
.min(1, "Required")
.max(64, "Too long, maximum length is 64 characters")
.regex(
/^[a-z0-9]+(?:[_-][a-z0-9]+)*$/,
"Project slug can only contain lowercase letters and numbers, with optional single hyphens (-) or underscores (_) between words. Cannot start or end with a hyphen or underscore."
)
});
export const ProjectOverviewChangeSection = () => { type BaseFormData = z.infer<typeof baseFormSchema>;
type FormDataWithSlug = z.infer<typeof formSchemaWithSlug>;
type Props = {
showSlugField?: boolean;
};
export const ProjectOverviewChangeSection = ({ showSlugField = false }: Props) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { mutateAsync, isPending } = useUpdateProject(); const { mutateAsync, isPending } = useUpdateProject();
const { handleSubmit, control, reset, watch } = useForm<BaseFormData | FormDataWithSlug>({
resolver: zodResolver(showSlugField ? formSchemaWithSlug : baseFormSchema)
});
const { handleSubmit, control, reset } = useForm<FormData>({ resolver: zodResolver(formSchema) }); const currentSlug = showSlugField ? watch("slug") : currentWorkspace?.slug;
useEffect(() => { useEffect(() => {
if (currentWorkspace) { if (currentWorkspace) {
reset({ reset({
name: currentWorkspace.name, name: currentWorkspace.name,
description: currentWorkspace.description ?? "" description: currentWorkspace.description ?? "",
...(showSlugField && { slug: currentWorkspace.slug })
}); });
} }
}, [currentWorkspace]); }, [currentWorkspace, showSlugField]);
const onFormSubmit = async ({ name, description }: FormData) => { const onFormSubmit = async (data: BaseFormData | FormDataWithSlug) => {
try { try {
if (!currentWorkspace?.id) return; if (!currentWorkspace?.id) return;
await mutateAsync({ await mutateAsync({
projectID: currentWorkspace.id, projectID: currentWorkspace.id,
newProjectName: name, newProjectName: data.name,
newProjectDescription: description newProjectDescription: data.description,
...(showSlugField &&
"slug" in data && {
newSlug: data.slug !== currentWorkspace.slug ? data.slug : undefined
})
}); });
createNotification({ createNotification({
@@ -65,20 +87,34 @@ export const ProjectOverviewChangeSection = () => {
<div className="justify-betweens flex"> <div className="justify-betweens flex">
<h2 className="mb-8 flex-1 text-xl font-semibold text-mineshaft-100">Project Overview</h2> <h2 className="mb-8 flex-1 text-xl font-semibold text-mineshaft-100">Project Overview</h2>
<div className="space-x-2"> <div className="space-x-2">
<CopyButton <Button
value={currentWorkspace?.slug || ""} variant="outline_bg"
hoverText="Click to project slug" size="sm"
notificationText="Copied project slug to clipboard" onClick={() => {
navigator.clipboard.writeText(currentSlug || "");
createNotification({
text: "Copied project slug to clipboard",
type: "success"
});
}}
title="Click to copy project slug"
> >
Copy Project Slug Copy Project Slug
</CopyButton> </Button>
<CopyButton <Button
value={currentWorkspace?.id || ""} variant="outline_bg"
hoverText="Click to project ID" size="sm"
notificationText="Copied project ID to clipboard" onClick={() => {
navigator.clipboard.writeText(currentWorkspace?.id || "");
createNotification({
text: "Copied project ID to clipboard",
type: "success"
});
}}
title="Click to copy project ID"
> >
Copy Project ID Copy Project ID
</CopyButton> </Button>
</div> </div>
</div> </div>
<div> <div>
@@ -113,6 +149,38 @@ export const ProjectOverviewChangeSection = () => {
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
</div> </div>
{showSlugField && (
<div className="flex w-full flex-row items-end gap-4">
<div className="w-full max-w-md">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project slug"
>
<Input
placeholder="Project slug"
{...field}
className="bg-mineshaft-800"
isDisabled={!isAllowed}
/>
</FormControl>
)}
control={control}
name="slug"
/>
)}
</ProjectPermissionCan>
</div>
</div>
)}
<div className="flex w-full flex-row items-end gap-4"> <div className="flex w-full flex-row items-end gap-4">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<ProjectPermissionCan <ProjectPermissionCan
@@ -14,7 +14,6 @@ import {
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
Button, Button,
Checkbox,
FormControl, FormControl,
Input, Input,
Modal, Modal,
@@ -33,13 +32,7 @@ import {
useUser useUser
} from "@app/context"; } from "@app/context";
import { getProjectHomePage } from "@app/helpers/project"; import { getProjectHomePage } from "@app/helpers/project";
import { import { useCreateWorkspace, useGetExternalKmsList, useGetUserWorkspaces } from "@app/hooks/api";
fetchOrgUsers,
useAddUserToWsNonE2EE,
useCreateWorkspace,
useGetExternalKmsList,
useGetUserWorkspaces
} from "@app/hooks/api";
import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types"; import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types";
import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates"; import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates";
import { ProjectType } from "@app/hooks/api/workspace/types"; import { ProjectType } from "@app/hooks/api/workspace/types";
@@ -51,7 +44,6 @@ const formSchema = z.object({
.trim() .trim()
.max(256, "Description too long, max length is 256 characters") .max(256, "Description too long, max length is 256 characters")
.optional(), .optional(),
addMembers: z.boolean(),
kmsKeyId: z.string(), kmsKeyId: z.string(),
template: z.string() template: z.string()
}); });
@@ -73,7 +65,6 @@ const NewProjectForm = ({ onOpenChange, projectType }: NewProjectFormProps) => {
const { user } = useUser(); const { user } = useUser();
const createWs = useCreateWorkspace(); const createWs = useCreateWorkspace();
const { refetch: refetchWorkspaces } = useGetUserWorkspaces(); const { refetch: refetchWorkspaces } = useGetUserWorkspaces();
const addUsersToProject = useAddUserToWsNonE2EE();
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const canReadProjectTemplates = permission.can( const canReadProjectTemplates = permission.can(
@@ -111,7 +102,6 @@ const NewProjectForm = ({ onOpenChange, projectType }: NewProjectFormProps) => {
const onCreateProject = async ({ const onCreateProject = async ({
name, name,
description, description,
addMembers,
kmsKeyId, kmsKeyId,
template template
}: TAddProjectFormData) => { }: TAddProjectFormData) => {
@@ -128,21 +118,6 @@ const NewProjectForm = ({ onOpenChange, projectType }: NewProjectFormProps) => {
template, template,
type: projectType type: projectType
}); });
const { id: newProjectId } = project;
if (addMembers) {
const orgUsers = await fetchOrgUsers(currentOrg.id);
await addUsersToProject.mutateAsync({
usernames: orgUsers
.filter(
(member) => member.user.username !== user.username && member.status === "accepted"
)
.map((member) => member.user.username),
projectId: newProjectId,
orgId: currentOrg.id
});
}
await refetchWorkspaces(); await refetchWorkspaces();
createNotification({ text: "Project created", type: "success" }); createNotification({ text: "Project created", type: "success" });
@@ -246,31 +221,7 @@ const NewProjectForm = ({ onOpenChange, projectType }: NewProjectFormProps) => {
)} )}
/> />
</div> </div>
<div className="mt-4 pl-1"> <div className="mt-4 flex">
<Controller
control={control}
name="addMembers"
defaultValue={false}
render={({ field: { onBlur, value, onChange } }) => (
<OrgPermissionCan I={OrgPermissionActions.Read} a={OrgPermissionSubjects.Member}>
{(isAllowed) => (
<div>
<Checkbox
id="add-project-layout"
isChecked={value}
onCheckedChange={onChange}
isDisabled={!isAllowed}
onBlur={onBlur}
>
Add all members of my organization to this project
</Checkbox>
</div>
)}
</OrgPermissionCan>
)}
/>
</div>
<div className="mt-14 flex">
<Accordion type="single" collapsible className="w-full"> <Accordion type="single" collapsible className="w-full">
<AccordionItem value="advance-settings" className="data-[state=open]:border-none"> <AccordionItem value="advance-settings" className="data-[state=open]:border-none">
<AccordionTrigger className="h-fit flex-none pl-1 text-sm"> <AccordionTrigger className="h-fit flex-none pl-1 text-sm">
+101
View File
@@ -74,6 +74,107 @@ export const queryClient = new QueryClient({
); );
return; return;
} }
if (serverResponse?.error === ApiErrorTypes.PermissionBoundaryError) {
createNotification(
{
title: "Forbidden Access",
type: "error",
text: `${serverResponse.message}.`,
callToAction: serverResponse?.details?.missingPermissions?.length ? (
<Modal>
<ModalTrigger asChild>
<Button variant="outline_bg" size="xs">
Show more
</Button>
</ModalTrigger>
<ModalContent title="Missing Permission">
<div className="flex flex-col gap-2">
{serverResponse.details?.missingPermissions?.map((el, index) => {
const hasConditions = Boolean(Object.keys(el.conditions || {}).length);
return (
<div
key={`Forbidden-error-details-${index + 1}`}
className="rounded-md border border-gray-600 p-4"
>
<div>
You are not authorized to perform the <b>{el.action}</b> action on the{" "}
<b>{el.subject}</b> resource.{" "}
{hasConditions &&
"Your permission does not allow access to the following conditions:"}
</div>
{hasConditions && (
<ul className="flex list-disc flex-col gap-1 pl-5 pt-2 text-sm">
{Object.keys(el.conditions || {}).flatMap((field, fieldIndex) => {
const operators = (
el.conditions as Record<
string,
| string
| { [K in PermissionConditionOperators]: string | string[] }
>
)[field];
const formattedFieldName = camelCaseToSpaces(field).toLowerCase();
if (typeof operators === "string") {
return (
<li
key={`Forbidden-error-details-${index + 1}-${
fieldIndex + 1
}`}
>
<span className="font-bold capitalize">
{formattedFieldName}
</span>{" "}
<span className="text-mineshaft-200">equal to</span>{" "}
<span className="text-yellow-600">{operators}</span>
</li>
);
}
return Object.keys(operators).map((operator, operatorIndex) => (
<li
key={`Forbidden-error-details-${index + 1}-${
fieldIndex + 1
}-${operatorIndex + 1}`}
>
<span className="font-bold capitalize">
{formattedFieldName}
</span>{" "}
<span className="text-mineshaft-200">
{
formatedConditionsOperatorNames[
operator as PermissionConditionOperators
]
}
</span>{" "}
<span className="text-yellow-600">
{operators[
operator as PermissionConditionOperators
].toString()}
</span>
</li>
));
})}
</ul>
)}
</div>
);
})}
</div>
</ModalContent>
</Modal>
) : undefined,
copyActions: [
{
value: serverResponse.reqId,
name: "Request ID",
label: `Request ID: ${serverResponse.reqId}`
}
]
},
{ closeOnClick: false }
);
return;
}
if (serverResponse?.error === ApiErrorTypes.ForbiddenError) { if (serverResponse?.error === ApiErrorTypes.ForbiddenError) {
createNotification( createNotification(
{ {
+14
View File
@@ -44,6 +44,7 @@ export type {
export enum ApiErrorTypes { export enum ApiErrorTypes {
ValidationError = "ValidationFailure", ValidationError = "ValidationFailure",
PermissionBoundaryError = "PermissionBoundaryError",
BadRequestError = "BadRequest", BadRequestError = "BadRequest",
UnauthorizedError = "UnauthorizedError", UnauthorizedError = "UnauthorizedError",
ForbiddenError = "PermissionDenied" ForbiddenError = "PermissionDenied"
@@ -74,4 +75,17 @@ export type TApiErrors =
statusCode: 400; statusCode: 400;
message: string; message: string;
error: ApiErrorTypes.BadRequestError; error: ApiErrorTypes.BadRequestError;
}
| {
reqId: string;
statusCode: 403;
message: string;
error: ApiErrorTypes.PermissionBoundaryError;
details: {
missingPermissions: {
action: string;
subject: string;
conditions: Record<string, Record<string, string>>;
}[];
};
}; };
+3 -2
View File
@@ -251,12 +251,13 @@ export const useUpdateProject = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<Workspace, object, UpdateProjectDTO>({ return useMutation<Workspace, object, UpdateProjectDTO>({
mutationFn: async ({ projectID, newProjectName, newProjectDescription }) => { mutationFn: async ({ projectID, newProjectName, newProjectDescription, newSlug }) => {
const { data } = await apiRequest.patch<{ workspace: Workspace }>( const { data } = await apiRequest.patch<{ workspace: Workspace }>(
`/api/v1/workspace/${projectID}`, `/api/v1/workspace/${projectID}`,
{ {
name: newProjectName, name: newProjectName,
description: newProjectDescription description: newProjectDescription,
slug: newSlug
} }
); );
return data.workspace; return data.workspace;
@@ -74,6 +74,7 @@ export type UpdateProjectDTO = {
projectID: string; projectID: string;
newProjectName: string; newProjectName: string;
newProjectDescription?: string; newProjectDescription?: string;
newSlug?: string;
}; };
export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number }; export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number };
@@ -1,11 +1,12 @@
import { ProjectOverviewChangeSection } from "@app/components/project/ProjectOverviewChangeSection";
import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection"; import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection";
import { DeleteProjectSection } from "../DeleteProjectSection"; import { DeleteProjectSection } from "../DeleteProjectSection";
import { ProjectOverviewChangeSection } from "../ProjectOverviewChangeSection";
export const ProjectGeneralTab = () => { export const ProjectGeneralTab = () => {
return ( return (
<div> <div>
<ProjectOverviewChangeSection /> <ProjectOverviewChangeSection showSlugField />
<AuditLogsRetentionSection /> <AuditLogsRetentionSection />
<DeleteProjectSection /> <DeleteProjectSection />
</div> </div>
@@ -1,51 +0,0 @@
import { useCallback } from "react";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button } from "@app/components/v2";
import { useToggle } from "@app/hooks";
type Props = {
value: string;
hoverText: string;
notificationText: string;
children: React.ReactNode;
};
export const CopyButton = ({ value, children, hoverText, notificationText }: Props) => {
const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false);
const copyToClipboard = useCallback(() => {
if (isProjectIdCopied) {
return;
}
setIsProjectIdCopied.on();
navigator.clipboard.writeText(value);
createNotification({
text: notificationText,
type: "success"
});
const timer = setTimeout(() => setIsProjectIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
}, [isProjectIdCopied]);
return (
<Button
colorSchema="secondary"
className="group relative"
leftIcon={<FontAwesomeIcon icon={isProjectIdCopied ? faCheck : faCopy} />}
onClick={copyToClipboard}
>
{children}
<span className="absolute -left-8 -top-20 hidden translate-y-full justify-center rounded-md bg-bunker-800 px-3 py-2 text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{hoverText}
</span>
</Button>
);
};
@@ -1,168 +0,0 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input, TextArea } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useUpdateProject } from "@app/hooks/api";
import { CopyButton } from "./CopyButton";
const formSchema = z.object({
name: z.string().min(1, "Required").max(64, "Too long, maximum length is 64 characters"),
description: z
.string()
.trim()
.max(256, "Description too long, max length is 256 characters")
.optional()
});
type FormData = z.infer<typeof formSchema>;
export const ProjectOverviewChangeSection = () => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync, isPending } = useUpdateProject();
const { handleSubmit, control, reset } = useForm<FormData>({ resolver: zodResolver(formSchema) });
useEffect(() => {
if (currentWorkspace) {
reset({
name: currentWorkspace.name,
description: currentWorkspace.description ?? ""
});
}
}, [currentWorkspace]);
const onFormSubmit = async ({ name, description }: FormData) => {
try {
if (!currentWorkspace?.id) return;
await mutateAsync({
projectID: currentWorkspace.id,
newProjectName: name,
newProjectDescription: description
});
createNotification({
text: "Successfully updated project overview",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update project overview",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="justify-betweens flex">
<h2 className="mb-8 flex-1 text-xl font-semibold text-mineshaft-100">Project Overview</h2>
<div className="space-x-2">
<CopyButton
value={currentWorkspace?.slug || ""}
hoverText="Click to project slug"
notificationText="Copied project slug to clipboard"
>
Copy Project Slug
</CopyButton>
<CopyButton
value={currentWorkspace?.id || ""}
hoverText="Click to project ID"
notificationText="Copied project ID to clipboard"
>
Copy Project ID
</CopyButton>
</div>
</div>
<div>
<form onSubmit={handleSubmit(onFormSubmit)} className="flex w-full flex-col gap-0">
<div className="flex w-full flex-row items-end gap-4">
<div className="w-full max-w-md">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project name"
>
<Input
placeholder="Project name"
{...field}
className="bg-mineshaft-800"
isDisabled={!isAllowed}
/>
</FormControl>
)}
control={control}
name="name"
/>
)}
</ProjectPermissionCan>
</div>
</div>
<div className="flex w-full flex-row items-end gap-4">
<div className="w-full max-w-md">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project description"
>
<TextArea
placeholder="Project description"
{...field}
rows={3}
className="thin-scrollbar max-w-md !resize-none bg-mineshaft-800"
isDisabled={!isAllowed}
/>
</FormControl>
)}
control={control}
name="description"
/>
)}
</ProjectPermissionCan>
</div>
</div>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
isLoading={isPending}
isDisabled={isPending || !isAllowed}
>
Save
</Button>
)}
</ProjectPermissionCan>
</div>
</form>
</div>
</div>
);
};
@@ -1 +0,0 @@
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";
@@ -1,2 +1 @@
export { DeleteProjectSection } from "./DeleteProjectSection"; export { DeleteProjectSection } from "./DeleteProjectSection";
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";
@@ -1,11 +1,12 @@
import { ProjectOverviewChangeSection } from "@app/components/project/ProjectOverviewChangeSection";
import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection"; import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection";
import { DeleteProjectSection } from "../DeleteProjectSection"; import { DeleteProjectSection } from "../DeleteProjectSection";
import { ProjectOverviewChangeSection } from "../ProjectOverviewChangeSection";
export const ProjectGeneralTab = () => { export const ProjectGeneralTab = () => {
return ( return (
<div> <div>
<ProjectOverviewChangeSection /> <ProjectOverviewChangeSection showSlugField />
<AuditLogsRetentionSection /> <AuditLogsRetentionSection />
<DeleteProjectSection /> <DeleteProjectSection />
</div> </div>
@@ -1,51 +0,0 @@
import { useCallback } from "react";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button } from "@app/components/v2";
import { useToggle } from "@app/hooks";
type Props = {
value: string;
hoverText: string;
notificationText: string;
children: React.ReactNode;
};
export const CopyButton = ({ value, children, hoverText, notificationText }: Props) => {
const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false);
const copyToClipboard = useCallback(() => {
if (isProjectIdCopied) {
return;
}
setIsProjectIdCopied.on();
navigator.clipboard.writeText(value);
createNotification({
text: notificationText,
type: "success"
});
const timer = setTimeout(() => setIsProjectIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
}, [isProjectIdCopied]);
return (
<Button
colorSchema="secondary"
className="group relative"
leftIcon={<FontAwesomeIcon icon={isProjectIdCopied ? faCheck : faCopy} />}
onClick={copyToClipboard}
>
{children}
<span className="absolute -left-8 -top-20 hidden translate-y-full justify-center rounded-md bg-bunker-800 px-3 py-2 text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{hoverText}
</span>
</Button>
);
};
@@ -1 +0,0 @@
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";
@@ -1,2 +1 @@
export { DeleteProjectSection } from "./DeleteProjectSection"; export { DeleteProjectSection } from "./DeleteProjectSection";
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";
@@ -36,7 +36,7 @@ const formSchema = z.object({
revocationStatement: z.string().min(1), revocationStatement: z.string().min(1),
renewStatement: z.string().optional(), renewStatement: z.string().optional(),
ca: z.string().optional(), ca: z.string().optional(),
projectGatewayId: z.string().optional() projectGatewayId: z.string().optional().nullable()
}) })
.partial(), .partial(),
defaultTTL: z.string().superRefine((val, ctx) => { defaultTTL: z.string().superRefine((val, ctx) => {
@@ -207,7 +207,7 @@ export const EditDynamicSecretSqlProviderForm = ({
helperText="" helperText=""
> >
<Select <Select
value={value} value={value || undefined}
onValueChange={onChange} onValueChange={onChange}
className="w-full border border-mineshaft-500" className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none" dropdownContainerClassName="max-w-none"
@@ -5,6 +5,7 @@ import {
faArrowRotateRight, faArrowRotateRight,
faCheckCircle, faCheckCircle,
faClock, faClock,
faCopy,
faDesktop, faDesktop,
faEyeSlash, faEyeSlash,
faPlus, faPlus,
@@ -462,7 +463,7 @@ export const SecretDetailSidebar = ({
<div <div
className={`grid auto-cols-min grid-flow-col gap-2 overflow-hidden ${tagFields.fields.length > 0 ? "pt-2" : ""}`} className={`grid auto-cols-min grid-flow-col gap-2 overflow-hidden ${tagFields.fields.length > 0 ? "pt-2" : ""}`}
> >
{tagFields.fields.map(({ tagColor, id: formId, slug, id }) => ( {tagFields.fields.map(({ tagColor, id: formId, slug }) => (
<Tag <Tag
className="flex w-min items-center space-x-2" className="flex w-min items-center space-x-2"
key={formId} key={formId}
@@ -471,7 +472,8 @@ export const SecretDetailSidebar = ({
createNotification({ type: "error", text: "Access denied" }); createNotification({ type: "error", text: "Access denied" });
return; return;
} }
const tag = tags?.find(({ id: tagId }) => id === tagId);
const tag = tags?.find(({ slug: tagSlug }) => slug === tagSlug);
if (tag) handleTagSelect(tag); if (tag) handleTagSelect(tag);
}} }}
> >
@@ -990,29 +992,49 @@ export const SecretDetailSidebar = ({
</Button> </Button>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan <div className="flex items-center gap-2">
I={ProjectPermissionActions.Delete} <Tooltip content="Copy Secret ID">
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: secretKey,
secretTags: selectTagSlugs
})}
>
{(isAllowed) => (
<IconButton <IconButton
colorSchema="danger" variant="outline_bg"
ariaLabel="Delete Secret" ariaLabel="Copy Secret ID"
className="border border-mineshaft-600 bg-mineshaft-700 hover:border-red-500/70 hover:bg-red-600/20" onClick={async () => {
isDisabled={!isAllowed} await navigator.clipboard.writeText(secret.id);
onClick={onDeleteSecret}
createNotification({
title: "Secret ID Copied",
text: "The secret ID has been copied to your clipboard.",
type: "success"
});
}}
> >
<Tooltip content="Delete Secret"> <FontAwesomeIcon icon={faCopy} />
<FontAwesomeIcon icon={faTrash} />
</Tooltip>
</IconButton> </IconButton>
)} </Tooltip>
</ProjectPermissionCan> <ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: secretKey,
secretTags: selectTagSlugs
})}
>
{(isAllowed) => (
<Tooltip content="Delete Secret">
<IconButton
colorSchema="danger"
variant="outline_bg"
ariaLabel="Delete Secret"
className="border border-mineshaft-600 bg-mineshaft-700 hover:border-red-500/70 hover:bg-red-600/20"
isDisabled={!isAllowed}
onClick={onDeleteSecret}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
)}
</ProjectPermissionCan>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -241,8 +241,8 @@ export const SecretListView = ({
let successMessage; let successMessage;
if (isReminderEvent) { if (isReminderEvent) {
successMessage = reminderRepeatDays successMessage = reminderRepeatDays
? "Successfully saved secret reminder" ? "Successfully saved secret reminder"
: "Successfully deleted secret reminder"; : "Successfully deleted secret reminder";
} else { } else {
successMessage = "Successfully saved secrets"; successMessage = "Successfully saved secrets";
@@ -1,3 +1,4 @@
import { ProjectOverviewChangeSection } from "@app/components/project/ProjectOverviewChangeSection";
import { useWorkspace } from "@app/context"; import { useWorkspace } from "@app/context";
import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types"; import { ProjectType, ProjectVersion } from "@app/hooks/api/workspace/types";
@@ -7,7 +8,6 @@ import { BackfillSecretReferenceSecretion } from "../BackfillSecretReferenceSect
import { DeleteProjectSection } from "../DeleteProjectSection"; import { DeleteProjectSection } from "../DeleteProjectSection";
import { EnvironmentSection } from "../EnvironmentSection"; import { EnvironmentSection } from "../EnvironmentSection";
import { PointInTimeVersionLimitSection } from "../PointInTimeVersionLimitSection"; import { PointInTimeVersionLimitSection } from "../PointInTimeVersionLimitSection";
import { ProjectOverviewChangeSection } from "../ProjectOverviewChangeSection";
import { RebuildSecretIndicesSection } from "../RebuildSecretIndicesSection/RebuildSecretIndicesSection"; import { RebuildSecretIndicesSection } from "../RebuildSecretIndicesSection/RebuildSecretIndicesSection";
import { SecretTagsSection } from "../SecretTagsSection"; import { SecretTagsSection } from "../SecretTagsSection";
@@ -17,7 +17,7 @@ export const ProjectGeneralTab = () => {
return ( return (
<div> <div>
<ProjectOverviewChangeSection /> <ProjectOverviewChangeSection showSlugField />
{isSecretManager && <EnvironmentSection />} {isSecretManager && <EnvironmentSection />}
{isSecretManager && <SecretTagsSection />} {isSecretManager && <SecretTagsSection />}
{isSecretManager && <AutoCapitalizationSection />} {isSecretManager && <AutoCapitalizationSection />}
@@ -1,51 +0,0 @@
import { useCallback } from "react";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button } from "@app/components/v2";
import { useToggle } from "@app/hooks";
type Props = {
value: string;
hoverText: string;
notificationText: string;
children: React.ReactNode;
};
export const CopyButton = ({ value, children, hoverText, notificationText }: Props) => {
const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false);
const copyToClipboard = useCallback(() => {
if (isProjectIdCopied) {
return;
}
setIsProjectIdCopied.on();
navigator.clipboard.writeText(value);
createNotification({
text: notificationText,
type: "success"
});
const timer = setTimeout(() => setIsProjectIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
}, [isProjectIdCopied]);
return (
<Button
colorSchema="secondary"
className="group relative"
leftIcon={<FontAwesomeIcon icon={isProjectIdCopied ? faCheck : faCopy} />}
onClick={copyToClipboard}
>
{children}
<span className="absolute -left-8 -top-20 hidden translate-y-full justify-center rounded-md bg-bunker-800 px-3 py-2 text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{hoverText}
</span>
</Button>
);
};
@@ -1,168 +0,0 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input, TextArea } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useUpdateProject } from "@app/hooks/api";
import { CopyButton } from "./CopyButton";
const formSchema = z.object({
name: z.string().min(1, "Required").max(64, "Too long, maximum length is 64 characters"),
description: z
.string()
.trim()
.max(256, "Description too long, max length is 256 characters")
.optional()
});
type FormData = z.infer<typeof formSchema>;
export const ProjectOverviewChangeSection = () => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync, isPending } = useUpdateProject();
const { handleSubmit, control, reset } = useForm<FormData>({ resolver: zodResolver(formSchema) });
useEffect(() => {
if (currentWorkspace) {
reset({
name: currentWorkspace.name,
description: currentWorkspace.description ?? ""
});
}
}, [currentWorkspace]);
const onFormSubmit = async ({ name, description }: FormData) => {
try {
if (!currentWorkspace?.id) return;
await mutateAsync({
projectID: currentWorkspace.id,
newProjectName: name,
newProjectDescription: description
});
createNotification({
text: "Successfully updated project overview",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update project overview",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="justify-betweens flex">
<h2 className="mb-8 flex-1 text-xl font-semibold text-mineshaft-100">Project Overview</h2>
<div className="space-x-2">
<CopyButton
value={currentWorkspace?.slug || ""}
hoverText="Click to project slug"
notificationText="Copied project slug to clipboard"
>
Copy Project Slug
</CopyButton>
<CopyButton
value={currentWorkspace?.id || ""}
hoverText="Click to project ID"
notificationText="Copied project ID to clipboard"
>
Copy Project ID
</CopyButton>
</div>
</div>
<div>
<form onSubmit={handleSubmit(onFormSubmit)} className="flex w-full flex-col gap-0">
<div className="flex w-full flex-row items-end gap-4">
<div className="w-full max-w-md">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project name"
>
<Input
placeholder="Project name"
{...field}
className="bg-mineshaft-800"
isDisabled={!isAllowed}
/>
</FormControl>
)}
control={control}
name="name"
/>
)}
</ProjectPermissionCan>
</div>
</div>
<div className="flex w-full flex-row items-end gap-4">
<div className="w-full max-w-md">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project description"
>
<TextArea
placeholder="Project description"
{...field}
rows={3}
className="thin-scrollbar max-w-md !resize-none bg-mineshaft-800"
isDisabled={!isAllowed}
/>
</FormControl>
)}
control={control}
name="description"
/>
)}
</ProjectPermissionCan>
</div>
</div>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
isLoading={isPending}
isDisabled={isPending || !isAllowed}
>
Save
</Button>
)}
</ProjectPermissionCan>
</div>
</form>
</div>
</div>
);
};
@@ -1 +0,0 @@
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";
@@ -2,5 +2,4 @@ export { AutoCapitalizationSection } from "./AutoCapitalizationSection";
export { BackfillSecretReferenceSecretion } from "./BackfillSecretReferenceSection"; export { BackfillSecretReferenceSecretion } from "./BackfillSecretReferenceSection";
export { DeleteProjectSection } from "./DeleteProjectSection"; export { DeleteProjectSection } from "./DeleteProjectSection";
export { EnvironmentSection } from "./EnvironmentSection"; export { EnvironmentSection } from "./EnvironmentSection";
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";
export { SecretTagsSection } from "./SecretTagsSection"; export { SecretTagsSection } from "./SecretTagsSection";
@@ -1,11 +1,12 @@
import { ProjectOverviewChangeSection } from "@app/components/project/ProjectOverviewChangeSection";
import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection"; import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection";
import { DeleteProjectSection } from "../DeleteProjectSection"; import { DeleteProjectSection } from "../DeleteProjectSection";
import { ProjectOverviewChangeSection } from "../ProjectOverviewChangeSection";
export const ProjectGeneralTab = () => { export const ProjectGeneralTab = () => {
return ( return (
<div> <div>
<ProjectOverviewChangeSection /> <ProjectOverviewChangeSection showSlugField />
<AuditLogsRetentionSection /> <AuditLogsRetentionSection />
<DeleteProjectSection /> <DeleteProjectSection />
</div> </div>
@@ -1,51 +0,0 @@
import { useCallback } from "react";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { Button } from "@app/components/v2";
import { useToggle } from "@app/hooks";
type Props = {
value: string;
hoverText: string;
notificationText: string;
children: React.ReactNode;
};
export const CopyButton = ({ value, children, hoverText, notificationText }: Props) => {
const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false);
const copyToClipboard = useCallback(() => {
if (isProjectIdCopied) {
return;
}
setIsProjectIdCopied.on();
navigator.clipboard.writeText(value);
createNotification({
text: notificationText,
type: "success"
});
const timer = setTimeout(() => setIsProjectIdCopied.off(), 2000);
// eslint-disable-next-line consistent-return
return () => clearTimeout(timer);
}, [isProjectIdCopied]);
return (
<Button
colorSchema="secondary"
className="group relative"
leftIcon={<FontAwesomeIcon icon={isProjectIdCopied ? faCheck : faCopy} />}
onClick={copyToClipboard}
>
{children}
<span className="absolute -left-8 -top-20 hidden translate-y-full justify-center rounded-md bg-bunker-800 px-3 py-2 text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{hoverText}
</span>
</Button>
);
};
@@ -1,168 +0,0 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, FormControl, Input, TextArea } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useUpdateProject } from "@app/hooks/api";
import { CopyButton } from "./CopyButton";
const formSchema = z.object({
name: z.string().min(1, "Required").max(64, "Too long, maximum length is 64 characters"),
description: z
.string()
.trim()
.max(256, "Description too long, max length is 256 characters")
.optional()
});
type FormData = z.infer<typeof formSchema>;
export const ProjectOverviewChangeSection = () => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync, isPending } = useUpdateProject();
const { handleSubmit, control, reset } = useForm<FormData>({ resolver: zodResolver(formSchema) });
useEffect(() => {
if (currentWorkspace) {
reset({
name: currentWorkspace.name,
description: currentWorkspace.description ?? ""
});
}
}, [currentWorkspace]);
const onFormSubmit = async ({ name, description }: FormData) => {
try {
if (!currentWorkspace?.id) return;
await mutateAsync({
projectID: currentWorkspace.id,
newProjectName: name,
newProjectDescription: description
});
createNotification({
text: "Successfully updated project overview",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to update project overview",
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="justify-betweens flex">
<h2 className="mb-8 flex-1 text-xl font-semibold text-mineshaft-100">Project Overview</h2>
<div className="space-x-2">
<CopyButton
value={currentWorkspace?.slug || ""}
hoverText="Click to project slug"
notificationText="Copied project slug to clipboard"
>
Copy Project Slug
</CopyButton>
<CopyButton
value={currentWorkspace?.id || ""}
hoverText="Click to project ID"
notificationText="Copied project ID to clipboard"
>
Copy Project ID
</CopyButton>
</div>
</div>
<div>
<form onSubmit={handleSubmit(onFormSubmit)} className="flex w-full flex-col gap-0">
<div className="flex w-full flex-row items-end gap-4">
<div className="w-full max-w-md">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project name"
>
<Input
placeholder="Project name"
{...field}
className="bg-mineshaft-800"
isDisabled={!isAllowed}
/>
</FormControl>
)}
control={control}
name="name"
/>
)}
</ProjectPermissionCan>
</div>
</div>
<div className="flex w-full flex-row items-end gap-4">
<div className="w-full max-w-md">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Controller
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project description"
>
<TextArea
placeholder="Project description"
{...field}
rows={3}
className="thin-scrollbar max-w-md !resize-none bg-mineshaft-800"
isDisabled={!isAllowed}
/>
</FormControl>
)}
control={control}
name="description"
/>
)}
</ProjectPermissionCan>
</div>
</div>
<div>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Project}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
isLoading={isPending}
isDisabled={isPending || !isAllowed}
>
Save
</Button>
)}
</ProjectPermissionCan>
</div>
</form>
</div>
</div>
);
};
@@ -1 +0,0 @@
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";
@@ -1,2 +1 @@
export { DeleteProjectSection } from "./DeleteProjectSection"; export { DeleteProjectSection } from "./DeleteProjectSection";
export { ProjectOverviewChangeSection } from "./ProjectOverviewChangeSection";