diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 8e9cdf3bb..0638034de 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -211,11 +211,31 @@ export const PROJECT_IDENTITIES = { UPDATE_IDENTITY_MEMBERSHIP: { projectId: "The ID of the project to update the identity membership for.", identityId: "The ID of the identity to update the membership for.", - roles: "A list of roles to update the membership to." + roles: { + description: "A list of role slugs to assign to the identity project membership.", + role: "The role slug to assign to the newly created identity project membership.", + isTemporary: "Whether the assigned role is temporary.", + temporaryMode: "Type of temporary expiry.", + temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s,2m,3h", + temporaryAccessStartTime: "Time to which the temporary access starts" + } }, DELETE_IDENTITY_MEMBERSHIP: { projectId: "The ID of the project to delete the identity membership from.", identityId: "The ID of the identity to delete the membership from." + }, + CREATE_IDENTITY_MEMBERSHIP: { + projectId: "The ID of the project to create the identity membership from.", + identityId: "The ID of the identity to create the membership from.", + role: "The role slug to assign to the newly created identity project membership.", + roles: { + description: "A list of role slugs to assign to the newly created identity project membership.", + role: "The role slug to assign to the newly created identity project membership.", + isTemporary: "Whether the assigned role is temporary.", + temporaryMode: "Type of temporary expiry.", + temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s,2m,3h", + temporaryAccessStartTime: "Time to which the temporary access starts" + } } }; diff --git a/backend/src/server/routes/v2/identity-project-router.ts b/backend/src/server/routes/v2/identity-project-router.ts index 476b09343..00bb6a6cc 100644 --- a/backend/src/server/routes/v2/identity-project-router.ts +++ b/backend/src/server/routes/v2/identity-project-router.ts @@ -8,6 +8,7 @@ import { ProjectUserMembershipRolesSchema } from "@app/db/schemas"; import { PROJECT_IDENTITIES } from "@app/lib/api-docs"; +import { BadRequestError } from "@app/lib/errors"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -22,12 +23,48 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + description: "Create project identity membership", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ projectId: z.string().trim(), identityId: z.string().trim() }), body: z.object({ - role: z.string().trim().min(1).default(ProjectMembershipRole.NoAccess) + // @depreciated + role: z.string().trim().optional().default(ProjectMembershipRole.NoAccess), + roles: z + .array( + z.union([ + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + }), + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryMode: z + .nativeEnum(ProjectUserMembershipTemporaryMode) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + }) + ]) + ) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.description) + .optional() }), response: { 200: z.object({ @@ -36,6 +73,9 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) } }, handler: async (req) => { + const { role, roles } = req.body; + if (!role && !roles) throw new BadRequestError({ message: "You must provide either role or roles field" }); + const identityMembership = await server.services.identityProject.createProjectIdentity({ actor: req.permission.type, actorId: req.permission.id, @@ -43,7 +83,7 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) actorOrgId: req.permission.orgId, identityId: req.params.identityId, projectId: req.params.projectId, - role: req.body.role + roles: roles || [{ role }] }); return { identityMembership }; } @@ -72,20 +112,31 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) .array( z.union([ z.object({ - role: z.string(), - isTemporary: z.literal(false).default(false) + role: z.string().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary) }), z.object({ - role: z.string(), - isTemporary: z.literal(true), - temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), - temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), - temporaryAccessStartTime: z.string().datetime() + role: z.string().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary), + temporaryMode: z + .nativeEnum(ProjectUserMembershipTemporaryMode) + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryAccessStartTime) }) ]) ) .min(1) - .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles) + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.description) }), response: { 200: z.object({ diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index 13ecf8bbc..2494bfb0e 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -51,7 +51,7 @@ export const identityProjectServiceFactory = ({ actorOrgId, actorAuthMethod, projectId, - role + roles }: TCreateProjectIdentityDTO) => { const { permission } = await permissionService.getProjectPermission( actor, @@ -78,18 +78,33 @@ export const identityProjectServiceFactory = ({ message: `Failed to find identity with id ${identityId}` }); - const { permission: rolePermission, role: customRole } = await permissionService.getProjectPermissionByRole( - role, - project.id + for await (const { role: requestedRoleChange } of roles) { + const { permission: rolePermission } = await permissionService.getProjectPermissionByRole( + requestedRoleChange, + projectId + ); + + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); + + if (!hasRequiredPriviledges) { + throw new ForbiddenRequestError({ message: "Failed to change to a more privileged role" }); + } + } + + // validate custom roles input + const customInputRoles = roles.filter( + ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) ); + const hasCustomRole = Boolean(customInputRoles.length); + const customRoles = hasCustomRole + ? await projectRoleDAL.find({ + projectId, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) throw new BadRequestError({ message: "Custom role not found" }); - const hasPriviledge = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasPriviledge) - throw new ForbiddenRequestError({ - message: "Failed to add identity to project with more privileged role" - }); - const isCustomRole = Boolean(customRole); - + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); const projectIdentity = await identityProjectDAL.transaction(async (tx) => { const identityProjectMembership = await identityProjectDAL.create( { @@ -98,16 +113,32 @@ export const identityProjectServiceFactory = ({ }, tx ); + const sanitizedProjectMembershipRoles = roles.map((inputRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); + if (!inputRole.isTemporary) { + return { + projectMembershipId: identityProjectMembership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null + }; + } - await identityProjectMembershipRoleDAL.create( - { + // check cron or relative here later for now its just relative + const relativeTimeInMs = ms(inputRole.temporaryRange); + return { projectMembershipId: identityProjectMembership.id, - role: isCustomRole ? ProjectMembershipRole.Custom : role, - customRoleId: customRole?.id - }, - tx - ); - return identityProjectMembership; + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null, + isTemporary: true, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: inputRole.temporaryRange, + temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime), + temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs) + }; + }); + + const identityRoles = await identityProjectMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); + return { ...identityProjectMembership, roles: identityRoles }; }); return projectIdentity; }; diff --git a/backend/src/services/identity-project/identity-project-types.ts b/backend/src/services/identity-project/identity-project-types.ts index 73e8ec246..adead31e3 100644 --- a/backend/src/services/identity-project/identity-project-types.ts +++ b/backend/src/services/identity-project/identity-project-types.ts @@ -4,7 +4,19 @@ import { ProjectUserMembershipTemporaryMode } from "../project-membership/projec export type TCreateProjectIdentityDTO = { identityId: string; - role: string; + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; } & TProjectPermission; export type TUpdateProjectIdentityDTO = { diff --git a/docs/api-reference/endpoints/project-identities/add-identity-membership.mdx b/docs/api-reference/endpoints/project-identities/add-identity-membership.mdx new file mode 100644 index 000000000..391e4b1c3 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities/add-identity-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Add Identity Project Membership" +openapi: "POST /api/v2/workspace/{projectId}/identity-memberships/{identityId}" +--- diff --git a/docs/api-reference/endpoints/project-identities/delete-identity-membership.mdx b/docs/api-reference/endpoints/project-identities/delete-identity-membership.mdx index e2b266626..ba04ca884 100644 --- a/docs/api-reference/endpoints/project-identities/delete-identity-membership.mdx +++ b/docs/api-reference/endpoints/project-identities/delete-identity-membership.mdx @@ -1,4 +1,4 @@ --- -title: "Delete Identity Membership" +title: "Delete Project Identity Membership" openapi: "DELETE /api/v2/workspace/{projectId}/identity-memberships/{identityId}" --- diff --git a/docs/api-reference/endpoints/project-identities/list-identity-memberships.mdx b/docs/api-reference/endpoints/project-identities/list-identity-memberships.mdx index e5162e693..aeadaa5f5 100644 --- a/docs/api-reference/endpoints/project-identities/list-identity-memberships.mdx +++ b/docs/api-reference/endpoints/project-identities/list-identity-memberships.mdx @@ -1,4 +1,4 @@ --- -title: "List Identity Memberships" +title: "List Project Identity Memberships" openapi: "GET /api/v2/workspace/{projectId}/identity-memberships" --- diff --git a/docs/api-reference/endpoints/project-identities/update-identity-membership.mdx b/docs/api-reference/endpoints/project-identities/update-identity-membership.mdx index 667cf7eb3..f77e05f0a 100644 --- a/docs/api-reference/endpoints/project-identities/update-identity-membership.mdx +++ b/docs/api-reference/endpoints/project-identities/update-identity-membership.mdx @@ -1,4 +1,4 @@ --- -title: "Update Identity Membership" +title: "Update Project Identity Membership" openapi: "PATCH /api/v2/workspace/{projectId}/identity-memberships/{identityId}" --- diff --git a/docs/mint.json b/docs/mint.json index f0ca7d1e9..12d0b4978 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -1,6 +1,6 @@ { "name": "Infisical", - "openapi": "https://app.infisical.com/api/docs/json", + "openapi": "http://localhost:8080/api/docs/json", "logo": { "dark": "/logo/dark.svg", "light": "/logo/light.svg", @@ -463,8 +463,9 @@ ] }, { - "group": "Projects", + "group": "Project Identities", "pages": [ + "api-reference/endpoints/project-identities/add-identity-membership", "api-reference/endpoints/project-identities/list-identity-memberships", "api-reference/endpoints/project-identities/update-identity-membership", "api-reference/endpoints/project-identities/delete-identity-membership"