diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 6a5ddf51e8..5f931440c7 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -22,6 +22,7 @@ import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-rou import { registerSecretApprovalRequestRouter } from "./secret-approval-request-router"; import { registerSecretRotationProviderRouter } from "./secret-rotation-provider-router"; import { registerSecretRotationRouter } from "./secret-rotation-router"; +import { registerSecretRouter } from "./secret-router"; import { registerSecretScanningRouter } from "./secret-scanning-router"; import { registerSecretVersionRouter } from "./secret-version-router"; import { registerSnapshotRouter } from "./snapshot-router"; @@ -92,6 +93,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register(registerLdapRouter, { prefix: "/ldap" }); await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" }); await server.register(registerSecretRotationRouter, { prefix: "/secret-rotations" }); + await server.register(registerSecretRouter, { prefix: "/secrets" }); await server.register(registerSecretVersionRouter, { prefix: "/secret" }); await server.register(registerGroupRouter, { prefix: "/groups" }); await server.register(registerAuditLogStreamRouter, { prefix: "/audit-log-streams" }); diff --git a/backend/src/ee/routes/v1/secret-router.ts b/backend/src/ee/routes/v1/secret-router.ts new file mode 100644 index 0000000000..4c249afe04 --- /dev/null +++ b/backend/src/ee/routes/v1/secret-router.ts @@ -0,0 +1,71 @@ +import z from "zod"; + +import { ProjectPermissionActions } from "@app/ee/services/permission/project-permission"; +import { RAW_SECRETS } from "@app/lib/api-docs"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const AccessListEntrySchema = z + .object({ + allowedActions: z.nativeEnum(ProjectPermissionActions).array(), + id: z.string(), + membershipId: z.string(), + name: z.string() + }) + .array(); + +export const registerSecretRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:secretName/access-list", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get list of users, machine identities, and groups with access to a secret", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + secretName: z.string().trim().describe(RAW_SECRETS.GET_ACCESS_LIST.secretName) + }), + querystring: z.object({ + workspaceId: z.string().trim().describe(RAW_SECRETS.GET_ACCESS_LIST.workspaceId), + environment: z.string().trim().describe(RAW_SECRETS.GET_ACCESS_LIST.environment), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(RAW_SECRETS.GET_ACCESS_LIST.secretPath) + }), + response: { + 200: z.object({ + groups: AccessListEntrySchema, + identities: AccessListEntrySchema, + users: AccessListEntrySchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { secretName } = req.params; + const { secretPath, environment, workspaceId: projectId } = req.query; + + return server.services.secret.getSecretAccessList({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + secretPath, + environment, + projectId, + secretName + }); + } + }); +}; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 69daa85146..014fcccfd4 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -24,6 +24,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ rbac: false, customRateLimits: false, customAlerts: false, + secretAccessInsights: false, auditLogs: false, auditLogsRetentionDays: 0, auditLogStreams: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 381044f825..678e15fd46 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -48,6 +48,7 @@ export type TFeatureSet = { samlSSO: false; hsm: false; oidcSSO: false; + secretAccessInsights: false; scim: false; ldap: false; groups: false; diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index 3a3c824142..ca46442e99 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -125,6 +125,404 @@ export const permissionDALFactory = (db: TDbClient) => { } }; + const getProjectGroupPermissions = async (projectId: string) => { + try { + const docs = await db + .replicaNode()(TableName.GroupProjectMembership) + .join(TableName.Groups, `${TableName.Groups}.id`, `${TableName.GroupProjectMembership}.groupId`) + .join( + TableName.GroupProjectMembershipRole, + `${TableName.GroupProjectMembershipRole}.projectMembershipId`, + `${TableName.GroupProjectMembership}.id` + ) + .leftJoin( + { groupCustomRoles: TableName.ProjectRoles }, + `${TableName.GroupProjectMembershipRole}.customRoleId`, + `groupCustomRoles.id` + ) + .where(`${TableName.GroupProjectMembership}.projectId`, "=", projectId) + .select( + db.ref("id").withSchema(TableName.GroupProjectMembership).as("membershipId"), + db.ref("id").withSchema(TableName.Groups).as("groupId"), + db.ref("name").withSchema(TableName.Groups).as("groupName"), + db.ref("slug").withSchema("groupCustomRoles").as("groupProjectMembershipRoleCustomRoleSlug"), + db.ref("permissions").withSchema("groupCustomRoles").as("groupProjectMembershipRolePermission"), + db.ref("id").withSchema(TableName.GroupProjectMembershipRole).as("groupProjectMembershipRoleId"), + db.ref("role").withSchema(TableName.GroupProjectMembershipRole).as("groupProjectMembershipRole"), + db + .ref("customRoleId") + .withSchema(TableName.GroupProjectMembershipRole) + .as("groupProjectMembershipRoleCustomRoleId"), + db + .ref("isTemporary") + .withSchema(TableName.GroupProjectMembershipRole) + .as("groupProjectMembershipRoleIsTemporary"), + db + .ref("temporaryMode") + .withSchema(TableName.GroupProjectMembershipRole) + .as("groupProjectMembershipRoleTemporaryMode"), + db + .ref("temporaryRange") + .withSchema(TableName.GroupProjectMembershipRole) + .as("groupProjectMembershipRoleTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.GroupProjectMembershipRole) + .as("groupProjectMembershipRoleTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.GroupProjectMembershipRole) + .as("groupProjectMembershipRoleTemporaryAccessEndTime") + ); + + const groupPermissions = sqlNestRelationships({ + data: docs, + key: "groupId", + parentMapper: ({ groupId, groupName, membershipId }) => ({ + groupId, + username: groupName, + id: membershipId + }), + childrenMapper: [ + { + key: "groupProjectMembershipRoleId", + label: "groupRoles" as const, + mapper: ({ + groupProjectMembershipRoleId, + groupProjectMembershipRole, + groupProjectMembershipRolePermission, + groupProjectMembershipRoleCustomRoleSlug, + groupProjectMembershipRoleIsTemporary, + groupProjectMembershipRoleTemporaryMode, + groupProjectMembershipRoleTemporaryAccessEndTime, + groupProjectMembershipRoleTemporaryAccessStartTime, + groupProjectMembershipRoleTemporaryRange + }) => ({ + id: groupProjectMembershipRoleId, + role: groupProjectMembershipRole, + customRoleSlug: groupProjectMembershipRoleCustomRoleSlug, + permissions: groupProjectMembershipRolePermission, + temporaryRange: groupProjectMembershipRoleTemporaryRange, + temporaryMode: groupProjectMembershipRoleTemporaryMode, + temporaryAccessStartTime: groupProjectMembershipRoleTemporaryAccessStartTime, + temporaryAccessEndTime: groupProjectMembershipRoleTemporaryAccessEndTime, + isTemporary: groupProjectMembershipRoleIsTemporary + }) + } + ] + }); + + return groupPermissions + .map((groupPermission) => { + if (!groupPermission) return undefined; + + const activeGroupRoles = + groupPermission?.groupRoles?.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ) ?? []; + + return { + ...groupPermission, + roles: activeGroupRoles + }; + }) + .filter((item): item is NonNullable => Boolean(item)); + } catch (error) { + throw new DatabaseError({ error, name: "GetProjectGroupPermissions" }); + } + }; + + const getProjectUserPermissions = async (projectId: string) => { + try { + const docs = await db + .replicaNode()(TableName.Users) + .where("isGhost", "=", false) + .leftJoin(TableName.GroupProjectMembership, (queryBuilder) => { + void queryBuilder.on(`${TableName.GroupProjectMembership}.projectId`, db.raw("?", [projectId])); + }) + .leftJoin( + TableName.GroupProjectMembershipRole, + `${TableName.GroupProjectMembershipRole}.projectMembershipId`, + `${TableName.GroupProjectMembership}.id` + ) + .leftJoin( + { groupCustomRoles: TableName.ProjectRoles }, + `${TableName.GroupProjectMembershipRole}.customRoleId`, + `groupCustomRoles.id` + ) + .join(TableName.ProjectMembership, (queryBuilder) => { + void queryBuilder + .on(`${TableName.ProjectMembership}.projectId`, db.raw("?", [projectId])) + .andOn(`${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`); + }) + .leftJoin( + TableName.ProjectUserMembershipRole, + `${TableName.ProjectUserMembershipRole}.projectMembershipId`, + `${TableName.ProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.ProjectUserMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) + .leftJoin(TableName.ProjectUserAdditionalPrivilege, (queryBuilder) => { + void queryBuilder + .on(`${TableName.ProjectUserAdditionalPrivilege}.projectId`, db.raw("?", [projectId])) + .andOn(`${TableName.ProjectUserAdditionalPrivilege}.userId`, `${TableName.Users}.id`); + }) + .join(TableName.Project, `${TableName.Project}.id`, db.raw("?", [projectId])) + .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.Users}.id`, `${TableName.IdentityMetadata}.userId`) + .andOn(`${TableName.Organization}.id`, `${TableName.IdentityMetadata}.orgId`); + }) + .select( + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("username").withSchema(TableName.Users).as("username"), + // groups specific + db.ref("id").withSchema(TableName.GroupProjectMembership).as("groupMembershipId"), + db.ref("createdAt").withSchema(TableName.GroupProjectMembership).as("groupMembershipCreatedAt"), + db.ref("updatedAt").withSchema(TableName.GroupProjectMembership).as("groupMembershipUpdatedAt"), + db.ref("slug").withSchema("groupCustomRoles").as("userGroupProjectMembershipRoleCustomRoleSlug"), + db.ref("permissions").withSchema("groupCustomRoles").as("userGroupProjectMembershipRolePermission"), + db.ref("id").withSchema(TableName.GroupProjectMembershipRole).as("userGroupProjectMembershipRoleId"), + db.ref("role").withSchema(TableName.GroupProjectMembershipRole).as("userGroupProjectMembershipRole"), + db + .ref("customRoleId") + .withSchema(TableName.GroupProjectMembershipRole) + .as("userGroupProjectMembershipRoleCustomRoleId"), + db + .ref("isTemporary") + .withSchema(TableName.GroupProjectMembershipRole) + .as("userGroupProjectMembershipRoleIsTemporary"), + db + .ref("temporaryMode") + .withSchema(TableName.GroupProjectMembershipRole) + .as("userGroupProjectMembershipRoleTemporaryMode"), + db + .ref("temporaryRange") + .withSchema(TableName.GroupProjectMembershipRole) + .as("userGroupProjectMembershipRoleTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.GroupProjectMembershipRole) + .as("userGroupProjectMembershipRoleTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.GroupProjectMembershipRole) + .as("userGroupProjectMembershipRoleTemporaryAccessEndTime"), + // user specific + db.ref("id").withSchema(TableName.ProjectMembership).as("membershipId"), + db.ref("createdAt").withSchema(TableName.ProjectMembership).as("membershipCreatedAt"), + db.ref("updatedAt").withSchema(TableName.ProjectMembership).as("membershipUpdatedAt"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("userProjectMembershipRoleCustomRoleSlug"), + db.ref("permissions").withSchema(TableName.ProjectRoles).as("userProjectCustomRolePermission"), + db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("userProjectMembershipRoleId"), + db.ref("role").withSchema(TableName.ProjectUserMembershipRole).as("userProjectMembershipRole"), + db + .ref("temporaryMode") + .withSchema(TableName.ProjectUserMembershipRole) + .as("userProjectMembershipRoleTemporaryMode"), + db + .ref("isTemporary") + .withSchema(TableName.ProjectUserMembershipRole) + .as("userProjectMembershipRoleIsTemporary"), + db + .ref("temporaryRange") + .withSchema(TableName.ProjectUserMembershipRole) + .as("userProjectMembershipRoleTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.ProjectUserMembershipRole) + .as("userProjectMembershipRoleTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.ProjectUserMembershipRole) + .as("userProjectMembershipRoleTemporaryAccessEndTime"), + db.ref("id").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userAdditionalPrivilegesId"), + db + .ref("permissions") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userAdditionalPrivilegesPermissions"), + db + .ref("temporaryMode") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userAdditionalPrivilegesTemporaryMode"), + db + .ref("isTemporary") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userAdditionalPrivilegesIsTemporary"), + db + .ref("temporaryRange") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userAdditionalPrivilegesTemporaryRange"), + db.ref("userId").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userAdditionalPrivilegesUserId"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userAdditionalPrivilegesTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.ProjectUserAdditionalPrivilege) + .as("userAdditionalPrivilegesTemporaryAccessEndTime"), + // general + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), + db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), + db.ref("orgId").withSchema(TableName.Project), + db.ref("type").withSchema(TableName.Project).as("projectType"), + db.ref("id").withSchema(TableName.Project).as("projectId") + ); + + const userPermissions = sqlNestRelationships({ + data: docs, + key: "userId", + parentMapper: ({ + orgId, + username, + orgAuthEnforced, + membershipId, + groupMembershipId, + membershipCreatedAt, + groupMembershipCreatedAt, + groupMembershipUpdatedAt, + membershipUpdatedAt, + projectType, + userId + }) => ({ + orgId, + orgAuthEnforced, + userId, + projectId, + username, + projectType, + id: membershipId || groupMembershipId, + createdAt: membershipCreatedAt || groupMembershipCreatedAt, + updatedAt: membershipUpdatedAt || groupMembershipUpdatedAt + }), + childrenMapper: [ + { + key: "userGroupProjectMembershipRoleId", + label: "userGroupRoles" as const, + mapper: ({ + userGroupProjectMembershipRoleId, + userGroupProjectMembershipRole, + userGroupProjectMembershipRolePermission, + userGroupProjectMembershipRoleCustomRoleSlug, + userGroupProjectMembershipRoleIsTemporary, + userGroupProjectMembershipRoleTemporaryMode, + userGroupProjectMembershipRoleTemporaryAccessEndTime, + userGroupProjectMembershipRoleTemporaryAccessStartTime, + userGroupProjectMembershipRoleTemporaryRange + }) => ({ + id: userGroupProjectMembershipRoleId, + role: userGroupProjectMembershipRole, + customRoleSlug: userGroupProjectMembershipRoleCustomRoleSlug, + permissions: userGroupProjectMembershipRolePermission, + temporaryRange: userGroupProjectMembershipRoleTemporaryRange, + temporaryMode: userGroupProjectMembershipRoleTemporaryMode, + temporaryAccessStartTime: userGroupProjectMembershipRoleTemporaryAccessStartTime, + temporaryAccessEndTime: userGroupProjectMembershipRoleTemporaryAccessEndTime, + isTemporary: userGroupProjectMembershipRoleIsTemporary + }) + }, + { + key: "userProjectMembershipRoleId", + label: "projectMembershipRoles" as const, + mapper: ({ + userProjectMembershipRoleId, + userProjectMembershipRole, + userProjectCustomRolePermission, + userProjectMembershipRoleIsTemporary, + userProjectMembershipRoleTemporaryMode, + userProjectMembershipRoleTemporaryRange, + userProjectMembershipRoleTemporaryAccessEndTime, + userProjectMembershipRoleTemporaryAccessStartTime, + userProjectMembershipRoleCustomRoleSlug + }) => ({ + id: userProjectMembershipRoleId, + role: userProjectMembershipRole, + customRoleSlug: userProjectMembershipRoleCustomRoleSlug, + permissions: userProjectCustomRolePermission, + temporaryRange: userProjectMembershipRoleTemporaryRange, + temporaryMode: userProjectMembershipRoleTemporaryMode, + temporaryAccessStartTime: userProjectMembershipRoleTemporaryAccessStartTime, + temporaryAccessEndTime: userProjectMembershipRoleTemporaryAccessEndTime, + isTemporary: userProjectMembershipRoleIsTemporary + }) + }, + { + key: "userAdditionalPrivilegesId", + label: "additionalPrivileges" as const, + mapper: ({ + userAdditionalPrivilegesId, + userAdditionalPrivilegesPermissions, + userAdditionalPrivilegesIsTemporary, + userAdditionalPrivilegesTemporaryMode, + userAdditionalPrivilegesTemporaryRange, + userAdditionalPrivilegesTemporaryAccessEndTime, + userAdditionalPrivilegesTemporaryAccessStartTime + }) => ({ + id: userAdditionalPrivilegesId, + permissions: userAdditionalPrivilegesPermissions, + temporaryRange: userAdditionalPrivilegesTemporaryRange, + temporaryMode: userAdditionalPrivilegesTemporaryMode, + temporaryAccessStartTime: userAdditionalPrivilegesTemporaryAccessStartTime, + temporaryAccessEndTime: userAdditionalPrivilegesTemporaryAccessEndTime, + isTemporary: userAdditionalPrivilegesIsTemporary + }) + }, + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return userPermissions + .map((userPermission) => { + if (!userPermission) return undefined; + if (!userPermission?.userGroupRoles?.[0] && !userPermission?.projectMembershipRoles?.[0]) return undefined; + + // when introducting cron mode change it here + const activeRoles = + userPermission?.projectMembershipRoles?.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ) ?? []; + + const activeGroupRoles = + userPermission?.userGroupRoles?.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ) ?? []; + + const activeAdditionalPrivileges = + userPermission?.additionalPrivileges?.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ) ?? []; + + return { + ...userPermission, + roles: [...activeRoles, ...activeGroupRoles], + additionalPrivileges: activeAdditionalPrivileges + }; + }) + .filter((item): item is NonNullable => Boolean(item)); + } catch (error) { + throw new DatabaseError({ error, name: "GetProjectUserPermissions" }); + } + }; + const getProjectPermission = async (userId: string, projectId: string) => { try { const subQueryUserGroups = db(TableName.UserGroupMembership).where("userId", userId).select("groupId"); @@ -414,6 +812,163 @@ export const permissionDALFactory = (db: TDbClient) => { } }; + const getProjectIdentityPermissions = async (projectId: string) => { + try { + const docs = await db + .replicaNode()(TableName.IdentityProjectMembership) + .join( + TableName.IdentityProjectMembershipRole, + `${TableName.IdentityProjectMembershipRole}.projectMembershipId`, + `${TableName.IdentityProjectMembership}.id` + ) + .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityProjectMembership}.identityId`) + .leftJoin( + TableName.ProjectRoles, + `${TableName.IdentityProjectMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) + .leftJoin( + TableName.IdentityProjectAdditionalPrivilege, + `${TableName.IdentityProjectAdditionalPrivilege}.projectMembershipId`, + `${TableName.IdentityProjectMembership}.id` + ) + .join( + // Join the Project table to later select orgId + TableName.Project, + `${TableName.IdentityProjectMembership}.projectId`, + `${TableName.Project}.id` + ) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.Identity}.id`, `${TableName.IdentityMetadata}.identityId`) + .andOn(`${TableName.Project}.orgId`, `${TableName.IdentityMetadata}.orgId`); + }) + .where(`${TableName.IdentityProjectMembership}.projectId`, projectId) + .select(selectAllTableCols(TableName.IdentityProjectMembershipRole)) + .select( + db.ref("id").withSchema(TableName.IdentityProjectMembership).as("membershipId"), + db.ref("id").withSchema(TableName.Identity).as("identityId"), + db.ref("name").withSchema(TableName.Identity).as("identityName"), + db.ref("orgId").withSchema(TableName.Project).as("orgId"), // Now you can select orgId from Project + db.ref("type").withSchema(TableName.Project).as("projectType"), + db.ref("createdAt").withSchema(TableName.IdentityProjectMembership).as("membershipCreatedAt"), + db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership).as("membershipUpdatedAt"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + db.ref("permissions").withSchema(TableName.ProjectRoles), + db.ref("id").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApId"), + db.ref("permissions").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApPermissions"), + db + .ref("temporaryMode") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryMode"), + db.ref("isTemporary").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApIsTemporary"), + db + .ref("temporaryRange") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryRange"), + db + .ref("temporaryAccessStartTime") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryAccessStartTime"), + db + .ref("temporaryAccessEndTime") + .withSchema(TableName.IdentityProjectAdditionalPrivilege) + .as("identityApTemporaryAccessEndTime"), + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue") + ); + + const permissions = sqlNestRelationships({ + data: docs, + key: "identityId", + parentMapper: ({ + membershipId, + membershipCreatedAt, + membershipUpdatedAt, + orgId, + identityName, + projectType, + identityId + }) => ({ + id: membershipId, + identityId, + username: identityName, + projectId, + createdAt: membershipCreatedAt, + updatedAt: membershipUpdatedAt, + orgId, + projectType, + // just a prefilled value + orgAuthEnforced: false + }), + childrenMapper: [ + { + key: "id", + label: "roles" as const, + mapper: (data) => + IdentityProjectMembershipRoleSchema.extend({ + permissions: z.unknown(), + customRoleSlug: z.string().optional().nullable() + }).parse(data) + }, + { + key: "identityApId", + label: "additionalPrivileges" as const, + mapper: ({ + identityApId, + identityApPermissions, + identityApIsTemporary, + identityApTemporaryMode, + identityApTemporaryRange, + identityApTemporaryAccessEndTime, + identityApTemporaryAccessStartTime + }) => ({ + id: identityApId, + permissions: identityApPermissions, + temporaryRange: identityApTemporaryRange, + temporaryMode: identityApTemporaryMode, + temporaryAccessEndTime: identityApTemporaryAccessEndTime, + temporaryAccessStartTime: identityApTemporaryAccessStartTime, + isTemporary: identityApIsTemporary + }) + }, + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return permissions + .map((permission) => { + if (!permission) { + return undefined; + } + + // when introducting cron mode change it here + const activeRoles = permission?.roles.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ); + const activeAdditionalPrivileges = permission?.additionalPrivileges?.filter( + ({ isTemporary, temporaryAccessEndTime }) => + !isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime) + ); + + return { ...permission, roles: activeRoles, additionalPrivileges: activeAdditionalPrivileges }; + }) + .filter((item): item is NonNullable => Boolean(item)); + } catch (error) { + throw new DatabaseError({ error, name: "GetProjectIdentityPermissions" }); + } + }; + const getProjectIdentityPermission = async (identityId: string, projectId: string) => { try { const docs = await db @@ -568,6 +1123,9 @@ export const permissionDALFactory = (db: TDbClient) => { getOrgPermission, getOrgIdentityPermission, getProjectPermission, - getProjectIdentityPermission + getProjectIdentityPermission, + getProjectUserPermissions, + getProjectIdentityPermissions, + getProjectGroupPermissions }; }; diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 96e1891159..79103bd2f3 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -405,6 +405,123 @@ export const permissionServiceFactory = ({ ForbidOnInvalidProjectType: (type: ProjectType) => void; }; + const getProjectPermissions = async (projectId: string) => { + // fetch user permissions + const rawUserProjectPermissions = await permissionDAL.getProjectUserPermissions(projectId); + const userPermissions = rawUserProjectPermissions.map((userProjectPermission) => { + const rolePermissions = + userProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || []; + const additionalPrivileges = + userProjectPermission.additionalPrivileges?.map(({ permissions }) => ({ + role: ProjectMembershipRole.Custom, + permissions + })) || []; + + const rules = buildProjectPermissionRules(rolePermissions.concat(additionalPrivileges)); + const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false }); + const metadataKeyValuePair = escapeHandlebarsMissingMetadata( + objectify( + userProjectPermission.metadata, + (i) => i.key, + (i) => i.value + ) + ); + const interpolateRules = templatedRules( + { + identity: { + id: userProjectPermission.userId, + username: userProjectPermission.username, + metadata: metadataKeyValuePair + } + }, + { data: false } + ); + const permission = createMongoAbility( + JSON.parse(interpolateRules) as RawRuleOf>[], + { + conditionsMatcher + } + ); + + return { + permission, + id: userProjectPermission.userId, + name: userProjectPermission.username, + membershipId: userProjectPermission.id + }; + }); + + // fetch identity permissions + const rawIdentityProjectPermissions = await permissionDAL.getProjectIdentityPermissions(projectId); + const identityPermissions = rawIdentityProjectPermissions.map((identityProjectPermission) => { + const rolePermissions = + identityProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || []; + const additionalPrivileges = + identityProjectPermission.additionalPrivileges?.map(({ permissions }) => ({ + role: ProjectMembershipRole.Custom, + permissions + })) || []; + + const rules = buildProjectPermissionRules(rolePermissions.concat(additionalPrivileges)); + const templatedRules = handlebars.compile(JSON.stringify(rules), { data: false }); + const metadataKeyValuePair = escapeHandlebarsMissingMetadata( + objectify( + identityProjectPermission.metadata, + (i) => i.key, + (i) => i.value + ) + ); + + const interpolateRules = templatedRules( + { + identity: { + id: identityProjectPermission.identityId, + username: identityProjectPermission.username, + metadata: metadataKeyValuePair + } + }, + { data: false } + ); + const permission = createMongoAbility( + JSON.parse(interpolateRules) as RawRuleOf>[], + { + conditionsMatcher + } + ); + + return { + permission, + id: identityProjectPermission.identityId, + name: identityProjectPermission.username, + membershipId: identityProjectPermission.id + }; + }); + + // fetch group permissions + const rawGroupProjectPermissions = await permissionDAL.getProjectGroupPermissions(projectId); + const groupPermissions = rawGroupProjectPermissions.map((groupProjectPermission) => { + const rolePermissions = + groupProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || []; + const rules = buildProjectPermissionRules(rolePermissions); + const permission = createMongoAbility(rules, { + conditionsMatcher + }); + + return { + permission, + id: groupProjectPermission.groupId, + name: groupProjectPermission.username, + membershipId: groupProjectPermission.id + }; + }); + + return { + userPermissions, + identityPermissions, + groupPermissions + }; + }; + const getProjectPermission = async ( type: T, id: string, @@ -455,6 +572,7 @@ export const permissionServiceFactory = ({ getOrgPermission, getUserProjectPermission, getProjectPermission, + getProjectPermissions, getOrgPermissionByRole, getProjectPermissionByRole, buildOrgPermission, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 57816dc24d..5235617aa9 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -741,6 +741,12 @@ export const RAW_SECRETS = { workspaceId: "The ID of the project where the secret is located.", environment: "The slug of the environment where the the secret is located.", secretPath: "The folder path where the secret is located." + }, + GET_ACCESS_LIST: { + secretName: "The name of the secret to get the access list for.", + workspaceId: "The ID of the project where the secret is located.", + environment: "The slug of the environment where the the secret is located.", + secretPath: "The folder path where the secret is located." } } as const; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 05575a63f6..50b0d94fc2 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1033,7 +1033,8 @@ export const registerRoutes = async ( secretApprovalRequestDAL, secretApprovalRequestSecretDAL, secretV2BridgeService, - secretApprovalRequestService + secretApprovalRequestService, + licenseService }); const secretSharingService = secretSharingServiceFactory({ diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 7fe6f824ce..91e4ef9694 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -11,6 +11,7 @@ import { SecretsSchema, SecretType } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; @@ -69,6 +70,7 @@ import { TDeleteSecretRawDTO, TGetASecretDTO, TGetASecretRawDTO, + TGetSecretAccessListDTO, TGetSecretsDTO, TGetSecretsRawDTO, TGetSecretVersionsDTO, @@ -94,7 +96,7 @@ type TSecretServiceFactoryDep = { >; secretV2BridgeService: TSecretV2BridgeServiceFactory; secretBlindIndexDAL: TSecretBlindIndexDALFactory; - permissionService: Pick; + permissionService: Pick; snapshotService: Pick; secretQueueService: Pick< TSecretQueueFactory, @@ -113,6 +115,7 @@ type TSecretServiceFactoryDep = { TSecretApprovalRequestSecretDALFactory, "insertMany" | "insertApprovalSecretTags" >; + licenseService: Pick; }; export type TSecretServiceFactory = ReturnType; @@ -134,7 +137,8 @@ export const secretServiceFactory = ({ secretApprovalRequestDAL, secretApprovalRequestSecretDAL, secretV2BridgeService, - secretApprovalRequestService + secretApprovalRequestService, + licenseService }: TSecretServiceFactoryDep) => { const getSecretReference = async (projectId: string) => { // if bot key missing means e2e still exist @@ -1153,6 +1157,71 @@ export const secretServiceFactory = ({ return secretV2BridgeService.getSecretReferenceTree(dto); }; + const getSecretAccessList = async (dto: TGetSecretAccessListDTO) => { + const { environment, secretPath, secretName, projectId } = dto; + const plan = await licenseService.getPlan(dto.actorOrgId); + if (!plan.secretAccessInsights) { + throw new BadRequestError({ + message: "Failed to fetch secret access list due to plan restriction. Upgrade your plan." + }); + } + + const secret = await secretV2BridgeService.getSecretByName({ + actor: dto.actor, + actorId: dto.actorId, + actorOrgId: dto.actorOrgId, + actorAuthMethod: dto.actorAuthMethod, + projectId, + secretName, + path: secretPath, + environment, + type: "shared" + }); + + const { userPermissions, identityPermissions, groupPermissions } = await permissionService.getProjectPermissions( + dto.projectId + ); + + const attachAllowedActions = ( + entityPermission: + | (typeof userPermissions)[number] + | (typeof identityPermissions)[number] + | (typeof groupPermissions)[number] + ) => { + const allowedActions = [ + ProjectPermissionActions.Read, + ProjectPermissionActions.Delete, + ProjectPermissionActions.Create, + ProjectPermissionActions.Edit + ].filter((action) => + entityPermission.permission.can( + action, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName, + secretTags: secret?.tags?.map((el) => el.slug) + }) + ) + ); + + return { + ...entityPermission, + allowedActions + }; + }; + + const usersWithAccess = userPermissions.map(attachAllowedActions).filter((user) => user.allowedActions.length > 0); + const identitiesWithAccess = identityPermissions + .map(attachAllowedActions) + .filter((identity) => identity.allowedActions.length > 0); + const groupsWithAccess = groupPermissions + .map(attachAllowedActions) + .filter((group) => group.allowedActions.length > 0); + + return { users: usersWithAccess, identities: identitiesWithAccess, groups: groupsWithAccess }; + }; + const getSecretsRaw = async ({ projectId, path, @@ -2946,6 +3015,7 @@ export const secretServiceFactory = ({ getSecretsCountMultiEnv, getSecretsRawMultiEnv, getSecretReferenceTree, - getSecretsRawByFolderMappings + getSecretsRawByFolderMappings, + getSecretAccessList }; }; diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 0bcf6ed13e..d1011bf8cc 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -190,6 +190,12 @@ export type TGetSecretsRawDTO = { keys?: string[]; } & TProjectPermission; +export type TGetSecretAccessListDTO = { + environment: string; + secretPath: string; + secretName: string; +} & TProjectPermission; + export type TGetASecretRawDTO = { secretName: string; path: string; diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index 04610e36ba..a6dd959b07 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -10,6 +10,7 @@ import { useToggle } from "@app/hooks/useToggle"; import { ERROR_NOT_ALLOWED_READ_SECRETS } from "./constants"; import { GetSecretVersionsDTO, + SecretAccessListEntry, SecretType, SecretV3Raw, SecretV3RawResponse, @@ -18,6 +19,7 @@ import { TGetProjectSecretsAllEnvDTO, TGetProjectSecretsDTO, TGetProjectSecretsKey, + TGetSecretAccessListDTO, TGetSecretReferenceTreeDTO, TSecretReferenceTraceNode } from "./types"; @@ -27,6 +29,13 @@ export const secretKeys = { getProjectSecret: ({ workspaceId, environment, secretPath }: TGetProjectSecretsKey) => [{ workspaceId, environment, secretPath }, "secrets"] as const, getSecretVersion: (secretId: string) => [{ secretId }, "secret-versions"] as const, + getSecretAccessList: ({ + workspaceId, + environment, + secretPath, + secretKey + }: TGetSecretAccessListDTO) => + ["secret-access-list", { workspaceId, environment, secretPath, secretKey }] as const, getSecretReferenceTree: (dto: TGetSecretReferenceTreeDTO) => ["secret-reference-tree", dto] }; @@ -232,6 +241,27 @@ export const useGetSecretVersion = (dto: GetSecretVersionsDTO) => }, []) }); +export const useGetSecretAccessList = (dto: TGetSecretAccessListDTO) => + useQuery({ + enabled: Boolean(dto.secretKey), + queryKey: secretKeys.getSecretAccessList(dto), + queryFn: async () => { + const { data } = await apiRequest.get<{ + groups: SecretAccessListEntry[]; + identities: SecretAccessListEntry[]; + users: SecretAccessListEntry[]; + }>(`/api/v1/secrets/${dto.secretKey}/access-list`, { + params: { + workspaceId: dto.workspaceId, + environment: dto.environment, + secretPath: dto.secretPath + } + }); + + return data; + } + }); + const fetchSecretReferenceTree = async ({ secretPath, projectId, diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 35fbb17135..cbed26f8f1 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -1,3 +1,5 @@ +import { ProjectPermissionActions } from "@app/context"; + import type { WsTag } from "../tags/types"; export enum SecretType { @@ -125,6 +127,13 @@ export type GetSecretVersionsDTO = { offset: number; }; +export type TGetSecretAccessListDTO = { + workspaceId: string; + environment: string; + secretPath: string; + secretKey: string; +}; + export type TCreateSecretsV3DTO = { secretKey: string; secretValue: string; @@ -231,3 +240,10 @@ export type TSecretReferenceTraceNode = { secretPath: string; children: TSecretReferenceTraceNode[]; }; + +export type SecretAccessListEntry = { + allowedActions: ProjectPermissionActions[]; + id: string; + membershipId: string; + name: string; +}; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index b57a2d26fa..5ad3c42217 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -23,6 +23,7 @@ export type SubscriptionPlan = { workspacesUsed: number; environmentLimit: number; samlSSO: boolean; + secretAccessInsights: boolean; hsm: boolean; oidcSSO: boolean; scim: boolean; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx index 95a39d416f..57f8be64c5 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx @@ -13,8 +13,10 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { Link } from "@tanstack/react-router"; import { format } from "date-fns"; +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { @@ -36,10 +38,17 @@ import { Tooltip } from "@app/components/v2"; import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; -import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; -import { useToggle } from "@app/hooks"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useProjectPermission, + useWorkspace +} from "@app/context"; +import { usePopUp, useToggle } from "@app/hooks"; import { useGetSecretVersion } from "@app/hooks/api"; +import { useGetSecretAccessList } from "@app/hooks/api/secrets/queries"; import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types"; +import { ProjectType } from "@app/hooks/api/workspace/types"; import { CreateReminderForm } from "./CreateReminderForm"; import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils"; @@ -87,7 +96,12 @@ export const SecretDetailSidebar = ({ values: secret }); + const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp([ + "secretAccessUpgradePlan" + ] as const); + const { permission } = useProjectPermission(); + const { currentWorkspace } = useWorkspace(); const tagFields = useFieldArray({ control, @@ -137,6 +151,13 @@ export const SecretDetailSidebar = ({ secretId: secret?.id }); + const { data: secretAccessList, isPending } = useGetSecretAccessList({ + workspaceId: currentWorkspace.id, + environment, + secretPath, + secretKey + }); + const handleOverrideClick = () => { if (isOverridden) { // override need not be flagged delete if it was never saved in server @@ -191,6 +212,13 @@ export const SecretDetailSidebar = ({ } }} /> + + handlePopUpToggle("secretAccessUpgradePlan", isUpgradeModalOpen) + } + text="Secret access analysis is only available on Infisical's Pro plan and above." + /> { if (isOpen && isDirty) { @@ -553,8 +581,117 @@ export const SecretDetailSidebar = ({ ))} +
+
+ Access List + + + +
+ {isPending && ( + + )} + {!isPending && secretAccessList === undefined && ( + + )} + {!isPending && secretAccessList && ( +
+ {secretAccessList.users.length > 0 && ( +
+
Users
+
+ {secretAccessList.users.map((user) => ( +
+ + + {user.name} + + +
+ ))} +
+
+ )} + {secretAccessList.identities.length > 0 && ( +
+
Identities
+
+ {secretAccessList.identities.map((identity) => ( +
+ + + {identity.name} + + +
+ ))} +
+
+ )} + {secretAccessList.groups.length > 0 && ( +
+
Groups
+
+ {secretAccessList.groups.map((group) => ( +
+ + + {group.name} + + +
+ ))} +
+
+ )} +
+ )} +
-
+