diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 927694ef4..82f67778e 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -33,11 +33,13 @@ export enum ApiDocsTags { LdapAuth = "LDAP Auth", Groups = "Groups", Organizations = "Organizations", + OrgIdentityMembership = "Organization Identity Membership", SubOrganizations = "Sub Organizations", Projects = "Projects", ProjectUsers = "Project Users", ProjectGroups = "Project Groups", ProjectIdentities = "Project Identities", + ProjectIdentityMembership = "Project Identity Membership", ProjectRoles = "Project Roles", ProjectTemplates = "Project Templates", Environments = "Environments", @@ -719,6 +721,49 @@ export const ORGANIZATIONS = { } } as const; +export const ORG_IDENTITY_MEMBERSHIP = { + CREATE_IDENTITY_MEMBERSHIP: { + identityId: "The ID of the machine identity to create the membership for.", + roles: { + description: "A list of role slugs to assign to the identity organization membership.", + role: "The role slug to assign to the newly created identity organization membership.", + isTemporary: + "Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.", + temporaryMode: "Type of temporary expiry.", + temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s, 2m, 3h, etc.", + temporaryAccessStartTime: "Time to which the temporary access starts." + } + }, + UPDATE_IDENTITY_MEMBERSHIP: { + identityId: "The ID of the machine identity to update the membership for.", + roles: { + description: "A list of role slugs to assign to the identity organization membership.", + role: "The role slug to assign to the identity organization membership.", + isTemporary: + "Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.", + temporaryMode: "Type of temporary expiry.", + temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s, 2m, 3h, etc.", + temporaryAccessStartTime: "Time to which the temporary access starts." + } + }, + DELETE_IDENTITY_MEMBERSHIP: { + identityId: "The ID of the machine identity to delete the membership from." + }, + LIST_IDENTITY_MEMBERSHIPS: { + offset: "The offset to start from. If you enter 10, it will start from the 10th identity membership.", + limit: "The number of identity memberships to return.", + identityName: "The text string that identity membership names will be filtered by.", + roles: "The role slugs to filter identity memberships by." + }, + GET_IDENTITY_MEMBERSHIP_BY_ID: { + identityId: "The ID of the machine identity to get the membership for." + }, + LIST_AVAILABLE_IDENTITIES: { + offset: "The offset to start from. If you enter 10, it will start from the 10th identity.", + limit: "The number of identities to return." + } +} as const; + export const SUB_ORGANIZATIONS = { CREATE: { name: "The name of the sub organization to create." @@ -907,6 +952,55 @@ export const PROJECT_IDENTITIES = { } }; +export const PROJECT_IDENTITY_MEMBERSHIP = { + CREATE_IDENTITY_MEMBERSHIP: { + projectId: "The ID of the project to create the identity membership for.", + identityId: "The ID of the machine identity to create the membership for.", + 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. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.", + temporaryMode: "Type of temporary expiry.", + temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s, 2m, 3h, etc.", + temporaryAccessStartTime: "Time to which the temporary access starts." + } + }, + UPDATE_IDENTITY_MEMBERSHIP: { + projectId: "The ID of the project to update the identity membership for.", + identityId: "The ID of the machine identity to update the membership for.", + roles: { + description: "A list of role slugs to assign to the identity project membership.", + role: "The role slug to assign to the identity project membership.", + isTemporary: + "Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.", + temporaryMode: "Type of temporary expiry.", + temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s, 2m, 3h, etc.", + 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 machine identity to delete the membership from." + }, + LIST_IDENTITY_MEMBERSHIPS: { + projectId: "The ID of the project to list identity memberships from.", + offset: "The offset to start from. If you enter 10, it will start from the 10th identity membership.", + limit: "The number of identity memberships to return.", + identityName: "The text string that identity membership names will be filtered by.", + roles: "The role slugs to filter identity memberships by." + }, + GET_IDENTITY_MEMBERSHIP_BY_ID: { + projectId: "The ID of the project to get the identity membership for.", + identityId: "The ID of the machine identity to get the membership for." + }, + LIST_AVAILABLE_IDENTITIES: { + projectId: "The ID of the project to list available identities for.", + offset: "The offset to start from. If you enter 10, it will start from the 10th identity.", + limit: "The number of identities to return." + } +} as const; + export const ENVIRONMENTS = { CREATE: { projectId: "The ID of the project to create the environment in.", diff --git a/backend/src/server/routes/v1/identity-org-membership-router.ts b/backend/src/server/routes/v1/identity-org-membership-router.ts index c9b93965a..c50e0d423 100644 --- a/backend/src/server/routes/v1/identity-org-membership-router.ts +++ b/backend/src/server/routes/v1/identity-org-membership-router.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -import { AccessScope, TemporaryPermissionMode } from "@app/db/schemas"; -import { ApiDocsTags, PROJECT_IDENTITIES } from "@app/lib/api-docs"; +import { AccessScope, IdentitiesSchema, MembershipRolesSchema, TemporaryPermissionMode } from "@app/db/schemas"; +import { ApiDocsTags, ORG_IDENTITY_MEMBERSHIP } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; -import { writeLimit } from "@app/server/config/rateLimiter"; +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"; @@ -25,8 +25,7 @@ export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProv onRequest: verifyAuth([AuthMode.JWT]), schema: { hide: true, - // this is hidden so not updating tags - tags: [ApiDocsTags.ProjectIdentities], + tags: [ApiDocsTags.OrgIdentityMembership], description: "Create org identity membership", security: [ { @@ -34,38 +33,40 @@ export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProv } ], params: z.object({ - identityId: z.string().trim() + identityId: z.string().trim().describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.identityId) }), body: z.object({ roles: z .array( z.union([ z.object({ - role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + role: z.string().describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.role), isTemporary: z .literal(false) .default(false) - .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + .describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.isTemporary) }), 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), + role: z.string().describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(true) + .describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.isTemporary), temporaryMode: z .nativeEnum(TemporaryPermissionMode) - .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + .describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.temporaryMode), temporaryRange: z .string() .refine((val) => ms(val) > 0, "Temporary range must be a positive number") - .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + .describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.temporaryRange), temporaryAccessStartTime: z .string() .datetime() - .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + .describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.temporaryAccessStartTime) }) ]) ) - .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.description) - .max(1) + .describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.description) + .min(1) }), response: { 200: z.object({ @@ -92,6 +93,85 @@ export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProv } }); + server.route({ + method: "PATCH", + url: "/identity-memberships/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: true, + tags: [ApiDocsTags.OrgIdentityMembership], + description: "Update org identity membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.identityId) + }), + body: z.object({ + roles: z + .array( + z.union([ + z.object({ + role: z.string().describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary) + }), + z.object({ + role: z.string().describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(true) + .describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary), + temporaryMode: z + .nativeEnum(TemporaryPermissionMode) + .describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryAccessStartTime) + }) + ]) + ) + .min(1) + .describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.description) + }), + response: { + 200: z.object({ + roles: MembershipRolesSchema.array() + }) + } + }, + handler: async (req) => { + const { membership } = await server.services.membershipIdentity.updateMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + selector: { + identityId: req.params.identityId + }, + data: { + roles: req.body.roles + } + }); + + return { + roles: membership.roles.map((el) => ({ ...el, membershipId: membership.id })) + }; + } + }); + server.route({ method: "DELETE", url: "/identity-memberships/:identityId", @@ -101,15 +181,15 @@ export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProv onRequest: verifyAuth([AuthMode.JWT]), schema: { hide: true, - tags: [ApiDocsTags.ProjectIdentities], - description: "Delete org identity memberships", + tags: [ApiDocsTags.OrgIdentityMembership], + description: "Delete org identity membership", security: [ { bearerAuth: [] } ], params: z.object({ - identityId: z.string().trim().describe(PROJECT_IDENTITIES.DELETE_IDENTITY_MEMBERSHIP.identityId) + identityId: z.string().trim().describe(ORG_IDENTITY_MEMBERSHIP.DELETE_IDENTITY_MEMBERSHIP.identityId) }), response: { 200: z.object({ @@ -134,4 +214,208 @@ export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProv }; } }); + + server.route({ + method: "GET", + url: "/identity-memberships", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: true, + tags: [ApiDocsTags.OrgIdentityMembership], + description: "List org identity memberships", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + offset: z.coerce + .number() + .min(0) + .default(0) + .describe(ORG_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.offset) + .optional(), + limit: z.coerce + .number() + .min(1) + .max(100) + .default(20) + .describe(ORG_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.limit) + .optional(), + identityName: z + .string() + .trim() + .describe(ORG_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.identityName) + .optional(), + roles: z + .string() + .transform((val) => val.split(",").map((role) => role.trim())) + .describe(ORG_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.roles) + .optional() + }), + response: { + 200: z.object({ + identityMemberships: z + .object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + identity: IdentitiesSchema.pick({ name: true, id: true, orgId: true, projectId: true }) + }) + .array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { data: identityMemberships, totalCount } = await server.services.membershipIdentity.listMemberships({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + data: { + offset: req.query.offset, + limit: req.query.limit, + identityName: req.query.identityName, + roles: req.query.roles + } + }); + + return { identityMemberships, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/identity-memberships/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: true, + tags: [ApiDocsTags.OrgIdentityMembership], + description: "Get org identity membership by identity ID", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(ORG_IDENTITY_MEMBERSHIP.GET_IDENTITY_MEMBERSHIP_BY_ID.identityId) + }), + response: { + 200: z.object({ + identityMembership: z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + identity: IdentitiesSchema.pick({ name: true, id: true, orgId: true, projectId: true }).extend({ + authMethods: z.array(z.string()) + }) + }) + }) + } + }, + handler: async (req) => { + const identityMembership = await server.services.membershipIdentity.getMembershipByIdentityId({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + selector: { + identityId: req.params.identityId + } + }); + + return { identityMembership }; + } + }); + + server.route({ + method: "GET", + url: "/available-identities", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: false, + tags: [ApiDocsTags.OrgIdentityMembership], + description: "List available identities for org membership", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + offset: z.coerce + .number() + .min(0) + .default(0) + .describe(ORG_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.offset) + .optional(), + limit: z.coerce + .number() + .min(1) + .max(100) + .default(20) + .describe(ORG_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.limit) + .optional() + }), + response: { + 200: z.object({ + identities: IdentitiesSchema.pick({ id: true, name: true }).array() + }) + } + }, + handler: async (req) => { + const { identities } = await server.services.membershipIdentity.listAvailableIdentities({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + data: { + offset: req.query.offset, + limit: req.query.limit + } + }); + + return { identities }; + } + }); }; diff --git a/backend/src/server/routes/v1/identity-project-membership-router.ts b/backend/src/server/routes/v1/identity-project-membership-router.ts new file mode 100644 index 000000000..de6e44a95 --- /dev/null +++ b/backend/src/server/routes/v1/identity-project-membership-router.ts @@ -0,0 +1,438 @@ +import { z } from "zod"; + +import { AccessScope, IdentitiesSchema, MembershipRolesSchema, TemporaryPermissionMode } from "@app/db/schemas"; +import { ApiDocsTags, PROJECT_IDENTITY_MEMBERSHIP } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +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"; + +const sanitizedProjectIdentityMembershipSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + identityId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export const registerProjectIdentityMembershipRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/:projectId/identity-memberships/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectIdentityMembership], + description: "Create project identity membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.identityId) + }), + body: z.object({ + roles: z + .array( + z.union([ + z.object({ + role: z.string().describe(PROJECT_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(PROJECT_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.isTemporary) + }), + z.object({ + role: z.string().describe(PROJECT_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(true) + .describe(PROJECT_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.isTemporary), + temporaryMode: z + .nativeEnum(TemporaryPermissionMode) + .describe(PROJECT_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.temporaryAccessStartTime) + }) + ]) + ) + .describe(PROJECT_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.description) + .min(1) + }), + response: { + 200: z.object({ + identityMembership: sanitizedProjectIdentityMembershipSchema + }) + } + }, + handler: async (req) => { + const { membership } = await server.services.membershipIdentity.createMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + data: { + identityId: req.params.identityId, + roles: req.body.roles + } + }); + + return { + identityMembership: { ...membership, identityId: req.params.identityId, projectId: req.params.projectId } + }; + } + }); + + server.route({ + method: "PATCH", + url: "/:projectId/identity-memberships/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectIdentityMembership], + description: "Update project identity membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.identityId) + }), + body: z.object({ + roles: z + .array( + z.union([ + z.object({ + role: z.string().describe(PROJECT_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(PROJECT_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary) + }), + z.object({ + role: z.string().describe(PROJECT_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(true) + .describe(PROJECT_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary), + temporaryMode: z + .nativeEnum(TemporaryPermissionMode) + .describe(PROJECT_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryAccessStartTime) + }) + ]) + ) + .min(1) + .describe(PROJECT_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.description) + }), + response: { + 200: z.object({ + roles: MembershipRolesSchema.array() + }) + } + }, + handler: async (req) => { + const { membership } = await server.services.membershipIdentity.updateMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + identityId: req.params.identityId + }, + data: { + roles: req.body.roles + } + }); + + return { + roles: membership.roles.map((el) => ({ ...el, membershipId: membership.id })) + }; + } + }); + + server.route({ + method: "DELETE", + url: "/:projectId/identity-memberships/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectIdentityMembership], + description: "Delete project identity membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITY_MEMBERSHIP.DELETE_IDENTITY_MEMBERSHIP.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITY_MEMBERSHIP.DELETE_IDENTITY_MEMBERSHIP.identityId) + }), + response: { + 200: z.object({ + identityMembership: sanitizedProjectIdentityMembershipSchema + }) + } + }, + handler: async (req) => { + const { membership } = await server.services.membershipIdentity.deleteMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + identityId: req.params.identityId + } + }); + + return { + identityMembership: { ...membership, identityId: req.params.identityId, projectId: req.params.projectId } + }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/identity-memberships", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectIdentityMembership], + description: "List project identity memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.projectId) + }), + querystring: z.object({ + offset: z.coerce + .number() + .min(0) + .default(0) + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.offset) + .optional(), + limit: z.coerce + .number() + .min(1) + .max(100) + .default(20) + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.limit) + .optional(), + identityName: z + .string() + .trim() + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.identityName) + .optional(), + roles: z + .string() + .transform((val) => val.split(",").map((role) => role.trim())) + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.roles) + .optional() + }), + response: { + 200: z.object({ + identityMemberships: z + .object({ + id: z.string(), + identityId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + identity: IdentitiesSchema.pick({ name: true, id: true, orgId: true, projectId: true }) + }) + .array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { data: identityMemberships, totalCount } = await server.services.membershipIdentity.listMemberships({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + data: { + offset: req.query.offset, + limit: req.query.limit, + identityName: req.query.identityName, + roles: req.query.roles + } + }); + + return { identityMemberships, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/identity-memberships/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectIdentityMembership], + description: "Get project identity membership by identity ID", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITY_MEMBERSHIP.GET_IDENTITY_MEMBERSHIP_BY_ID.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITY_MEMBERSHIP.GET_IDENTITY_MEMBERSHIP_BY_ID.identityId) + }), + response: { + 200: z.object({ + identityMembership: z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + identity: IdentitiesSchema.pick({ name: true, id: true, orgId: true, projectId: true }).extend({ + authMethods: z.array(z.string()) + }) + }) + }) + } + }, + handler: async (req) => { + const identityMembership = await server.services.membershipIdentity.getMembershipByIdentityId({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + identityId: req.params.identityId + } + }); + + return { identityMembership }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/available-identities", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectIdentityMembership], + description: "List available identities for project membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.projectId) + }), + querystring: z.object({ + offset: z.coerce + .number() + .min(0) + .default(0) + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.offset) + .optional(), + limit: z.coerce + .number() + .min(1) + .max(100) + .default(20) + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.limit) + .optional() + }), + response: { + 200: z.object({ + identities: IdentitiesSchema.pick({ id: true, name: true }).array() + }) + } + }, + handler: async (req) => { + const { identities } = await server.services.membershipIdentity.listAvailableIdentities({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + data: { + offset: req.query.offset, + limit: req.query.limit + } + }); + + return { identities }; + } + }); +}; diff --git a/backend/src/services/membership-identity/membership-identity-dal.ts b/backend/src/services/membership-identity/membership-identity-dal.ts index 682bfef3e..4ff52ec0f 100644 --- a/backend/src/services/membership-identity/membership-identity-dal.ts +++ b/backend/src/services/membership-identity/membership-identity-dal.ts @@ -92,6 +92,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { db.ref("name").withSchema(TableName.Identity).as("identityName"), db.ref("id").withSchema(TableName.Identity).as("identityId"), db.ref("orgId").withSchema(TableName.Identity).as("identityOrgId"), + db.ref("projectId").withSchema(TableName.Identity).as("identityProjectId"), db.ref("hasDeleteProtection").withSchema(TableName.Identity).as("identityHasDeleteProtection"), db.ref("slug").withSchema(TableName.Role).as("roleSlug"), @@ -134,6 +135,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { const { identityId: actorIdentityId, identityOrgId, + identityProjectId, identityHasDeleteProtection, identityName, uaId, @@ -155,7 +157,8 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { name: identityName, id: actorIdentityId, hasDeleteProtection: identityHasDeleteProtection, - identityOrgId, + orgId: identityOrgId, + projectId: identityProjectId, authMethods: buildAuthMethods({ uaId, awsId, @@ -281,6 +284,8 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { .select( db.ref("name").withSchema(TableName.Identity).as("identityName"), db.ref("id").withSchema(TableName.Identity).as("identityId"), + db.ref("orgId").withSchema(TableName.Identity).as("identityOrgId"), + db.ref("projectId").withSchema(TableName.Identity).as("identityProjectId"), db.ref("hasDeleteProtection").withSchema(TableName.Identity).as("identityHasDeleteProtection"), db.ref("slug").withSchema(TableName.Role).as("roleSlug"), @@ -310,13 +315,22 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { data: docs, key: "id", parentMapper: (el) => { - const { identityId: actorIdentityId, identityHasDeleteProtection, identityName } = el; + const { + identityId: actorIdentityId, + identityHasDeleteProtection, + identityName, + identityProjectId, + identityOrgId + } = el; return { ...MembershipsSchema.parse(el), + identityId: actorIdentityId, identity: { name: identityName, id: actorIdentityId, - hasDeleteProtection: identityHasDeleteProtection + hasDeleteProtection: identityHasDeleteProtection, + orgId: identityOrgId, + projectId: identityProjectId } }; }, @@ -356,14 +370,18 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { } }; - // this right now only support sub organization - const listAvailableIdentities = async (orgId: string, rootOrgId: string) => { + const listAvailableIdentities = async (scopeData: AccessScopeData, rootOrgId: string) => { try { - const usersConnectedToOrg = db + const identitesConnectedToOrg = db .replicaNode()(TableName.Membership) .whereNotNull(`${TableName.Membership}.actorIdentityId`) - .where(`${TableName.Membership}.scope`, AccessScope.Organization) - .where(`${TableName.Membership}.scopeOrgId`, orgId) + .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) + .where(`${TableName.Membership}.scope`, scopeData.scope) + .where((qb) => { + if (scopeData.scope === AccessScope.Project) { + void qb.where(`${TableName.Membership}.scopeProjectId`, scopeData.projectId); + } + }) .select("actorIdentityId"); const docs = await db @@ -371,8 +389,15 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Membership}.actorIdentityId`) .where(`${TableName.Membership}.scope`, AccessScope.Organization) .whereNotNull(`${TableName.Membership}.actorIdentityId`) - .where(`${TableName.Membership}.scopeOrgId`, rootOrgId) - .whereNotIn(`${TableName.Membership}.actorIdentityId`, usersConnectedToOrg) + .where((qb) => { + // if sub org pick from root and if project pick from org of project + if (scopeData.scope === AccessScope.Organization) { + void qb.where(`${TableName.Membership}.scopeOrgId`, rootOrgId); + } else { + void qb.where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId); + } + }) + .whereNotIn(`${TableName.Membership}.actorIdentityId`, identitesConnectedToOrg) .select( db.ref("id").withSchema(TableName.Identity), db.ref("name").withSchema(TableName.Identity), diff --git a/backend/src/services/membership-identity/membership-identity-service.ts b/backend/src/services/membership-identity/membership-identity-service.ts index 3f3eb6d6e..1cbc8aa01 100644 --- a/backend/src/services/membership-identity/membership-identity-service.ts +++ b/backend/src/services/membership-identity/membership-identity-service.ts @@ -340,13 +340,9 @@ export const membershipIdentityServiceFactory = ({ await factory.onListMembershipIdentityGuard(dto); - const organizationDetails = await orgDAL.findById(dto.scopeData.orgId); - if (!organizationDetails.rootOrgId) return { identities: [] }; + if (dto.permission.rootOrgId === dto.permission.orgId) return { identities: [] }; - const identities = await membershipIdentityDAL.listAvailableIdentities( - organizationDetails.id, - organizationDetails.rootOrgId - ); + const identities = await membershipIdentityDAL.listAvailableIdentities(dto.scopeData, dto.permission.rootOrgId); return { identities }; };