feature: project and org identity pagination, search and sort

This commit is contained in:
Scott Wilson
2024-09-11 07:22:08 -07:00
parent 1321aa712f
commit 16182a9d1d
22 changed files with 1061 additions and 492 deletions
+12 -2
View File
@@ -363,7 +363,12 @@ export const ORGANIZATIONS = {
membershipId: "The ID of the membership to delete."
},
LIST_IDENTITY_MEMBERSHIPS: {
orgId: "The ID of the organization to get identity memberships from."
orgId: "The ID of the organization to get 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.",
orderBy: "The column to order identity memberships by.",
direction: "The direction identity memberships will be sorted in.",
textFilter: "The text string that identity membership names will be filtered by."
},
GET_PROJECTS: {
organizationId: "The ID of the organization to get projects from."
@@ -470,7 +475,12 @@ export const PROJECT_USERS = {
export const PROJECT_IDENTITIES = {
LIST_IDENTITY_MEMBERSHIPS: {
projectId: "The ID of the project to get identity memberships from."
projectId: "The ID of the project to get 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.",
orderBy: "The column to order identity memberships by.",
direction: "The direction identity memberships will be sorted in.",
textFilter: "The text string that identity membership names will be filtered by."
},
GET_IDENTITY_MEMBERSHIP_BY_ID: {
identityId: "The ID of the identity to get the membership for.",
+5
View File
@@ -52,3 +52,8 @@ export enum SecretSharingAccessType {
Anyone = "anyone",
Organization = "organization"
}
export enum OrderByDirection {
ASC = "asc",
DESC = "desc"
}
@@ -246,12 +246,13 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
description: true
}).optional(),
identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true })
}).array()
}).array(),
totalCount: z.number()
})
}
},
handler: async (req) => {
const identities = await server.services.identity.listOrgIdentities({
const { identityMemberships, totalCount } = await server.services.identity.listOrgIdentities({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
@@ -259,7 +260,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
orgId: req.query.orgId
});
return { identities };
return { identities: identityMemberships, totalCount };
}
});
@@ -2,9 +2,11 @@ import { z } from "zod";
import { IdentitiesSchema, IdentityOrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas";
import { ORGANIZATIONS } from "@app/lib/api-docs";
import { OrderByDirection } from "@app/lib/types";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { OrgIdentityOrderBy } from "@app/services/identity/identity-types";
export const registerIdentityOrgRouter = async (server: FastifyZodProvider) => {
server.route({
@@ -24,6 +26,32 @@ export const registerIdentityOrgRouter = async (server: FastifyZodProvider) => {
params: z.object({
orgId: z.string().trim().describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.orgId)
}),
querystring: z.object({
offset: z.coerce.number().min(0).default(0).describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.offset).optional(),
limit: z.coerce
.number()
.min(1)
.max(20000) // TODO: temp limit until combobox added to add identity to project modal, reduce once added
.default(100)
.describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.limit)
.optional(),
orderBy: z
.nativeEnum(OrgIdentityOrderBy)
.default(OrgIdentityOrderBy.Name)
.describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.orderBy)
.optional(),
direction: z
.nativeEnum(OrderByDirection)
.default(OrderByDirection.ASC)
.describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.direction)
.optional(),
textFilter: z
.string()
.trim()
.default("")
.describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.textFilter)
.optional()
}),
response: {
200: z.object({
identityMemberships: IdentityOrgMembershipsSchema.merge(
@@ -37,20 +65,26 @@ export const registerIdentityOrgRouter = async (server: FastifyZodProvider) => {
}).optional(),
identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true })
})
).array()
).array(),
totalCount: z.number()
})
}
},
handler: async (req) => {
const identityMemberships = await server.services.identity.listOrgIdentities({
const { identityMemberships, totalCount } = await server.services.identity.listOrgIdentities({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
orgId: req.params.orgId
orgId: req.params.orgId,
limit: req.query.limit,
offset: req.query.offset,
orderBy: req.query.orderBy,
direction: req.query.direction,
textFilter: req.query.textFilter
});
return { identityMemberships };
return { identityMemberships, totalCount };
}
});
};
@@ -7,11 +7,13 @@ import {
ProjectMembershipRole,
ProjectUserMembershipRolesSchema
} from "@app/db/schemas";
import { PROJECT_IDENTITIES } from "@app/lib/api-docs";
import { ORGANIZATIONS, PROJECT_IDENTITIES } from "@app/lib/api-docs";
import { BadRequestError } from "@app/lib/errors";
import { OrderByDirection } from "@app/lib/types";
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";
import { ProjectIdentityOrderBy } from "@app/services/identity-project/identity-project-types";
import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types";
import { SanitizedProjectSchema } from "../sanitizedSchemas";
@@ -214,6 +216,37 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider)
params: z.object({
projectId: z.string().trim().describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.projectId)
}),
querystring: z.object({
offset: z.coerce
.number()
.min(0)
.default(0)
.describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.offset)
.optional(),
limit: z.coerce
.number()
.min(1)
.max(20000) // TODO: temp limit until combobox added to add identity to project modal, reduce once added
.default(100)
.describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.limit)
.optional(),
orderBy: z
.nativeEnum(ProjectIdentityOrderBy)
.default(ProjectIdentityOrderBy.Name)
.describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.orderBy)
.optional(),
direction: z
.nativeEnum(OrderByDirection)
.default(OrderByDirection.ASC)
.describe(ORGANIZATIONS.LIST_IDENTITY_MEMBERSHIPS.direction)
.optional(),
textFilter: z
.string()
.trim()
.default("")
.describe(PROJECT_IDENTITIES.LIST_IDENTITY_MEMBERSHIPS.textFilter)
.optional()
}),
response: {
200: z.object({
identityMemberships: z
@@ -239,19 +272,25 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider)
identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }),
project: SanitizedProjectSchema.pick({ name: true, id: true })
})
.array()
.array(),
totalCount: z.number()
})
}
},
handler: async (req) => {
const identityMemberships = await server.services.identityProject.listProjectIdentities({
const { identityMemberships, totalCount } = await server.services.identityProject.listProjectIdentities({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.params.projectId
projectId: req.params.projectId,
limit: req.query.limit,
offset: req.query.offset,
orderBy: req.query.orderBy,
direction: req.query.direction,
textFilter: req.query.textFilter
});
return { identityMemberships };
return { identityMemberships, totalCount };
}
});
@@ -4,6 +4,7 @@ import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, sqlNestRelationships } from "@app/lib/knex";
import { TListProjectIdentityDTO } from "@app/services/identity-project/identity-project-types";
export type TIdentityProjectDALFactory = ReturnType<typeof identityProjectDALFactory>;
@@ -107,9 +108,16 @@ export const identityProjectDALFactory = (db: TDbClient) => {
}
};
const findByProjectId = async (projectId: string, filter: { identityId?: string } = {}, tx?: Knex) => {
const findByProjectId = async (
projectId: string,
filter: { identityId?: string } & Pick<
TListProjectIdentityDTO,
"limit" | "offset" | "textFilter" | "orderBy" | "direction"
> = {},
tx?: Knex
) => {
try {
const docs = await (tx || db.replicaNode())(TableName.IdentityProjectMembership)
const query = (tx || db.replicaNode())(TableName.IdentityProjectMembership)
.where(`${TableName.IdentityProjectMembership}.projectId`, projectId)
.join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`)
.join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`)
@@ -117,6 +125,10 @@ export const identityProjectDALFactory = (db: TDbClient) => {
if (filter.identityId) {
void qb.where("identityId", filter.identityId);
}
if (filter.textFilter) {
void qb.whereILike(`${TableName.Identity}.name`, `%${filter.textFilter}%`);
}
})
.join(
TableName.IdentityProjectMembershipRole,
@@ -154,6 +166,22 @@ export const identityProjectDALFactory = (db: TDbClient) => {
db.ref("name").as("projectName").withSchema(TableName.Project)
);
if (filter.limit) {
void query.offset(filter.offset ?? 0).limit(filter.limit);
}
if (filter.orderBy) {
switch (filter.orderBy) {
case "name":
void query.orderBy(`${TableName.Identity}.${filter.orderBy}`, filter.direction);
break;
default:
// do nothing
}
}
const docs = await query;
const members = sqlNestRelationships({
data: docs,
parentMapper: ({ identityId, identityName, identityAuthMethod, id, createdAt, updatedAt, projectName }) => ({
@@ -208,9 +236,36 @@ export const identityProjectDALFactory = (db: TDbClient) => {
}
};
const getCountByProjectId = async (
projectId: string,
filter: { identityId?: string } & Pick<TListProjectIdentityDTO, "textFilter"> = {},
tx?: Knex
) => {
try {
const identities = await (tx || db.replicaNode())(TableName.IdentityProjectMembership)
.where(`${TableName.IdentityProjectMembership}.projectId`, projectId)
.join(TableName.Project, `${TableName.IdentityProjectMembership}.projectId`, `${TableName.Project}.id`)
.join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`)
.where((qb) => {
if (filter.identityId) {
void qb.where("identityId", filter.identityId);
}
if (filter.textFilter) {
void qb.whereILike(`${TableName.Identity}.name`, `%${filter.textFilter}%`);
}
});
return identities.length;
} catch (error) {
throw new DatabaseError({ error, name: "GetCountByProjectId" });
}
};
return {
...identityProjectOrm,
findByIdentityId,
findByProjectId
findByProjectId,
getCountByProjectId
};
};
@@ -268,7 +268,12 @@ export const identityProjectServiceFactory = ({
actor,
actorId,
actorAuthMethod,
actorOrgId
actorOrgId,
limit,
offset,
orderBy,
direction,
textFilter
}: TListProjectIdentityDTO) => {
const { permission } = await permissionService.getProjectPermission(
actor,
@@ -279,8 +284,17 @@ export const identityProjectServiceFactory = ({
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Identity);
const identityMemberships = await identityProjectDAL.findByProjectId(projectId);
return identityMemberships;
const identityMemberships = await identityProjectDAL.findByProjectId(projectId, {
limit,
offset,
orderBy,
direction,
textFilter
});
const totalCount = await identityProjectDAL.getCountByProjectId(projectId, { textFilter });
return { identityMemberships, totalCount };
};
const getProjectIdentityByIdentityId = async ({
@@ -1,4 +1,4 @@
import { TProjectPermission } from "@app/lib/types";
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types";
@@ -40,8 +40,18 @@ export type TDeleteProjectIdentityDTO = {
identityId: string;
} & TProjectPermission;
export type TListProjectIdentityDTO = TProjectPermission;
export type TListProjectIdentityDTO = {
limit?: number;
offset?: number;
orderBy?: ProjectIdentityOrderBy;
direction?: OrderByDirection;
textFilter?: string;
} & TProjectPermission;
export type TGetProjectIdentityByIdentityIdDTO = {
identityId: string;
} & TProjectPermission;
export enum ProjectIdentityOrderBy {
Name = "name"
}
@@ -4,6 +4,8 @@ import { TDbClient } from "@app/db";
import { TableName, TIdentityOrgMemberships } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types";
import { TListOrgIdentitiesByOrgIdDTO } from "@app/services/identity/identity-types";
export type TIdentityOrgDALFactory = ReturnType<typeof identityOrgDALFactory>;
@@ -27,9 +29,20 @@ export const identityOrgDALFactory = (db: TDbClient) => {
}
};
const find = async (filter: Partial<TIdentityOrgMemberships>, tx?: Knex) => {
const find = async (
{
limit,
offset = 0,
orderBy,
direction = OrderByDirection.ASC,
textFilter,
...filter
}: Partial<TIdentityOrgMemberships> &
Pick<TListOrgIdentitiesByOrgIdDTO, "offset" | "limit" | "orderBy" | "direction" | "textFilter">,
tx?: Knex
) => {
try {
const docs = await (tx || db.replicaNode())(TableName.IdentityOrgMembership)
const query = (tx || db.replicaNode())(TableName.IdentityOrgMembership)
.where(filter)
.join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`)
.leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`)
@@ -44,6 +57,30 @@ export const identityOrgDALFactory = (db: TDbClient) => {
.select(db.ref("id").as("identityId").withSchema(TableName.Identity))
.select(db.ref("name").as("identityName").withSchema(TableName.Identity))
.select(db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity));
if (limit) {
void query.offset(offset).limit(limit);
}
if (orderBy) {
switch (orderBy) {
case "name":
void query.orderBy(`${TableName.Identity}.${orderBy}`, direction);
break;
case "role":
void query.orderBy(`${TableName.IdentityOrgMembership}.${orderBy}`, direction);
break;
default:
// do nothing
}
}
if (textFilter?.length) {
void query.whereILike(`${TableName.Identity}.name`, `%${textFilter}%`);
}
const docs = await query;
return docs.map(
({
crId,
@@ -79,5 +116,26 @@ export const identityOrgDALFactory = (db: TDbClient) => {
}
};
return { ...identityOrgOrm, find, findOne };
const countAllOrgIdentities = async (
{ textFilter, ...filter }: Partial<TIdentityOrgMemberships> & Pick<TListOrgIdentitiesByOrgIdDTO, "textFilter">,
tx?: Knex
) => {
try {
const query = (tx || db.replicaNode())(TableName.IdentityOrgMembership)
.where(filter)
.join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`);
if (textFilter?.length) {
void query.whereILike(`${TableName.Identity}.name`, `%${textFilter}%`);
}
const identities = await query;
return identities.length;
} catch (error) {
throw new DatabaseError({ error, name: "countAllOrgIdentities" });
}
};
return { ...identityOrgOrm, find, findOne, countAllOrgIdentities };
};
@@ -6,7 +6,6 @@ import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/pe
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { isAtLeastAsPrivileged } from "@app/lib/casl";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { TOrgPermission } from "@app/lib/types";
import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
import { ActorType } from "../auth/auth-type";
@@ -16,6 +15,7 @@ import {
TCreateIdentityDTO,
TDeleteIdentityDTO,
TGetIdentityByIdDTO,
TListOrgIdentitiesByOrgIdDTO,
TListProjectIdentitiesByIdentityIdDTO,
TUpdateIdentityDTO
} from "./identity-types";
@@ -195,14 +195,36 @@ export const identityServiceFactory = ({
return { ...deletedIdentity, orgId: identityOrgMembership.orgId };
};
const listOrgIdentities = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TOrgPermission) => {
const listOrgIdentities = async ({
orgId,
actor,
actorId,
actorAuthMethod,
actorOrgId,
limit,
offset,
orderBy,
direction,
textFilter
}: TListOrgIdentitiesByOrgIdDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity);
const identityMemberships = await identityOrgMembershipDAL.find({
[`${TableName.IdentityOrgMembership}.orgId` as "orgId"]: orgId
[`${TableName.IdentityOrgMembership}.orgId` as "orgId"]: orgId,
limit,
offset,
orderBy,
direction,
textFilter
});
return identityMemberships;
const totalCount = await identityOrgMembershipDAL.countAllOrgIdentities({
[`${TableName.IdentityOrgMembership}.orgId` as "orgId"]: orgId,
textFilter
});
return { identityMemberships, totalCount };
};
const listProjectIdentitiesByIdentityId = async ({
@@ -1,5 +1,5 @@
import { IPType } from "@app/lib/ip";
import { TOrgPermission } from "@app/lib/types";
import { OrderByDirection, TOrgPermission } from "@app/lib/types";
export type TCreateIdentityDTO = {
role: string;
@@ -29,3 +29,16 @@ export interface TIdentityTrustedIp {
export type TListProjectIdentitiesByIdentityIdDTO = {
identityId: string;
} & Omit<TOrgPermission, "orgId">;
export type TListOrgIdentitiesByOrgIdDTO = {
limit?: number;
offset?: number;
orderBy?: OrgIdentityOrderBy;
direction?: OrderByDirection;
textFilter?: string;
} & TOrgPermission;
export enum OrgIdentityOrderBy {
Name = "name",
Role = "role"
}