From b7644c929419470658b72a9876b9c8fe93ed419d Mon Sep 17 00:00:00 2001 From: = Date: Sun, 19 Oct 2025 21:41:21 +0530 Subject: [PATCH] feat: added root org identity link functionality --- backend/src/ee/routes/v1/sub-org-router.ts | 6 +- .../ee/services/license/license-service.ts | 15 +- backend/src/server/routes/index.ts | 4 +- .../v1/identity-org-membership-router.ts | 137 ++++++++++++++++++ backend/src/server/routes/v1/index.ts | 2 + .../server/routes/v1/organization-router.ts | 65 +++++++++ .../services/auth-token/auth-token-service.ts | 2 +- .../src/services/identity/identity-service.ts | 6 +- .../membership-identity-dal.ts | 34 ++++- .../membership-identity-service.ts | 30 +++- .../membership-identity-types.ts | 5 +- .../org/org-membership-identity-factory.ts | 87 +++++++++-- .../membership-user/membership-user-dal.ts | 40 ++++- .../membership-user-service.ts | 20 ++- .../membership-user/membership-user-types.ts | 5 + .../org/org-membership-user-factory.ts | 23 ++- .../OrganizationContext.tsx | 3 +- .../hooks/api/orgIdentityMembership/index.tsx | 2 + .../api/orgIdentityMembership/mutation.tsx | 42 ++++++ .../hooks/api/orgIdentityMembership/types.ts | 30 ++++ frontend/src/hooks/api/organization/index.ts | 1 + .../src/hooks/api/organization/queries.tsx | 30 +++- .../IdentitySection/IdentityLinkForm.tsx | 129 +++++++++++++++++ .../IdentitySection/IdentitySection.tsx | 60 ++++++-- .../pages/organization/BillingPage/route.tsx | 11 +- .../components/OrgTabGroup/OrgTabGroup.tsx | 46 ++++-- 26 files changed, 753 insertions(+), 82 deletions(-) create mode 100644 backend/src/server/routes/v1/identity-org-membership-router.ts create mode 100644 frontend/src/hooks/api/orgIdentityMembership/index.tsx create mode 100644 frontend/src/hooks/api/orgIdentityMembership/mutation.tsx create mode 100644 frontend/src/hooks/api/orgIdentityMembership/types.ts create mode 100644 frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLinkForm.tsx diff --git a/backend/src/ee/routes/v1/sub-org-router.ts b/backend/src/ee/routes/v1/sub-org-router.ts index 281b54e35..aed2b63d6 100644 --- a/backend/src/ee/routes/v1/sub-org-router.ts +++ b/backend/src/ee/routes/v1/sub-org-router.ts @@ -49,7 +49,8 @@ export const registerSubOrgRouter = async (server: FastifyZodProvider) => { type: req.permission.type, authMethod: req.permission.authMethod, orgId: req.permission.orgId, - parentOrgId: req.permission.parentOrgId + parentOrgId: req.permission.parentOrgId, + rootOrgId: req.permission.rootOrgId } }); @@ -107,7 +108,8 @@ export const registerSubOrgRouter = async (server: FastifyZodProvider) => { type: req.permission.type, authMethod: req.permission.authMethod, orgId: req.permission.orgId, - parentOrgId: req.permission.orgId + parentOrgId: req.permission.orgId, + rootOrgId: req.permission.rootOrgId }, data: { limit: req.query.limit, diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 2d539dbf0..8fc4987f4 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -285,19 +285,20 @@ export const licenseServiceFactory = ({ }; const updateSubscriptionOrgMemberCount = async (orgId: string, tx?: Knex) => { - if (instanceType === InstanceType.Cloud) { - const org = await orgDAL.findOrgById(orgId); - if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); + const org = await orgDAL.findOrgById(orgId); + if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); - const quantity = await licenseDAL.countOfOrgMembers(orgId, tx); - const quantityIdentities = await licenseDAL.countOrgUsersAndIdentities(orgId, tx); + const rootOrgId = org.rootOrgId || org.id; + if (instanceType === InstanceType.Cloud) { + const quantity = await licenseDAL.countOfOrgMembers(rootOrgId, tx); + const quantityIdentities = await licenseDAL.countOrgUsersAndIdentities(rootOrgId, tx); if (org?.customerId) { await licenseServerCloudApi.request.patch(`/api/license-server/v1/customers/${org.customerId}/cloud-plan`, { quantity, quantityIdentities }); } - await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); + await keyStore.deleteItem(FEATURE_CACHE_KEY(rootOrgId)); } else if (instanceType === InstanceType.EnterpriseOnPrem) { const usedSeats = await licenseDAL.countOfOrgMembers(null, tx); const usedIdentitySeats = await licenseDAL.countOrgUsersAndIdentities(null, tx); @@ -308,7 +309,7 @@ export const licenseServiceFactory = ({ usedIdentitySeats }); } - await refreshPlan(orgId); + await refreshPlan(rootOrgId); }; // below all are api calls diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index bcf508835..f23fcb7f4 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -585,6 +585,7 @@ export const registerRoutes = async ( }); const membershipIdentityService = membershipIdentityServiceFactory({ + identityDAL, membershipIdentityDAL, membershipRoleDAL, orgDAL, @@ -1577,7 +1578,8 @@ export const registerRoutes = async ( permissionService, projectDAL, accessTokenQueue, - smtpService + smtpService, + orgDAL }); const identityService = identityServiceFactory({ diff --git a/backend/src/server/routes/v1/identity-org-membership-router.ts b/backend/src/server/routes/v1/identity-org-membership-router.ts new file mode 100644 index 000000000..c9b93965a --- /dev/null +++ b/backend/src/server/routes/v1/identity-org-membership-router.ts @@ -0,0 +1,137 @@ +import { z } from "zod"; + +import { AccessScope, TemporaryPermissionMode } from "@app/db/schemas"; +import { ApiDocsTags, PROJECT_IDENTITIES } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const sanitizedOrgIdentityMembershipSchema = z.object({ + id: z.string().uuid(), + orgId: z.string(), + identityId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/identity-memberships/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: true, + // this is hidden so not updating tags + tags: [ApiDocsTags.ProjectIdentities], + description: "Create org identity membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + 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(TemporaryPermissionMode) + .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) + .max(1) + }), + response: { + 200: z.object({ + identityMembership: sanitizedOrgIdentityMembershipSchema + }) + } + }, + handler: async (req) => { + const { membership } = await server.services.membershipIdentity.createMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + data: { + identityId: req.params.identityId, + roles: req.body.roles + } + }); + + return { + identityMembership: { ...membership, identityId: req.params.identityId, orgId: req.permission.orgId } + }; + } + }); + + server.route({ + method: "DELETE", + url: "/identity-memberships/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: true, + tags: [ApiDocsTags.ProjectIdentities], + description: "Delete org identity memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(PROJECT_IDENTITIES.DELETE_IDENTITY_MEMBERSHIP.identityId) + }), + response: { + 200: z.object({ + identityMembership: sanitizedOrgIdentityMembershipSchema + }) + } + }, + handler: async (req) => { + const { membership } = await server.services.membershipIdentity.deleteMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + selector: { + identityId: req.params.identityId + } + }); + + return { + identityMembership: { ...membership, identityId: req.params.identityId, orgId: req.permission.orgId } + }; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 89865b1a1..307b922d3 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -65,6 +65,7 @@ import { registerUserEngagementRouter } from "./user-engagement-router"; import { registerUserRouter } from "./user-router"; import { registerWebhookRouter } from "./webhook-router"; import { registerWorkflowIntegrationRouter } from "./workflow-integration-router"; +import { registerOrgIdentityMembershipRouter } from "./identity-org-membership-router"; export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerSsoRouter, { prefix: "/sso" }); @@ -89,6 +90,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { ); await server.register(registerPasswordRouter, { prefix: "/password" }); await server.register(registerOrgRouter, { prefix: "/organization" }); + await server.register(registerOrgIdentityMembershipRouter, { prefix: "/organization" }); await server.register(registerAdminRouter, { prefix: "/admin" }); await server.register(registerOrgAdminRouter, { prefix: "/organization-admin" }); await server.register(registerUserRouter, { prefix: "/user" }); diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 81165f1e7..76b3eae51 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -2,6 +2,7 @@ import RE2 from "re2"; import { z } from "zod"; import { + AccessScope, AuditLogsSchema, GroupsSchema, IncidentContactsSchema, @@ -475,4 +476,68 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { return { groups }; } }); + + server.route({ + method: "GET", + url: "/users/available", + schema: { + response: { + 200: z.object({ + users: z + .object({ + id: z.string().uuid(), + username: z.string(), + email: z.string().nullable().optional(), + firstName: z.string().nullable().optional(), + lastName: z.string().nullable().optional() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { users } = await server.services.membershipUser.listAvailableUsers({ + permission: req.permission, + scopeData: { + orgId: req.permission.orgId, + scope: AccessScope.Organization + }, + data: {} + }); + + return { users }; + } + }); + + server.route({ + method: "GET", + url: "/identities/available", + schema: { + response: { + 200: z.object({ + identities: z + .object({ + id: z.string().uuid(), + name: z.string(), + hasDeleteProtection: z.boolean() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { identities } = await server.services.membershipIdentity.listAvailableIdentities({ + permission: req.permission, + scopeData: { + orgId: req.permission.orgId, + scope: AccessScope.Organization + }, + data: {} + }); + + return { identities }; + } + }); }; diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 984fb4c31..28a986fe8 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -236,7 +236,7 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL, orgD } orgId = subOrganization.id; rootOrgId = token.organizationId; - parentOrgId = subOrganization.parentOrgId; + parentOrgId = subOrganization.parentOrgId as string; } else { const orgMembership = await membershipUserDAL.findOne({ actorUserId: user.id, diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index e83f2f369..f35c80032 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -216,11 +216,12 @@ export const identityServiceFactory = ({ if (isCustomRole) customRole = rolePermissionDetails?.role; } + const identityDetails = await identityDAL.findById(id); const identity = await identityDAL.transaction(async (tx) => { const newIdentity = - name || hasDeleteProtection + identityDetails.orgId === actorOrgId && (name || hasDeleteProtection) ? await identityDAL.updateById(id, { name, hasDeleteProtection }, tx) - : await identityDAL.findById(id, tx); + : identityDetails; if (role) { await membershipRoleDAL.delete({ membershipId: identityOrgMembership.id }, tx); @@ -282,7 +283,6 @@ export const identityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - // TODO(namespace): check this in identity service const activeLockouts = await keyStore.getKeysByPattern(`lockout:identity:${id}:*`); const activeLockoutAuthMethods = new Set(); diff --git a/backend/src/services/membership-identity/membership-identity-dal.ts b/backend/src/services/membership-identity/membership-identity-dal.ts index 64e508fb8..78df6a757 100644 --- a/backend/src/services/membership-identity/membership-identity-dal.ts +++ b/backend/src/services/membership-identity/membership-identity-dal.ts @@ -91,6 +91,7 @@ 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("hasDeleteProtection").withSchema(TableName.Identity).as("identityHasDeleteProtection"), db.ref("slug").withSchema(TableName.Role).as("roleSlug"), @@ -132,6 +133,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { parentMapper: (el) => { const { identityId: actorIdentityId, + identityOrgId, identityHasDeleteProtection, identityName, uaId, @@ -153,6 +155,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { name: identityName, id: actorIdentityId, hasDeleteProtection: identityHasDeleteProtection, + identityOrgId, authMethods: buildAuthMethods({ uaId, awsId, @@ -353,5 +356,34 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { } }; - return { ...orm, findIdentities, getIdentityById }; + // this right nwo only support sub organization + const listAvailableIdentities = async (orgId: string, rootOrgId: string) => { + try { + const usersConnectedToOrg = db + .replicaNode()(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .select("actorIdentityId"); + + const docs = await db + .replicaNode()(TableName.Membership) + .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) + .select( + db.ref("id").withSchema(TableName.Identity), + db.ref("name").withSchema(TableName.Identity), + db.ref("hasDeleteProtection").withSchema(TableName.Identity) + ); + + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "ListAvailableUsers" }); + } + }; + + return { ...orm, findIdentities, getIdentityById, listAvailableIdentities }; }; diff --git a/backend/src/services/membership-identity/membership-identity-service.ts b/backend/src/services/membership-identity/membership-identity-service.ts index 4dd3da064..c1bb9cbbc 100644 --- a/backend/src/services/membership-identity/membership-identity-service.ts +++ b/backend/src/services/membership-identity/membership-identity-service.ts @@ -20,6 +20,7 @@ import { import { newNamespaceMembershipIdentityFactory } from "./namespace/namespace-membership-identity-factory"; import { newOrgMembershipIdentityFactory } from "./org/org-membership-identity-factory"; import { newProjectMembershipIdentityFactory } from "./project/project-membership-identity-factory"; +import { TIdentityDALFactory } from "../identity/identity-dal"; type TMembershipIdentityServiceFactoryDep = { membershipIdentityDAL: TMembershipIdentityDALFactory; @@ -31,6 +32,7 @@ type TMembershipIdentityServiceFactoryDep = { >; orgDAL: Pick; additionalPrivilegeDAL: Pick; + identityDAL: Pick; }; export type TMembershipIdentityServiceFactory = ReturnType; @@ -41,12 +43,14 @@ export const membershipIdentityServiceFactory = ({ membershipRoleDAL, permissionService, orgDAL, - additionalPrivilegeDAL + additionalPrivilegeDAL, + identityDAL }: TMembershipIdentityServiceFactoryDep) => { const scopeFactory = { [AccessScope.Organization]: newOrgMembershipIdentityFactory({ orgDAL, - permissionService + permissionService, + identityDAL }), [AccessScope.Project]: newProjectMembershipIdentityFactory({ membershipIdentityDAL, @@ -305,7 +309,7 @@ export const membershipIdentityServiceFactory = ({ [SearchResourceOperators.$contains]: dto.data.identityName } : undefined, - role: dto.data.roles.length + role: dto.data?.roles?.length ? { [SearchResourceOperators.$in]: dto.data.roles } @@ -329,11 +333,29 @@ export const membershipIdentityServiceFactory = ({ return membership; }; + const listAvailableIdentities = async (dto: TListMembershipIdentityDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onListMembershipIdentityGuard(dto); + + const organizationDetails = await orgDAL.findById(dto.scopeData.orgId); + if (!organizationDetails.rootOrgId) return { identities: [] }; + + const identities = await membershipIdentityDAL.listAvailableIdentities( + organizationDetails.id, + organizationDetails.rootOrgId + ); + + return { identities }; + }; + return { createMembership, updateMembership, deleteMembership, listMemberships, - getMembershipByIdentityId + getMembershipByIdentityId, + listAvailableIdentities }; }; diff --git a/backend/src/services/membership-identity/membership-identity-types.ts b/backend/src/services/membership-identity/membership-identity-types.ts index adce10237..78923cb14 100644 --- a/backend/src/services/membership-identity/membership-identity-types.ts +++ b/backend/src/services/membership-identity/membership-identity-types.ts @@ -54,14 +54,11 @@ export type TUpdateMembershipIdentityDTO = { export type TListMembershipIdentityDTO = { permission: OrgServiceActor; scopeData: AccessScopeData; - selector: { - identityId: string; - }; data: { limit?: number; offset?: number; identityName?: string; - roles: string[]; + roles?: string[]; }; }; diff --git a/backend/src/services/membership-identity/org/org-membership-identity-factory.ts b/backend/src/services/membership-identity/org/org-membership-identity-factory.ts index fce31b0fe..caffc984e 100644 --- a/backend/src/services/membership-identity/org/org-membership-identity-factory.ts +++ b/backend/src/services/membership-identity/org/org-membership-identity-factory.ts @@ -12,15 +12,18 @@ import { TOrgDALFactory } from "@app/services/org/org-dal"; import { isCustomOrgRole } from "@app/services/org/org-role-fns"; import { TMembershipIdentityScopeFactory } from "../membership-identity-types"; +import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; type TOrgMembershipIdentityScopeFactoryDep = { permissionService: Pick; orgDAL: Pick; + identityDAL: Pick; }; export const newOrgMembershipIdentityFactory = ({ permissionService, - orgDAL + orgDAL, + identityDAL }: TOrgMembershipIdentityScopeFactoryDep): TMembershipIdentityScopeFactory => { const getScopeField: TMembershipIdentityScopeFactory["getScopeField"] = (dto) => { if (dto.scope === AccessScope.Organization) { @@ -38,12 +41,53 @@ export const newOrgMembershipIdentityFactory = ({ const isCustomRole: TMembershipIdentityScopeFactory["isCustomRole"] = (role: string) => isCustomOrgRole(role); - const onCreateMembershipIdentityGuard: TMembershipIdentityScopeFactory["onCreateMembershipIdentityGuard"] = - async () => { - throw new BadRequestError({ - message: "Organization membership cannot be created for organization scoped identity" - }); - }; + const onCreateMembershipIdentityGuard: TMembershipIdentityScopeFactory["onCreateMembershipIdentityGuard"] = async ( + dto + ) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.ChildOrganization + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + + const identityDetails = await identityDAL.findById(dto.data.identityId); + if (identityDetails.orgId !== dto.permission.rootOrgId) { + throw new BadRequestError({ message: "Only identites from parent organization can be invited" }); + } + + const permissionRoles = await permissionService.getOrgPermissionByRoles( + dto.data.roles.map((el) => el.role), + dto.permission.orgId + ); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); + for (const permissionRole of permissionRoles) { + if (permissionRole?.role?.name !== OrgMembershipRole.NoAccess) { + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GrantPrivileges, + OrgPermissionSubjects.Identity, + permission, + permissionRole.permission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update identity org membership", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GrantPrivileges, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + } + }; const onUpdateMembershipIdentityGuard: TMembershipIdentityScopeFactory["onUpdateMembershipIdentityGuard"] = async ( dto @@ -87,12 +131,29 @@ export const newOrgMembershipIdentityFactory = ({ } }; - const onDeleteMembershipIdentityGuard: TMembershipIdentityScopeFactory["onDeleteMembershipIdentityGuard"] = - async () => { - throw new BadRequestError({ - message: "Organization membership cannot be deleted for organization scoped identity" - }); - }; + const onDeleteMembershipIdentityGuard: TMembershipIdentityScopeFactory["onDeleteMembershipIdentityGuard"] = async ( + dto + ) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.ChildOrganization + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + + const identityDetails = await identityDAL.findById(dto.selector.identityId); + if (identityDetails.orgId !== dto.permission.rootOrgId) { + throw new BadRequestError({ message: "Only identites from parent organization can do this operation" }); + } + + if (identityDetails.orgId === dto.permission.orgId) { + throw new BadRequestError({ message: "Identity cannot exist as orphan" }); + } + }; const onListMembershipIdentityGuard: TMembershipIdentityScopeFactory["onListMembershipIdentityGuard"] = async ( dto diff --git a/backend/src/services/membership-user/membership-user-dal.ts b/backend/src/services/membership-user/membership-user-dal.ts index 69b9585ed..41fa09696 100644 --- a/backend/src/services/membership-user/membership-user-dal.ts +++ b/backend/src/services/membership-user/membership-user-dal.ts @@ -291,13 +291,37 @@ export const membershipUserDALFactory = (db: TDbClient) => { } }; - // const listAvailableUsers = async (scopeData: AccessScopeData) => { - // try { - // const query = await db.replicaNode()(TableName.Membership).where(`${TableName.Membership}.scopeOrgId`); - // } catch (error) { - // throw new DatabaseError({ error, name: "ListAvailableUsers" }); - // } - // }; + // this right nwo only support sub organization + const listAvailableUsers = async (orgId: string, rootOrgId: string) => { + try { + const usersConnectedToOrg = db + .replicaNode()(TableName.Membership) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .where(`${TableName.Membership}.scopeOrgId`, orgId) + .select("actorUserId"); - return { ...orm, findUsers, getUserById }; + const docs = await db + .replicaNode()(TableName.Membership) + .join(TableName.Users, `${TableName.Users}.id`, `${TableName.Membership}.actorUserId`) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .where(`${TableName.Users}.isGhost`, false) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .where(`${TableName.Membership}.scopeOrgId`, rootOrgId) + .whereNot(`${TableName.Membership}.actorUserId`, usersConnectedToOrg) + .select( + db.ref("id").withSchema(TableName.Users), + db.ref("email").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users) + ); + + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "ListAvailableUsers" }); + } + }; + + return { ...orm, findUsers, getUserById, listAvailableUsers }; }; diff --git a/backend/src/services/membership-user/membership-user-service.ts b/backend/src/services/membership-user/membership-user-service.ts index 82dca0159..f6265d2bb 100644 --- a/backend/src/services/membership-user/membership-user-service.ts +++ b/backend/src/services/membership-user/membership-user-service.ts @@ -83,7 +83,8 @@ export const membershipUserServiceFactory = ({ orgDAL, tokenService, userDAL, - userGroupMembershipDAL + userGroupMembershipDAL, + membershipUserDAL }), [AccessScope.Namespace]: newNamespaceMembershipUserFactory({}), [AccessScope.Project]: newProjectMembershipUserFactory({ @@ -471,11 +472,26 @@ export const membershipUserServiceFactory = ({ return membership; }; + // Should only be used for sub organization as of now + const listAvailableUsers = async (dto: TListMembershipUserDTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onListMembershipUserGuard(dto); + + const organizationDetails = await orgDAL.findById(dto.scopeData.orgId); + if (!organizationDetails.rootOrgId) return { users: [] }; + + const users = await membershipUserDAL.listAvailableUsers(organizationDetails.id, organizationDetails.rootOrgId); + return { users }; + }; + return { createMembership, updateMembership, deleteMembership, listMemberships, - getMembershipByUserId + getMembershipByUserId, + listAvailableUsers }; }; diff --git a/backend/src/services/membership-user/membership-user-types.ts b/backend/src/services/membership-user/membership-user-types.ts index b8761671c..15982bb6a 100644 --- a/backend/src/services/membership-user/membership-user-types.ts +++ b/backend/src/services/membership-user/membership-user-types.ts @@ -93,3 +93,8 @@ export type TGetMembershipUserByUserIdDTO = { userId: string; }; }; + +export type TListAvailableUsersDTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; +}; diff --git a/backend/src/services/membership-user/org/org-membership-user-factory.ts b/backend/src/services/membership-user/org/org-membership-user-factory.ts index 5caf77c2c..761a1397d 100644 --- a/backend/src/services/membership-user/org/org-membership-user-factory.ts +++ b/backend/src/services/membership-user/org/org-membership-user-factory.ts @@ -16,6 +16,7 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { TMembershipUserScopeFactory } from "../membership-user-types"; +import { TMembershipUserDALFactory } from "../membership-user-dal"; type TOrgMembershipUserScopeFactoryDep = { permissionService: Pick; @@ -25,6 +26,7 @@ type TOrgMembershipUserScopeFactoryDep = { orgDAL: Pick; userGroupMembershipDAL: Pick; licenseService: Pick; + membershipUserDAL: Pick; }; export const newOrgMembershipUserFactory = ({ @@ -33,7 +35,8 @@ export const newOrgMembershipUserFactory = ({ userDAL, orgDAL, smtpService, - licenseService + licenseService, + membershipUserDAL }: TOrgMembershipUserScopeFactoryDep): TMembershipUserScopeFactory => { const getScopeField: TMembershipUserScopeFactory["getScopeField"] = (dto) => { if (dto.scope === AccessScope.Organization) { @@ -51,7 +54,10 @@ export const newOrgMembershipUserFactory = ({ const isCustomRole: TMembershipUserScopeFactory["isCustomRole"] = (role: string) => isCustomOrgRole(role); - const onCreateMembershipUserGuard: TMembershipUserScopeFactory["onCreateMembershipUserGuard"] = async (dto) => { + const onCreateMembershipUserGuard: TMembershipUserScopeFactory["onCreateMembershipUserGuard"] = async ( + dto, + newMembers + ) => { const { permission } = await permissionService.getOrgPermission({ actor: dto.permission.type, actorId: dto.permission.id, @@ -78,6 +84,19 @@ export const newOrgMembershipUserFactory = ({ message: "Failed to invite user due to org-level auth enforced for organization" }); } + if (org.rootOrgId) { + const rootOrgMembership = await membershipUserDAL.find({ + scope: AccessScope.Organization, + $in: { + actorUserId: newMembers.map((el) => el.id) + }, + scopeOrgId: org.rootOrgId + }); + if (rootOrgMembership.length !== newMembers.length) + throw new BadRequestError({ + message: "User doesn't have membership in root organization" + }); + } }; const onCreateMembershipComplete: TMembershipUserScopeFactory["onCreateMembershipComplete"] = async ( diff --git a/frontend/src/context/OrganizationContext/OrganizationContext.tsx b/frontend/src/context/OrganizationContext/OrganizationContext.tsx index b7726e6a4..5a18ec741 100644 --- a/frontend/src/context/OrganizationContext/OrganizationContext.tsx +++ b/frontend/src/context/OrganizationContext/OrganizationContext.tsx @@ -21,6 +21,7 @@ export const useOrganization = () => { id: currentOrg?.subOrganization?.id || currentOrg?.id, parentOrgId: currentOrg.id }, - isSubOrganization: Boolean(currentOrg.subOrganization) + isSubOrganization: Boolean(currentOrg.subOrganization), + isRootOrganization: !currentOrg.subOrganization }; }; diff --git a/frontend/src/hooks/api/orgIdentityMembership/index.tsx b/frontend/src/hooks/api/orgIdentityMembership/index.tsx new file mode 100644 index 000000000..61d572e30 --- /dev/null +++ b/frontend/src/hooks/api/orgIdentityMembership/index.tsx @@ -0,0 +1,2 @@ +export { useCreateOrgIdentityMembership, useDeleteOrgIdentityMembership } from "./mutation"; +export type { TCreateOrgIdentityMembershipDTO, TDeleteOrgIdentityMembershipDTO, TOrgIdentityMembership } from "./types"; diff --git a/frontend/src/hooks/api/orgIdentityMembership/mutation.tsx b/frontend/src/hooks/api/orgIdentityMembership/mutation.tsx new file mode 100644 index 000000000..cd5787842 --- /dev/null +++ b/frontend/src/hooks/api/orgIdentityMembership/mutation.tsx @@ -0,0 +1,42 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { + TCreateOrgIdentityMembershipDTO, + TDeleteOrgIdentityMembershipDTO, + TOrgIdentityMembership +} from "./types"; + +export const useCreateOrgIdentityMembership = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ identityId, roles }: TCreateOrgIdentityMembershipDTO) => { + const { data } = await apiRequest.post<{ identityMembership: TOrgIdentityMembership }>( + `/api/v1/organization/identity-memberships/${identityId}`, + { roles } + ); + return data.identityMembership; + }, + onSuccess: () => { + // Invalidate relevant queries if needed + queryClient.invalidateQueries({ queryKey: ["organization"] }); + } + }); +}; + +export const useDeleteOrgIdentityMembership = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ identityId }: TDeleteOrgIdentityMembershipDTO) => { + const { data } = await apiRequest.delete<{ identityMembership: TOrgIdentityMembership }>( + `/api/v1/organization/identity-memberships/${identityId}` + ); + return data.identityMembership; + }, + onSuccess: () => { + // Invalidate relevant queries if needed + queryClient.invalidateQueries({ queryKey: ["organization"] }); + } + }); +}; diff --git a/frontend/src/hooks/api/orgIdentityMembership/types.ts b/frontend/src/hooks/api/orgIdentityMembership/types.ts new file mode 100644 index 000000000..a50ddc1d0 --- /dev/null +++ b/frontend/src/hooks/api/orgIdentityMembership/types.ts @@ -0,0 +1,30 @@ +import { TemporaryPermissionMode } from "@app/db/schemas"; + +export type TOrgIdentityMembership = { + id: string; + orgId: string; + identityId: string; + createdAt: string; + updatedAt: string; +}; + +export type TCreateOrgIdentityMembershipDTO = { + identityId: string; + roles: Array< + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: TemporaryPermissionMode; + temporaryRange: string; + temporaryAccessStartTime: string; + } + >; +}; + +export type TDeleteOrgIdentityMembershipDTO = { + identityId: string; +}; diff --git a/frontend/src/hooks/api/organization/index.ts b/frontend/src/hooks/api/organization/index.ts index f4627a614..5f2be9b76 100644 --- a/frontend/src/hooks/api/organization/index.ts +++ b/frontend/src/hooks/api/organization/index.ts @@ -1,5 +1,6 @@ export { useAddOrgPmtMethod, + useGetAvailableOrgIdentities, useAddOrgTaxId, useCreateCustomerPortalSession, useCreateOrg, diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index ce3071584..d76b8a54b 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -42,7 +42,9 @@ export const organizationKeys = { [...organizationKeys.getOrgIdentityMemberships(orgId), params] as const, getOrgGroups: (orgId: string) => [{ orgId }, "organization-groups"] as const, getOrgIntegrationAuths: (orgId: string) => [{ orgId }, "integration-auths"] as const, - getOrgById: (orgId: string) => ["organization", { orgId }] + getOrgById: (orgId: string) => ["organization", { orgId }], + getAvailableIdentities: () => ["available-identities"], + getAvailableUsers: () => ["available-users"] }; export const fetchOrganizations = async () => { @@ -574,3 +576,29 @@ export const useGetOrgIntegrationAuths = ( select }); }; + +export const useGetAvailableOrgIdentities = (enabled = true) => + useQuery({ + queryKey: organizationKeys.getAvailableIdentities(), + queryFn: async () => { + const { data } = await apiRequest.get<{ identities: { name: string; id: string }[] }>( + `/api/v1/organization/identities/available` + ); + + return data.identities; + }, + enabled + }); + +export const useGetAvailableOrgUsers = (enabled = true) => + useQuery({ + queryKey: organizationKeys.getAvailableUsers(), + queryFn: async () => { + const { data } = await apiRequest.get<{ + users: { username: string; id: string; firstName: string; lastName: string }[]; + }>(`/api/v1/organization/users/available`); + + return data.users; + }, + enabled + }); diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLinkForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLinkForm.tsx new file mode 100644 index 000000000..96ad3eba4 --- /dev/null +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLinkForm.tsx @@ -0,0 +1,129 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useNavigate } from "@tanstack/react-router"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FilterableSelect, FormControl } from "@app/components/v2"; +import { useOrganization } from "@app/context"; +import { useGetAvailableOrgIdentities, useGetOrgRoles } from "@app/hooks/api"; +import { useCreateOrgIdentityMembership } from "@app/hooks/api/orgIdentityMembership"; + +const schema = z + .object({ + identity: z.object({ name: z.string(), id: z.string() }), + role: z.object({ name: z.string(), slug: z.string() }) + }) + .required(); + +export type FormData = z.infer; + +type Props = { + onClose: () => void; +}; + +export const IdentityLinkForm = ({ onClose }: Props) => { + const navigate = useNavigate(); + const { currentOrg } = useOrganization(); + const orgId = currentOrg?.id || ""; + + const { data: roles } = useGetOrgRoles(orgId); + + const { mutateAsync: createMutateAsync } = useCreateOrgIdentityMembership(); + const { data: rootOrgIdentities, isPending: isRootOrgLoading } = useGetAvailableOrgIdentities(); + + const { + control, + handleSubmit, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: {} + }); + + const onFormSubmit = async ({ identity, role }: FormData) => { + try { + await createMutateAsync({ + identityId: identity.id, + roles: [{ role: role.slug, isTemporary: false }] + }); + createNotification({ + text: "Successfully linked identity", + type: "success" + }); + navigate({ + to: "/organization/identities/$identityId", + params: { + identityId: identity.id + } + }); + } catch (err) { + console.error(err); + const error = err as any; + const text = error?.response?.data?.message ?? "Failed to link identity"; + + createNotification({ + text, + type: "error" + }); + } + }; + + return ( +
+ ( + + option.id} + getOptionLabel={(option) => option.name} + isLoading={isRootOrgLoading} + /> + + )} + /> + ( + + option.slug} + getOptionLabel={(option) => option.name} + menuPortalTarget={document.body} + /> + + )} + /> +
+ + +
+ + ); +}; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx index 9280ab5d9..6627f87ce 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx @@ -1,10 +1,15 @@ -import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { + faArrowUpRightFromSquare, + faBookOpen, + faLink, + faPlus +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; -import { Button, DeleteActionModal } from "@app/components/v2"; +import { Button, DeleteActionModal, Modal, ModalContent } from "@app/components/v2"; import { OrgPermissionIdentityActions, OrgPermissionSubjects, @@ -23,11 +28,12 @@ import { IdentityModal } from "./IdentityModal"; import { IdentityTable } from "./IdentityTable"; import { IdentityTokenAuthTokenModal } from "./IdentityTokenAuthTokenModal"; import { MachineAuthTemplateUsagesModal } from "./MachineAuthTemplateUsagesModal"; +import { IdentityLinkForm } from "./IdentityLinkForm"; export const IdentitySection = withPermission( () => { const { subscription } = useSubscription(); - const { currentOrg } = useOrganization(); + const { currentOrg, isSubOrganization } = useOrganization(); const orgId = currentOrg?.id || ""; const { mutateAsync: deleteMutateAsync } = useDeleteIdentity(); @@ -43,7 +49,8 @@ export const IdentitySection = withPermission( "createTemplate", "editTemplate", "deleteTemplate", - "viewUsages" + "viewUsages", + "linkIdentity" ] as const); const isMoreIdentitiesAllowed = subscription?.identityLimit @@ -105,8 +112,8 @@ export const IdentitySection = withPermission( return (
-
-
+
+

Identities

+ {isSubOrganization && ( + + {(isAllowed) => ( + + )} + + )} - {/* */} - {/* */} + handlePopUpToggle("linkIdentity", isOpen)} + > + + handlePopUpClose("linkIdentity")} /> + + { + beforeLoad: ({ search }) => { + if (search.subOrganization) { + throw redirect({ + to: "/organization/projects", + search + }); + } + return { breadcrumbs: [ { diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx index 66af7df1b..f5b0f5a0c 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx @@ -14,25 +14,39 @@ import { OrgSecurityTab } from "../OrgSecurityTab"; import { OrgSsoTab } from "../OrgSsoTab"; import { OrgWorkflowIntegrationTab } from "../OrgWorkflowIntegrationTab"; import { ProjectTemplatesTab } from "../ProjectTemplatesTab"; +import { useOrganization } from "@app/context"; export const OrgTabGroup = () => { const search = useSearch({ from: ROUTE_PATHS.Organization.SettingsPage.id }); + const { isSubOrganization } = useOrganization(); + const tabs = [ { name: "General", key: "tab-org-general", component: OrgGeneralTab }, { name: "SSO", key: "sso-settings", - component: OrgSsoTab + component: OrgSsoTab, + isHidden: isSubOrganization }, { name: "Provisioning", key: "provisioning-settings", - component: OrgProvisioningTab + component: OrgProvisioningTab, + isHidden: isSubOrganization + }, + { + name: "Security", + key: "tab-org-security", + component: OrgSecurityTab, + isHidden: isSubOrganization + }, + { + name: "Encryption", + key: "tab-org-encryption", + component: OrgEncryptionTab }, - { name: "Security", key: "tab-org-security", component: OrgSecurityTab }, - { name: "Encryption", key: "tab-org-encryption", component: OrgEncryptionTab }, { name: "Workflow Integrations", key: "workflow-integrations", @@ -57,17 +71,21 @@ export const OrgTabGroup = () => { return ( - {tabs.map((tab) => ( - - {tab.name} - - ))} + {tabs + .filter((el) => !el.isHidden) + .map((tab) => ( + + {tab.name} + + ))} - {tabs.map(({ key, component: Component }) => ( - - - - ))} + {tabs + .filter((el) => !el.isHidden) + .map(({ key, component: Component }) => ( + + + + ))} ); };