feat: added root org identity link functionality

This commit is contained in:
=
2025-10-19 21:41:21 +05:30
parent 545ea4e27c
commit b7644c9294
26 changed files with 753 additions and 82 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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({

View File

@@ -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 }
};
}
});
};

View File

@@ -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" });

View File

@@ -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 };
}
});
};

View File

@@ -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,

View File

@@ -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<string>();

View File

@@ -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 };
};

View File

@@ -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<TOrgDALFactory, "findById">;
additionalPrivilegeDAL: Pick<TAdditionalPrivilegeDALFactory, "delete">;
identityDAL: Pick<TIdentityDALFactory, "findById">;
};
export type TMembershipIdentityServiceFactory = ReturnType<typeof membershipIdentityServiceFactory>;
@@ -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
};
};

View File

@@ -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[];
};
};

View File

@@ -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<TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRoles">;
orgDAL: Pick<TOrgDALFactory, "findById">;
identityDAL: Pick<TIdentityDALFactory, "findById">;
};
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

View File

@@ -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 };
};

View File

@@ -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
};
};

View File

@@ -93,3 +93,8 @@ export type TGetMembershipUserByUserIdDTO = {
userId: string;
};
};
export type TListAvailableUsersDTO = {
permission: OrgServiceActor;
scopeData: AccessScopeData;
};

View File

@@ -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<TPermissionServiceFactory, "getOrgPermission">;
@@ -25,6 +26,7 @@ type TOrgMembershipUserScopeFactoryDep = {
orgDAL: Pick<TOrgDALFactory, "findById">;
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "delete">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
membershipUserDAL: Pick<TMembershipUserDALFactory, "find">;
};
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 (

View File

@@ -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
};
};

View File

@@ -0,0 +1,2 @@
export { useCreateOrgIdentityMembership, useDeleteOrgIdentityMembership } from "./mutation";
export type { TCreateOrgIdentityMembershipDTO, TDeleteOrgIdentityMembershipDTO, TOrgIdentityMembership } from "./types";

View File

@@ -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"] });
}
});
};

View File

@@ -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;
};

View File

@@ -1,5 +1,6 @@
export {
useAddOrgPmtMethod,
useGetAvailableOrgIdentities,
useAddOrgTaxId,
useCreateCustomerPortalSession,
useCreateOrg,

View File

@@ -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 = <TData = IntegrationAuth[],>(
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
});

View File

@@ -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<typeof schema>;
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<FormData>({
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 (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="identity"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl label="Identity" errorText={error?.message} isError={Boolean(error)}>
<FilterableSelect
value={value}
onChange={onChange}
placeholder="Select identity..."
options={rootOrgIdentities}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => option.name}
isLoading={isRootOrgLoading}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="role"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Role"
errorText={error?.message}
isError={Boolean(error)}
className="mt-4"
>
<FilterableSelect
value={value}
onChange={onChange}
options={roles}
placeholder="Select role..."
getOptionValue={(option) => option.slug}
getOptionLabel={(option) => option.name}
menuPortalTarget={document.body}
/>
</FormControl>
)}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Link
</Button>
<Button colorSchema="secondary" variant="plain" onClick={() => onClose()}>
Cancel
</Button>
</div>
</form>
);
};

View File

@@ -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 (
<div>
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-1">
<div className="mb-4 flex w-full items-center gap-4">
<div className="flex flex-1 items-center gap-1">
<p className="text-xl font-medium text-mineshaft-100">Identities</p>
<a
href="https://infisical.com/docs/documentation/platform/identities/overview"
@@ -123,6 +130,26 @@ export const IdentitySection = withPermission(
</div>
</a>
</div>
{isSubOrganization && (
<OrgPermissionCan
I={OrgPermissionIdentityActions.Create}
a={OrgPermissionSubjects.Identity}
>
{(isAllowed) => (
<Button
variant="plain"
colorSchema="secondary"
leftIcon={<FontAwesomeIcon icon={faLink} />}
onClick={() => {
handlePopUpOpen("linkIdentity");
}}
isDisabled={!isAllowed}
>
Link Identity
</Button>
)}
</OrgPermissionCan>
)}
<OrgPermissionCan
I={OrgPermissionIdentityActions.Create}
a={OrgPermissionSubjects.Identity}
@@ -210,16 +237,17 @@ export const IdentitySection = withPermission(
?.name || ""
}
/>
{/* <IdentityAuthMethodModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/> */}
{/* <IdentityUniversalAuthClientSecretModal
popUp={popUp}
handlePopUpOpen={handlePopUpOpen}
handlePopUpToggle={handlePopUpToggle}
/> */}
<Modal
isOpen={popUp.linkIdentity.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("linkIdentity", isOpen)}
>
<ModalContent
title="Assign Existing Identity"
subTitle="Assign an existing identity from your organization or namespace to this project. The identity will continue to be managed at its original scope."
>
<IdentityLinkForm onClose={() => handlePopUpClose("linkIdentity")} />
</ModalContent>
</Modal>
<IdentityTokenAuthTokenModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen}

View File

@@ -1,4 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { BillingPage } from "./BillingPage";
@@ -6,7 +6,14 @@ export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/organization/billing"
)({
component: BillingPage,
beforeLoad: () => {
beforeLoad: ({ search }) => {
if (search.subOrganization) {
throw redirect({
to: "/organization/projects",
search
});
}
return {
breadcrumbs: [
{

View File

@@ -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 orientation="vertical" value={selectedTab} onValueChange={setSelectedTab}>
<TabList>
{tabs.map((tab) => (
<Tab variant="org" value={tab.key} key={tab.key}>
{tab.name}
</Tab>
))}
{tabs
.filter((el) => !el.isHidden)
.map((tab) => (
<Tab variant="org" value={tab.key} key={tab.key}>
{tab.name}
</Tab>
))}
</TabList>
{tabs.map(({ key, component: Component }) => (
<TabPanel value={key} key={`tab-panel-${key}`}>
<Component />
</TabPanel>
))}
{tabs
.filter((el) => !el.isHidden)
.map(({ key, component: Component }) => (
<TabPanel value={key} key={`tab-panel-${key}`}>
<Component />
</TabPanel>
))}
</Tabs>
);
};