From 5b923c25b5df933927307153d7ea070fd6f5695e Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Sat, 25 Nov 2023 18:37:20 +0700 Subject: [PATCH] Added authz logic to MI --- .../controllers/v2/organizationsController.ts | 7 + .../src/controllers/v2/workspaceController.ts | 36 +++-- .../v3/machineIdentityController.ts | 126 +++++++++--------- backend/src/ee/models/auditLog/types.ts | 1 - backend/src/ee/services/ProjectRoleService.ts | 74 +++++++++- backend/src/ee/services/RoleService.ts | 87 +++++++++--- backend/src/validation/machineIdentity.ts | 3 +- .../src/context/OrgPermissionContext/types.ts | 4 +- .../context/ProjectPermissionContext/types.ts | 4 +- .../src/views/Org/MembersPage/MembersPage.tsx | 2 +- .../AddMachineIdentityModal.tsx | 18 +-- .../MachineIdentitySection.tsx | 4 +- .../MachineIdentityTable.tsx | 22 +-- .../OrgRoleModifySection.tsx | 10 +- .../OrgRoleModifySection.utils.ts | 3 +- .../views/Project/MembersPage/MembersPage.tsx | 2 +- .../AddMachineIdentityModal.tsx | 6 +- .../MachineIdentitySection.tsx | 4 +- .../MachineIdentityTable.tsx | 6 +- 19 files changed, 286 insertions(+), 133 deletions(-) diff --git a/backend/src/controllers/v2/organizationsController.ts b/backend/src/controllers/v2/organizationsController.ts index 4c6a72823..0ff16d00e 100644 --- a/backend/src/controllers/v2/organizationsController.ts +++ b/backend/src/controllers/v2/organizationsController.ts @@ -393,6 +393,13 @@ export const getOrganizationMachineMemberships = async (req: Request, res: Respo const { params: { organizationId } } = await validateRequest(reqValidator.GetOrgServiceMembersV2, req); + + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Read, + OrgPermissionSubjects.MachineIdentity + ); const machineMemberships = await MachineMembershipOrg.find({ organization: new Types.ObjectId(organizationId) diff --git a/backend/src/controllers/v2/workspaceController.ts b/backend/src/controllers/v2/workspaceController.ts index 6099645ed..55f0b1617 100644 --- a/backend/src/controllers/v2/workspaceController.ts +++ b/backend/src/controllers/v2/workspaceController.ts @@ -24,10 +24,12 @@ import * as reqValidator from "../../validation"; import { ProjectPermissionActions, ProjectPermissionSub, - getAuthDataProjectPermissions + getAuthDataProjectPermissions, + getRolePermissions, + isAtLeastAsPrivilegedWorkspace } from "../../ee/services/ProjectRoleService"; import { ForbiddenError } from "@casl/ability"; -import { BadRequestError, ResourceNotFoundError } from "../../utils/errors"; +import { BadRequestError, ForbiddenRequestError, ResourceNotFoundError } from "../../utils/errors"; import { ADMIN, MEMBER, VIEWER } from "../../variables"; interface V2PushSecret { @@ -523,7 +525,7 @@ export const addMachineToWorkspace = async (req: Request, res: Response) => { ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, - ProjectPermissionSub.ServiceTokens + ProjectPermissionSub.MachineIdentity ); let machineMembership = await MachineMembership.findOne({ @@ -532,7 +534,7 @@ export const addMachineToWorkspace = async (req: Request, res: Response) => { }); if (machineMembership) throw BadRequestError({ - message: "Machine identity already exists in workspace" + message: `Machine identity with id ${machineId} already exists in workspace with id ${workspaceId}` }); const machineIdentity = await MachineIdentity.findById(machineId); @@ -545,6 +547,13 @@ export const addMachineToWorkspace = async (req: Request, res: Response) => { message: "Failed to add machine identity to workspace in another organization" }); + const rolePermission = await getRolePermissions(role, workspaceId); + const hasRequiredPrivileges = isAtLeastAsPrivilegedWorkspace(permission, rolePermission); + + if (!hasRequiredPrivileges) throw ForbiddenRequestError({ + message: "Failed to add a more privileged MI to workspace" + }); + let customRole; if (role) { const isCustomRole = ![ADMIN, MEMBER, VIEWER].includes(role); @@ -591,8 +600,8 @@ export const addMachineToWorkspace = async (req: Request, res: Response) => { }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - ProjectPermissionSub.ServiceTokens + ProjectPermissionActions.Edit, + ProjectPermissionSub.MachineIdentity ); let machineMembership = await MachineMembership.findOne({ @@ -601,7 +610,7 @@ export const addMachineToWorkspace = async (req: Request, res: Response) => { }); if (!machineMembership) throw BadRequestError({ - message: "Machine identity does not exist in workspace" + message: `Machine identity with id ${machineId} does not exist in workspace with id ${workspaceId}` }); const machineIdentity = await MachineIdentity.findById(machineId); @@ -611,7 +620,14 @@ export const addMachineToWorkspace = async (req: Request, res: Response) => { if (!workspace) throw ResourceNotFoundError(); if (!machineIdentity.organization.equals(workspace.organization)) throw BadRequestError({ - message: "Failed to add machine identity to workspace in another organization" + message: "Failed to update machine identity in workspace in another organization" + }); + + const rolePermission = await getRolePermissions(role, workspaceId); + const hasRequiredPrivileges = isAtLeastAsPrivilegedWorkspace(permission, rolePermission); + + if (!hasRequiredPrivileges) throw ForbiddenRequestError({ + message: "Failed to update MI to a more privileged role" }); let customRole; @@ -665,7 +681,7 @@ export const addMachineToWorkspace = async (req: Request, res: Response) => { ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, - ProjectPermissionSub.ServiceTokens + ProjectPermissionSub.MachineIdentity ); const machineMembership = await MachineMembership.findOneAndDelete({ @@ -698,7 +714,7 @@ export const addMachineToWorkspace = async (req: Request, res: Response) => { ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Read, - ProjectPermissionSub.ServiceTokens + ProjectPermissionSub.MachineIdentity ); const machineMemberships = await MachineMembership.find({ diff --git a/backend/src/ee/controllers/v3/machineIdentityController.ts b/backend/src/ee/controllers/v3/machineIdentityController.ts index 96292962c..be26ea7b0 100644 --- a/backend/src/ee/controllers/v3/machineIdentityController.ts +++ b/backend/src/ee/controllers/v3/machineIdentityController.ts @@ -16,13 +16,21 @@ import { import { validateRequest } from "../../../helpers/validation"; import * as reqValidator from "../../../validation/machineIdentity"; import { createToken } from "../../../helpers/auth"; - - -import { BadRequestError, ResourceNotFoundError, UnauthorizedRequestError } from "../../../utils/errors"; +import { + getOrgRolePermissions, + getUserOrgPermissions, + isAtLeastAsPrivilegedOrg +} from "../../services/RoleService"; +import { BadRequestError, ForbiddenRequestError, ResourceNotFoundError, UnauthorizedRequestError } from "../../../utils/errors"; import { extractIPDetails, isValidIpOrCidr } from "../../../utils/ip"; import { EEAuditLogService, EELicenseService } from "../../services"; import { getAuthSecret } from "../../../config"; import { ADMIN, AuthTokenType, CUSTOM, MEMBER } from "../../../variables"; +import { + OrgPermissionActions, + OrgPermissionSubjects +} from "../../services/RoleService"; +import { ForbiddenError } from "@casl/ability"; /** * Return machine identity access and refresh token as per refresh operation @@ -32,12 +40,12 @@ import { ADMIN, AuthTokenType, CUSTOM, MEMBER } from "../../../variables"; export const refreshToken = async (req: Request, res: Response) => { const { body: { - refresh_token + refreshToken } } = await validateRequest(reqValidator.RefreshTokenV3, req); const decodedToken = ( - jwt.verify(refresh_token, await getAuthSecret()) + jwt.verify(refreshToken, await getAuthSecret()) ); if (decodedToken.authTokenType !== AuthTokenType.SERVICE_REFRESH_TOKEN) throw UnauthorizedRequestError(); @@ -55,15 +63,15 @@ import { ADMIN, AuthTokenType, CUSTOM, MEMBER } from "../../../variables"; } const response: { - refresh_token?: string; - access_token: string; - expires_in: number; - token_type: string; + refreshToken?: string; + accessToken: string; + expiresIn: number; + tokenType: string; } = { - refresh_token, - access_token: "", - expires_in: 0, - token_type: "Bearer" + refreshToken, + accessToken: "", + expiresIn: 0, + tokenType: "Bearer" }; if (machineIdentity.isRefreshTokenRotationEnabled) { @@ -81,7 +89,7 @@ import { ADMIN, AuthTokenType, CUSTOM, MEMBER } from "../../../variables"; if (!machineIdentity) throw BadRequestError(); - response.refresh_token = createToken({ + response.refreshToken = createToken({ payload: { serviceTokenDataId: machineIdentity._id.toString(), authTokenType: AuthTokenType.SERVICE_REFRESH_TOKEN, @@ -91,9 +99,9 @@ import { ADMIN, AuthTokenType, CUSTOM, MEMBER } from "../../../variables"; }); } - response.access_token = createToken({ + response.accessToken = createToken({ payload: { - serviceTokenDataId: machineIdentity._id.toString(), + serviceTokenDataId: machineIdentity._id.toString(), // TODO: fix this authTokenType: AuthTokenType.SERVICE_ACCESS_TOKEN, tokenVersion: machineIdentity.tokenVersion }, @@ -101,7 +109,7 @@ import { ADMIN, AuthTokenType, CUSTOM, MEMBER } from "../../../variables"; secret: await getAuthSecret() }); - response.expires_in = machineIdentity.accessTokenTTL; + response.expiresIn = machineIdentity.accessTokenTTL; await MachineIdentity.findByIdAndUpdate( machineIdentity._id, @@ -135,22 +143,23 @@ export const createMachineIdentity = async (req: Request, res: Response) => { isRefreshTokenRotationEnabled } } = await validateRequest(reqValidator.CreateMachineIdentityV3, req); - - // const { permission } = await getAuthDataProjectPermissions({ - // authData: req.authData, - // workspaceId: new Types.ObjectId(workspaceId) - // }); - - // ForbiddenError.from(permission).throwUnlessCan( - // ProjectPermissionActions.Create, - // ProjectPermissionSub.ServiceTokens - // ); - // const workspace = await Workspace.findById(workspaceId); - // if (!workspace) throw BadRequestError({ message: "Workspace not found" }); + const { permission } = await getUserOrgPermissions(req.user._id, organizationId); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Create, + OrgPermissionSubjects.MachineIdentity + ); + + const rolePermission = await getOrgRolePermissions(role, organizationId); + const hasRequiredPrivileges = isAtLeastAsPrivilegedOrg(permission, rolePermission); + + if (!hasRequiredPrivileges) throw ForbiddenRequestError({ + message: "Failed to create a more privileged MI" + }); const organization = await Organization.findById(organizationId); - if (!organization) throw BadRequestError({ message: "Organization not found" }); + if (!organization) throw BadRequestError({ message: `Organization with id ${organizationId} not found` }); const isCustomRole = ![ADMIN, MEMBER].includes(role); @@ -217,7 +226,7 @@ export const createMachineIdentity = async (req: Request, res: Response) => { const refreshToken = createToken({ payload: { - serviceTokenDataId: machineIdentity._id.toString(), + serviceTokenDataId: machineIdentity._id.toString(), // TODO: update authTokenType: AuthTokenType.SERVICE_REFRESH_TOKEN, tokenVersion: machineIdentity.tokenVersion }, @@ -248,7 +257,7 @@ export const createMachineIdentity = async (req: Request, res: Response) => { } /** - * Update service token V3 data with id [serviceTokenDataId] + * Update machine identity with id [machineId] * @param req * @param res * @returns @@ -258,7 +267,6 @@ export const updateMachineIdentity = async (req: Request, res: Response) => { params: { machineId }, body: { name, - isActive, role, trustedIps, expiresIn, @@ -269,21 +277,24 @@ export const updateMachineIdentity = async (req: Request, res: Response) => { let machineIdentity = await MachineIdentity.findById(machineId); if (!machineIdentity) throw ResourceNotFoundError({ - message: "Service token not found" + message: `Machine identity with id ${machineId} not found` }); - - // const { permission } = await getAuthDataProjectPermissions({ - // authData: req.authData, - // workspaceId: serviceTokenData.workspace - // }); - - // ForbiddenError.from(permission).throwUnlessCan( - // ProjectPermissionActions.Edit, - // ProjectPermissionSub.ServiceTokens - // ); - // const workspace = await Workspace.findById(serviceTokenData.workspace); - // if (!workspace) throw BadRequestError({ message: "Workspace not found" }); + const { permission } = await getUserOrgPermissions(req.user._id, machineIdentity.organization.toString()); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.MachineIdentity + ); + + if (role) { + const rolePermission = await getOrgRolePermissions(role, machineIdentity.organization.toString()); + const hasRequiredPrivileges = isAtLeastAsPrivilegedOrg(permission, rolePermission); + + if (!hasRequiredPrivileges) throw ForbiddenRequestError({ + message: "Failed to update MI to a more privileged role" + }); + } let customRole; if (role) { @@ -329,7 +340,6 @@ export const updateMachineIdentity = async (req: Request, res: Response) => { machineId, { name, - isActive, trustedIps: reformattedTrustedIps, expiresAt, accessTokenTTL, @@ -341,7 +351,7 @@ export const updateMachineIdentity = async (req: Request, res: Response) => { ); if (!machineIdentity) throw BadRequestError({ - message: "Failed to update service token" + message: `Failed to update machine identity with id ${machineId}` }); await MachineMembershipOrg.findOneAndUpdate( @@ -370,7 +380,6 @@ export const updateMachineIdentity = async (req: Request, res: Response) => { type: EventType.UPDATE_MACHINE_IDENTITY, metadata: { name: machineIdentity.name, - isActive, role, trustedIps: reformattedTrustedIps as Array, expiresAt @@ -387,7 +396,7 @@ export const updateMachineIdentity = async (req: Request, res: Response) => { } /** - * Delete service token data with id [serviceTokenDataId] + * Delete machine identity with id [machineId] * @param req * @param res * @returns @@ -399,18 +408,15 @@ export const deleteMachineIdentity = async (req: Request, res: Response) => { let machineIdentity = await MachineIdentity.findById(machineId); if (!machineIdentity) throw ResourceNotFoundError({ - message: "Service token not found" + message: `Machine identity with id ${machineId} not found` }); + + const { permission } = await getUserOrgPermissions(req.user._id, machineIdentity.organization.toString()); - // const { permission } = await getAuthDataProjectPermissions({ - // authData: req.authData, - // workspaceId: serviceTokenData.workspace - // }); - - // ForbiddenError.from(permission).throwUnlessCan( - // ProjectPermissionActions.Delete, - // ProjectPermissionSub.ServiceTokens - // ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Delete, + OrgPermissionSubjects.MachineIdentity + ); machineIdentity = await MachineIdentity.findByIdAndDelete(machineId); diff --git a/backend/src/ee/models/auditLog/types.ts b/backend/src/ee/models/auditLog/types.ts index 859606901..bd79b5bec 100644 --- a/backend/src/ee/models/auditLog/types.ts +++ b/backend/src/ee/models/auditLog/types.ts @@ -235,7 +235,6 @@ interface UpdateMachineIdentityEvent { type: EventType.UPDATE_MACHINE_IDENTITY; metadata: { name?: string; - isActive?: boolean; role?: string; trustedIps?: Array; expiresAt?: Date; diff --git a/backend/src/ee/services/ProjectRoleService.ts b/backend/src/ee/services/ProjectRoleService.ts index d4f25394a..f20114b87 100644 --- a/backend/src/ee/services/ProjectRoleService.ts +++ b/backend/src/ee/services/ProjectRoleService.ts @@ -11,7 +11,7 @@ import { UnauthorizedRequestError } from "../../utils/errors"; import { FieldCondition, FieldInstruction, JsInterpreter } from "@ucast/mongo2js"; import picomatch from "picomatch"; import { AuthData } from "../../interfaces/middleware"; -import { ActorType, IRole } from "../models"; +import { ActorType, IRole, Role } from "../models"; import { IMachineIdentity, MachineMembership, @@ -20,6 +20,7 @@ import { } from "../../models"; import { ADMIN, CUSTOM, MEMBER, VIEWER } from "../../variables"; import { checkIPAgainstBlocklist } from "../../utils/ip"; +import { BadRequestError } from "../../utils/errors"; const $glob: FieldInstruction = { type: "field", @@ -60,7 +61,8 @@ export enum ProjectPermissionSub { Secrets = "secrets", SecretRollback = "secret-rollback", SecretApproval = "secret-approval", - SecretRotation = "secret-rotation" + SecretRotation = "secret-rotation", + MachineIdentity = "machine-identity" } type SubjectFields = { @@ -85,6 +87,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] + | [ProjectPermissionActions, ProjectPermissionSub.MachineIdentity] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] @@ -131,6 +134,11 @@ const buildAdminPermission = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Webhooks); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Read, ProjectPermissionSub.MachineIdentity); + can(ProjectPermissionActions.Create, ProjectPermissionSub.MachineIdentity); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.MachineIdentity); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.MachineIdentity); + can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); can(ProjectPermissionActions.Create, ProjectPermissionSub.ServiceTokens); can(ProjectPermissionActions.Edit, ProjectPermissionSub.ServiceTokens); @@ -196,6 +204,11 @@ const buildMemberPermission = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Webhooks); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Read, ProjectPermissionSub.MachineIdentity); + can(ProjectPermissionActions.Create, ProjectPermissionSub.MachineIdentity); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.MachineIdentity); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.MachineIdentity); + can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); can(ProjectPermissionActions.Create, ProjectPermissionSub.ServiceTokens); can(ProjectPermissionActions.Edit, ProjectPermissionSub.ServiceTokens); @@ -236,6 +249,7 @@ const buildViewerPermission = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); can(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); can(ProjectPermissionActions.Read, ProjectPermissionSub.Webhooks); + can(ProjectPermissionActions.Read, ProjectPermissionSub.MachineIdentity); can(ProjectPermissionActions.Read, ProjectPermissionSub.ServiceTokens); can(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments); @@ -337,3 +351,59 @@ export const getAuthDataProjectPermissions = async ({ throw UnauthorizedRequestError(); } } + +export const getRolePermissions = async (role: string, workspaceId: string) => { + const isCustomRole = ![ADMIN, MEMBER, VIEWER].includes(role); + if (isCustomRole) { + const workspaceRole = await Role.findOne({ + slug: role, + isOrgRole: false, + workspace: new Types.ObjectId(workspaceId) + }); + + if (!workspaceRole) throw BadRequestError({ message: "Role not found" }); + + return createMongoAbility(workspaceRole.permissions as RawRuleOf>[], { + conditionsMatcher + }); + } + + switch (role) { + case ADMIN: + return adminProjectPermissions; + case MEMBER: + return memberProjectPermissions; + case VIEWER: + return viewerProjectPermission; + default: + throw BadRequestError({ message: "Role not found" }); + } +} + +/** + * Extracts and formats permissions from a CASL Ability object or a raw permission set. + * @param ability + * @returns + */ + const extractPermissions = (ability: MongoAbility | ProjectPermissionSet) => { + return ability.A.map((permission: any) => `${permission.action}_${permission.subject}`); +} + +/** + * 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 isAtLeastAsPrivilegedWorkspace = (permissions1: MongoAbility | ProjectPermissionSet, permissions2: MongoAbility | ProjectPermissionSet) => { + + 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; +} \ No newline at end of file diff --git a/backend/src/ee/services/RoleService.ts b/backend/src/ee/services/RoleService.ts index 8c14d9d57..f4d65cb75 100644 --- a/backend/src/ee/services/RoleService.ts +++ b/backend/src/ee/services/RoleService.ts @@ -1,8 +1,9 @@ +import { Types } from "mongoose"; import { AbilityBuilder, MongoAbility, RawRuleOf, createMongoAbility } from "@casl/ability"; import { MembershipOrg } from "../../models"; -import { IRole } from "../models"; +import { IRole, Role } from "../models"; import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors"; -import { ACCEPTED } from "../../variables"; +import { ACCEPTED, ADMIN, CUSTOM, MEMBER } from "../../variables"; import { conditionsMatcher } from "./ProjectRoleService"; export enum OrgPermissionActions { @@ -21,7 +22,7 @@ export enum OrgPermissionSubjects { Sso = "sso", Billing = "billing", SecretScanning = "secret-scanning", - ServiceTokens = "service-tokens" // TODO: consider renaming + MachineIdentity = "machine-identity" } export type OrgPermissionSet = @@ -34,7 +35,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] | [OrgPermissionActions, OrgPermissionSubjects.Billing] - | [OrgPermissionActions, OrgPermissionSubjects.ServiceTokens]; + | [OrgPermissionActions, OrgPermissionSubjects.MachineIdentity]; const buildAdminPermission = () => { const { can, build } = new AbilityBuilder>(createMongoAbility); @@ -77,10 +78,10 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.Billing); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Billing); - can(OrgPermissionActions.Read, OrgPermissionSubjects.ServiceTokens); - can(OrgPermissionActions.Create, OrgPermissionSubjects.ServiceTokens); - can(OrgPermissionActions.Edit, OrgPermissionSubjects.ServiceTokens); - can(OrgPermissionActions.Delete, OrgPermissionSubjects.ServiceTokens); + can(OrgPermissionActions.Read, OrgPermissionSubjects.MachineIdentity); + can(OrgPermissionActions.Create, OrgPermissionSubjects.MachineIdentity); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.MachineIdentity); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.MachineIdentity); return build({ conditionsMatcher }); }; @@ -105,10 +106,10 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.SecretScanning); can(OrgPermissionActions.Delete, OrgPermissionSubjects.SecretScanning); - can(OrgPermissionActions.Read, OrgPermissionSubjects.ServiceTokens); - can(OrgPermissionActions.Create, OrgPermissionSubjects.ServiceTokens); - can(OrgPermissionActions.Edit, OrgPermissionSubjects.ServiceTokens); - can(OrgPermissionActions.Delete, OrgPermissionSubjects.ServiceTokens); + can(OrgPermissionActions.Read, OrgPermissionSubjects.MachineIdentity); + can(OrgPermissionActions.Create, OrgPermissionSubjects.MachineIdentity); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.MachineIdentity); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.MachineIdentity); return build({ conditionsMatcher }); }; @@ -132,11 +133,11 @@ export const getUserOrgPermissions = async (userId: string, orgId: string) => { throw UnauthorizedRequestError({ message: "User doesn't belong to organization" }); } - if (membership.role === "admin") return { permission: adminPermissions, membership }; + if (membership.role === ADMIN) return { permission: adminPermissions, membership }; - if (membership.role === "member") return { permission: memberPermissions, membership }; + if (membership.role === MEMBER) return { permission: memberPermissions, membership }; - if (membership.role === "custom") { + if (membership.role === CUSTOM) { const permission = createMongoAbility(membership.customRole.permissions, { conditionsMatcher }); @@ -144,4 +145,58 @@ export const getUserOrgPermissions = async (userId: string, orgId: string) => { } throw BadRequestError({ message: "User role not found" }); -}; \ No newline at end of file +}; + +export const getOrgRolePermissions = async (role: string, orgId: string) => { + const isCustomRole = ![ADMIN, MEMBER].includes(role); + if (isCustomRole) { + const orgRole = await Role.findOne({ + slug: role, + isOrgRole: true, + organization: new Types.ObjectId(orgId) + }); + + if (!orgRole) throw BadRequestError({ message: "Org Role not found" }); + + return createMongoAbility(orgRole.permissions as RawRuleOf>[], { + conditionsMatcher + }); + } + + switch (role) { + case ADMIN: + return adminPermissions; + case MEMBER: + return memberPermissions; + default: + throw BadRequestError({ message: "User org role not found" }); + } +} + +/** + * Extracts and formats permissions from a CASL Ability object or a raw permission set. + * @param ability + * @returns + */ +const extractPermissions = (ability: MongoAbility | OrgPermissionSet) => { + return ability.A.map((permission: any) => `${permission.action}_${permission.subject}`); +} + +/** + * 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 isAtLeastAsPrivilegedOrg = (permissions1: MongoAbility | OrgPermissionSet, permissions2: MongoAbility | OrgPermissionSet) => { + + 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; +} \ No newline at end of file diff --git a/backend/src/validation/machineIdentity.ts b/backend/src/validation/machineIdentity.ts index 6536f874e..32be61c99 100644 --- a/backend/src/validation/machineIdentity.ts +++ b/backend/src/validation/machineIdentity.ts @@ -3,7 +3,7 @@ import { MEMBER } from "../variables"; export const RefreshTokenV3 = z.object({ body: z.object({ - refresh_token: z.string().trim() + refreshToken: z.string().trim() }) }); @@ -31,7 +31,6 @@ export const UpdateMachineIdentityV3 = z.object({ }), body: z.object({ name: z.string().trim().optional(), - isActive: z.boolean().optional(), role: z.string().trim().min(1).optional(), trustedIps: z .object({ diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 8f2c95fbf..65315cb16 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -16,7 +16,7 @@ export enum OrgPermissionSubjects { Sso = "sso", Billing = "billing", SecretScanning = "secret-scanning", - ServiceTokens = "service-tokens" + MachineIdentity = "machine-identity" } export type OrgPermissionSet = @@ -29,6 +29,6 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] | [OrgPermissionActions, OrgPermissionSubjects.Billing] - | [OrgPermissionActions, OrgPermissionSubjects.ServiceTokens]; + | [OrgPermissionActions, OrgPermissionSubjects.MachineIdentity]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 5ae91e756..60d33afbd 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -22,7 +22,8 @@ export enum ProjectPermissionSub { Secrets = "secrets", SecretRollback = "secret-rollback", SecretApproval = "secret-approval", - SecretRotation = "secret-rotation" + SecretRotation = "secret-rotation", + MachineIdentity = "machine-identity" } type SubjectFields = { @@ -44,6 +45,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.Environments] | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.Settings] + | [ProjectPermissionActions, ProjectPermissionSub.MachineIdentity] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] diff --git a/frontend/src/views/Org/MembersPage/MembersPage.tsx b/frontend/src/views/Org/MembersPage/MembersPage.tsx index b6338a96d..3b85e2cd4 100644 --- a/frontend/src/views/Org/MembersPage/MembersPage.tsx +++ b/frontend/src/views/Org/MembersPage/MembersPage.tsx @@ -26,7 +26,7 @@ export const MembersPage = withPermission( People Machine Identities - Roles + Organization Roles diff --git a/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/AddMachineIdentityModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/AddMachineIdentityModal.tsx index b76205f08..9439c6daf 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/AddMachineIdentityModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/AddMachineIdentityModal.tsx @@ -32,7 +32,7 @@ import { useGetRoles, useUpdateMachineIdentity } from "@app/hooks/api"; -import { ServiceTokenV3TrustedIp } from "@app/hooks/api/serviceTokens/types"; +import { MachineTrustedIp } from "@app/hooks/api/machineIdentities/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; enum TabSections { @@ -50,7 +50,7 @@ const expirations = [ ]; const schema = yup.object({ - name: yup.string().required("ST V3 name is required"), + name: yup.string().required("MI name is required"), expiresIn: yup.string(), accessTokenTTL: yup .string() @@ -63,7 +63,7 @@ const schema = yup.object({ return !Number.isNaN(num) && num > 0 && String(num) === value; }) .required("Access Token TTL is required"), - role: yup.string().required("ST V3 role is required"), + role: yup.string(), trustedIps: yup .array( yup.object({ @@ -146,7 +146,7 @@ export const AddMachineIdentityModal = ({ name: string; slug: string; }; - trustedIps: ServiceTokenV3TrustedIp[]; + trustedIps: MachineTrustedIp[]; accessTokenTTL: number; isRefreshTokenRotationEnabled: boolean; }; @@ -161,7 +161,7 @@ export const AddMachineIdentityModal = ({ trustedIps: machineIdentity.trustedIps.map(({ ipAddress, prefix - }: ServiceTokenV3TrustedIp) => { + }: MachineTrustedIp) => { return ({ ipAddress: `${ipAddress}${prefix !== undefined ? `/${prefix}` : ""}` }); @@ -206,9 +206,9 @@ export const AddMachineIdentityModal = ({ await updateMutateAsync({ machineId: machineIdentity.machineId, name, - role, + role: role || undefined, trustedIps, - expiresIn: expiresIn === "" ? undefined : Number(expiresIn), + expiresIn: (!expiresIn) ? undefined : Number(expiresIn), accessTokenTTL: Number(accessTokenTTL), isRefreshTokenRotationEnabled }); @@ -218,10 +218,10 @@ export const AddMachineIdentityModal = ({ const { refreshToken } = await createMutateAsync({ name, - role, + role: role || undefined, organizationId: orgId, trustedIps, - expiresIn: expiresIn === "" ? undefined : Number(expiresIn), + expiresIn: (!expiresIn) ? undefined : Number(expiresIn), accessTokenTTL: Number(accessTokenTTL), isRefreshTokenRotationEnabled }); diff --git a/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/MachineIdentitySection.tsx b/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/MachineIdentitySection.tsx index 852e8d96a..7237d7746 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/MachineIdentitySection.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgMachineIdentityTab/components/MachineIdentitySection/MachineIdentitySection.tsx @@ -53,7 +53,7 @@ export const MachineIdentitySection = withPermission(

{(isAllowed) => (