feat: completed api for new search identities

This commit is contained in:
=
2025-04-07 15:17:28 +05:30
parent bfcfffbabf
commit 03a3e80082
8 changed files with 572 additions and 5 deletions

View File

@@ -66,6 +66,17 @@ export const IDENTITIES = {
},
LIST: {
orgId: "The ID of the organization to list identities."
},
SEARCH: {
search: {
desc: "The filters to apply to the search.",
name: "The name of the identity to filter by.",
role: "The organizational role of the identity to filter by."
},
offset: "The offset to start from. If you enter 10, it will start from the 10th identity.",
limit: "The number of identities to return.",
orderBy: "The column to order identities by.",
orderDirection: "The direction to order identities in."
}
} as const;

View File

@@ -0,0 +1,142 @@
import { Knex } from "knex";
import { SearchResourceOperators, TSearchResourceOperator } from "./search";
const buildKnexQuery = (
query: Knex.QueryBuilder,
// when it's multiple table field means it's field1 or field2
fields: string | string[],
operator: SearchResourceOperators,
value: unknown
) => {
switch (operator) {
case SearchResourceOperators.$eq: {
if (typeof value !== "string" && typeof value !== "number")
throw new Error("Invalid value type for $eq operator");
if (typeof fields === "string") {
return void query.where(fields, "=", value);
}
return void query.where((qb) => {
return fields.forEach((el, index) => {
if (index === 0) {
return void qb.where(el, "=", value);
}
return void qb.orWhere(el, "=", value);
});
});
}
case SearchResourceOperators.$neq: {
if (typeof value !== "string" && typeof value !== "number")
throw new Error("Invalid value type for $neq operator");
if (typeof fields === "string") {
return void query.where(fields, "<>", value);
}
return void query.where((qb) => {
return fields.forEach((el, index) => {
if (index === 0) {
return void qb.where(el, "<>", value);
}
return void qb.orWhere(el, "<>", value);
});
});
}
case SearchResourceOperators.$in: {
if (!Array.isArray(value)) throw new Error("Invalid value type for $in operator");
if (typeof fields === "string") {
return void query.whereIn(fields, value);
}
return void query.where((qb) => {
return fields.forEach((el, index) => {
if (index === 0) {
return void qb.whereIn(el, value);
}
return void qb.orWhereIn(el, value);
});
});
}
case SearchResourceOperators.$contains: {
if (typeof value !== "string") throw new Error("Invalid value type for $contains operator");
if (typeof fields === "string") {
return void query.whereILike(fields, `%${value}%`);
}
return void query.where((qb) => {
return fields.forEach((el, index) => {
if (index === 0) {
return void qb.whereILike(el, `%${value}%`);
}
return void qb.orWhereILike(el, `%${value}%`);
});
});
break;
}
default:
break;
}
};
export const buildKnexFilterForSearchResource = <T extends { [K: string]: TSearchResourceOperator }, K extends keyof T>(
rootQuery: Knex.QueryBuilder,
searchFilter: T & { $or?: T[] },
getAttributeField: (attr: K) => string | string[] | null
) => {
const { $or: orFilters = [] } = searchFilter;
(Object.keys(searchFilter) as K[]).forEach((key) => {
// akhilmhdh: yes, we could have split in top. This is done to satisfy ts type error
if (key === "$or") return;
const dbField = getAttributeField(key);
if (!dbField) throw new Error(`DB field not found for ${String(key)}`);
const dbValue = searchFilter[key];
if (typeof dbValue === "string" || typeof dbValue === "number") {
buildKnexQuery(rootQuery, dbField, SearchResourceOperators.$eq, dbValue);
return;
}
Object.keys(dbValue as Record<string, unknown>).forEach((el) => {
buildKnexQuery(
rootQuery,
dbField,
el as SearchResourceOperators,
(dbValue as Record<SearchResourceOperators, unknown>)[el as SearchResourceOperators]
);
});
});
if (orFilters.length) {
void rootQuery.andWhere((andQb) => {
return orFilters.forEach((orFilter) => {
return void andQb.orWhere((qb) => {
(Object.keys(orFilter) as K[]).forEach((key) => {
const dbField = getAttributeField(key);
if (!dbField) throw new Error(`DB field not found for ${String(key)}`);
const dbValue = orFilter[key];
if (typeof dbValue === "string" || typeof dbValue === "number") {
buildKnexQuery(qb, dbField, SearchResourceOperators.$eq, dbValue);
return;
}
Object.keys(dbValue as Record<string, unknown>).forEach((el) => {
buildKnexQuery(
qb,
dbField,
el as SearchResourceOperators,
(dbValue as Record<SearchResourceOperators, unknown>)[el as SearchResourceOperators]
);
});
});
});
});
});
}
};

View File

@@ -0,0 +1,43 @@
import { z } from "zod";
export enum SearchResourceOperators {
$eq = "$eq",
$neq = "$neq",
$in = "$in",
$contains = "$contains"
}
export const SearchResourceOperatorSchema = z.union([
z.string(),
z.number(),
z
.object({
[SearchResourceOperators.$eq]: z.string().optional(),
[SearchResourceOperators.$neq]: z.string().optional(),
[SearchResourceOperators.$in]: z.string().array().optional(),
[SearchResourceOperators.$contains]: z.string().array().optional()
})
.partial()
]);
export type TSearchResourceOperator = z.infer<typeof SearchResourceOperatorSchema>;
export type TSearchResource = {
[k: string]: z.ZodOptional<
z.ZodUnion<
[
z.ZodEffects<z.ZodString | z.ZodNumber>,
z.ZodObject<{
[SearchResourceOperators.$eq]?: z.ZodOptional<z.ZodEffects<z.ZodString | z.ZodNumber>>;
[SearchResourceOperators.$neq]?: z.ZodOptional<z.ZodEffects<z.ZodString | z.ZodNumber>>;
[SearchResourceOperators.$in]?: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString | z.ZodNumber>>>;
[SearchResourceOperators.$contains]?: z.ZodOptional<z.ZodEffects<z.ZodString>>;
}>
]
>
>;
};
export const buildSearchZodSchema = <T extends TSearchResource>(schema: z.ZodObject<T>) => {
return schema.extend({ $or: schema.array().optional() }).optional();
};

View File

@@ -1,3 +1,5 @@
import { z } from "zod";
export enum CharacterType {
Alphabets = "alphabets",
Numbers = "numbers",
@@ -101,3 +103,10 @@ export const characterValidator = (allowedCharacters: CharacterType[]) => {
return regex.test(input);
};
};
export const zodValidateCharacters = (allowedCharacters: CharacterType[]) => {
const validator = characterValidator(allowedCharacters);
return (schema: z.ZodString, fieldName: string) => {
return schema.refine(validator, { message: `${fieldName} can only contain ${allowedCharacters.join(",")}` });
};
};

View File

@@ -3,15 +3,26 @@ import { z } from "zod";
import { IdentitiesSchema, IdentityOrgMembershipsSchema, OrgMembershipRole, OrgRolesSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { IDENTITIES } from "@app/lib/api-docs";
import { buildSearchZodSchema, SearchResourceOperators } from "@app/lib/search-resource/search";
import { OrderByDirection } from "@app/lib/types";
import { CharacterType, zodValidateCharacters } from "@app/lib/validator/validate-string";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
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";
import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns";
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
import { SanitizedProjectSchema } from "../sanitizedSchemas";
const searchResourceZodValidate = zodValidateCharacters([
CharacterType.AlphaNumeric,
CharacterType.Spaces,
CharacterType.Underscore,
CharacterType.Hyphen
]);
export const registerIdentityRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
@@ -245,7 +256,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
method: "GET",
url: "/",
config: {
rateLimit: writeLimit
rateLimit: readLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
@@ -289,6 +300,101 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
}
});
server.route({
method: "POST",
url: "/search",
config: {
rateLimit: readLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
description: "Search identities",
security: [
{
bearerAuth: []
}
],
body: z.object({
orderBy: z
.nativeEnum(OrgIdentityOrderBy)
.default(OrgIdentityOrderBy.Name)
.describe(IDENTITIES.SEARCH.orderBy)
.optional(),
orderDirection: z
.nativeEnum(OrderByDirection)
.default(OrderByDirection.ASC)
.describe(IDENTITIES.SEARCH.orderDirection)
.optional(),
limit: z.number().max(100).default(50).describe(IDENTITIES.SEARCH.limit),
offset: z.number().default(0).describe(IDENTITIES.SEARCH.offset),
search: buildSearchZodSchema(
z
.object({
name: z
.union([
searchResourceZodValidate(z.string().max(255), "Name"),
z
.object({
[SearchResourceOperators.$eq]: searchResourceZodValidate(z.string().max(255), "Name $eq"),
[SearchResourceOperators.$contains]: searchResourceZodValidate(
z.string().max(255),
"Name $contains"
),
[SearchResourceOperators.$in]: searchResourceZodValidate(z.string().max(255), "Name $in").array()
})
.partial()
])
.describe(IDENTITIES.SEARCH.search.name),
role: z
.union([
searchResourceZodValidate(z.string().max(255), "Role"),
z
.object({
[SearchResourceOperators.$eq]: searchResourceZodValidate(z.string().max(255), "Role $eq"),
[SearchResourceOperators.$in]: searchResourceZodValidate(z.string().max(255), "Role $in").array()
})
.partial()
])
.describe(IDENTITIES.SEARCH.search.name)
})
.describe(IDENTITIES.SEARCH.search.desc)
.partial()
)
}),
response: {
200: z.object({
identities: IdentityOrgMembershipsSchema.extend({
customRole: OrgRolesSchema.pick({
id: true,
name: true,
slug: true,
permissions: true,
description: true
}).optional(),
identity: IdentitiesSchema.pick({ name: true, id: true }).extend({
authMethods: z.array(z.string())
})
}).array(),
totalCount: z.number()
})
}
},
handler: async (req) => {
const { identityMemberships, totalCount } = await server.services.identity.searchOrgIdentities({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
searchFilter: req.body.search,
orgId: req.permission.orgId,
limit: req.body.limit,
offset: req.body.offset
});
return { identities: identityMemberships, totalCount };
}
});
server.route({
method: "GET",
url: "/:identityId/identity-memberships",

View File

@@ -14,10 +14,15 @@ import {
TIdentityUniversalAuths,
TOrgRoles
} from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { BadRequestError, DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
import { buildKnexFilterForSearchResource } from "@app/lib/search-resource/db";
import { OrderByDirection } from "@app/lib/types";
import { OrgIdentityOrderBy, TListOrgIdentitiesByOrgIdDTO } from "@app/services/identity/identity-types";
import {
OrgIdentityOrderBy,
TListOrgIdentitiesByOrgIdDTO,
TSearchOrgIdentitiesByOrgIdDAL
} from "@app/services/identity/identity-types";
import { buildAuthMethods } from "./identity-fns";
@@ -195,7 +200,6 @@ export const identityOrgDALFactory = (db: TDbClient) => {
"paginatedIdentity.identityId",
`${TableName.IdentityJwtAuth}.identityId`
)
.select(
db.ref("id").withSchema("paginatedIdentity"),
db.ref("role").withSchema("paginatedIdentity"),
@@ -309,6 +313,214 @@ export const identityOrgDALFactory = (db: TDbClient) => {
}
};
const searchIdentities = async (
{
limit,
offset = 0,
orderBy = OrgIdentityOrderBy.Name,
orderDirection = OrderByDirection.ASC,
searchFilter,
orgId
}: TSearchOrgIdentitiesByOrgIdDAL,
tx?: Knex
) => {
try {
const searchQuery = (tx || db.replicaNode())(TableName.IdentityOrgMembership)
.join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityOrgMembership}.identityId`)
.where(`${TableName.IdentityOrgMembership}.orgId`, orgId)
.leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`)
.orderBy(`${TableName.Identity}.${orderBy}`, orderDirection)
.select(`${TableName.IdentityOrgMembership}.id`)
.select<{ id: string; total_count: string }>(
db.raw(
`count(${TableName.IdentityOrgMembership}."identityId") OVER(PARTITION BY ${TableName.IdentityOrgMembership}."orgId") as total_count`
)
)
.as("searchedIdentities");
if (searchFilter) {
buildKnexFilterForSearchResource(searchQuery, searchFilter, (attr) => {
switch (attr) {
case "role":
return [`${TableName.OrgRoles}.slug`, `${TableName.IdentityOrgMembership}.role`];
case "name":
return `${TableName.Identity}.name`;
default:
throw new BadRequestError({ message: `Invalid ${String(attr)} provided` });
}
});
}
if (limit) {
void searchQuery.offset(offset).limit(limit);
}
type TSubquery = Awaited<typeof searchQuery>;
const query = (tx || db.replicaNode())(TableName.IdentityOrgMembership)
.where(`${TableName.IdentityOrgMembership}.orgId`, orgId)
.join<TSubquery>(searchQuery, `${TableName.IdentityOrgMembership}.id`, "searchedIdentities.id")
.join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`)
.leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`)
.leftJoin(TableName.IdentityMetadata, (queryBuilder) => {
void queryBuilder
.on(`${TableName.IdentityOrgMembership}.identityId`, `${TableName.IdentityMetadata}.identityId`)
.andOn(`${TableName.IdentityOrgMembership}.orgId`, `${TableName.IdentityMetadata}.orgId`);
})
.leftJoin(
TableName.IdentityUniversalAuth,
`${TableName.IdentityOrgMembership}.identityId`,
`${TableName.IdentityUniversalAuth}.identityId`
)
.leftJoin(
TableName.IdentityGcpAuth,
`${TableName.IdentityOrgMembership}.identityId`,
`${TableName.IdentityGcpAuth}.identityId`
)
.leftJoin(
TableName.IdentityAwsAuth,
`${TableName.IdentityOrgMembership}.identityId`,
`${TableName.IdentityAwsAuth}.identityId`
)
.leftJoin(
TableName.IdentityKubernetesAuth,
`${TableName.IdentityOrgMembership}.identityId`,
`${TableName.IdentityKubernetesAuth}.identityId`
)
.leftJoin(
TableName.IdentityOidcAuth,
`${TableName.IdentityOrgMembership}.identityId`,
`${TableName.IdentityOidcAuth}.identityId`
)
.leftJoin(
TableName.IdentityAzureAuth,
`${TableName.IdentityOrgMembership}.identityId`,
`${TableName.IdentityAzureAuth}.identityId`
)
.leftJoin(
TableName.IdentityTokenAuth,
`${TableName.IdentityOrgMembership}.identityId`,
`${TableName.IdentityTokenAuth}.identityId`
)
.leftJoin(
TableName.IdentityJwtAuth,
`${TableName.IdentityOrgMembership}.identityId`,
`${TableName.IdentityJwtAuth}.identityId`
)
.select(
db.ref("id").withSchema(TableName.IdentityOrgMembership),
db.ref("total_count").withSchema("searchedIdentities"),
db.ref("role").withSchema(TableName.IdentityOrgMembership),
db.ref("roleId").withSchema(TableName.IdentityOrgMembership),
db.ref("orgId").withSchema(TableName.IdentityOrgMembership),
db.ref("createdAt").withSchema(TableName.IdentityOrgMembership),
db.ref("updatedAt").withSchema(TableName.IdentityOrgMembership),
db.ref("identityId").withSchema(TableName.IdentityOrgMembership).as("identityId"),
db.ref("name").withSchema(TableName.Identity).as("identityName"),
db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth),
db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth),
db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth),
db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth),
db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth),
db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth),
db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth),
db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth)
)
// cr stands for custom role
.select(db.ref("id").as("crId").withSchema(TableName.OrgRoles))
.select(db.ref("name").as("crName").withSchema(TableName.OrgRoles))
.select(db.ref("slug").as("crSlug").withSchema(TableName.OrgRoles))
.select(db.ref("description").as("crDescription").withSchema(TableName.OrgRoles))
.select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles))
.select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles))
.select(
db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"),
db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue")
);
if (orderBy === OrgIdentityOrderBy.Name) {
void query.orderBy("identityName", orderDirection);
}
const docs = await query;
const formattedDocs = sqlNestRelationships({
data: docs,
key: "id",
parentMapper: ({
crId,
crDescription,
crSlug,
crPermission,
crName,
identityId,
identityName,
role,
roleId,
total_count,
id,
uaId,
awsId,
gcpId,
jwtId,
kubernetesId,
oidcId,
azureId,
tokenId,
createdAt,
updatedAt
}) => ({
role,
roleId,
identityId,
id,
total_count: total_count as string,
orgId,
createdAt,
updatedAt,
customRole: roleId
? {
id: crId,
name: crName,
slug: crSlug,
permissions: crPermission,
description: crDescription
}
: undefined,
identity: {
id: identityId,
name: identityName,
authMethods: buildAuthMethods({
uaId,
awsId,
gcpId,
kubernetesId,
oidcId,
azureId,
tokenId,
jwtId
})
}
}),
childrenMapper: [
{
key: "metadataId",
label: "metadata" as const,
mapper: ({ metadataKey, metadataValue, metadataId }) => ({
id: metadataId,
key: metadataKey,
value: metadataValue
})
}
]
});
return { docs: formattedDocs, totalCount: Number(formattedDocs?.[0]?.total_count ?? 0) };
} catch (error) {
throw new DatabaseError({ error, name: "FindByOrgId" });
}
};
const countAllOrgIdentities = async (
{ search, ...filter }: Partial<TIdentityOrgMemberships> & Pick<TListOrgIdentitiesByOrgIdDTO, "search">,
tx?: Knex
@@ -331,5 +543,5 @@ export const identityOrgDALFactory = (db: TDbClient) => {
}
};
return { ...identityOrgOrm, find, findOne, countAllOrgIdentities };
return { ...identityOrgOrm, find, findOne, countAllOrgIdentities, searchIdentities };
};

View File

@@ -21,6 +21,7 @@ import {
TGetIdentityByIdDTO,
TListOrgIdentitiesByOrgIdDTO,
TListProjectIdentitiesByIdentityIdDTO,
TSearchOrgIdentitiesByOrgIdDTO,
TUpdateIdentityDTO
} from "./identity-types";
@@ -288,6 +289,33 @@ export const identityServiceFactory = ({
return { identityMemberships, totalCount };
};
const searchOrgIdentities = async ({
orgId,
actor,
actorId,
actorAuthMethod,
actorOrgId,
limit,
offset,
orderBy,
orderDirection,
searchFilter = {}
}: TSearchOrgIdentitiesByOrgIdDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity);
const { totalCount, docs } = await identityOrgMembershipDAL.searchIdentities({
orgId,
limit,
offset,
orderBy,
orderDirection,
searchFilter
});
return { identityMemberships: docs, totalCount };
};
const listProjectIdentitiesByIdentityId = async ({
identityId,
actor,
@@ -317,6 +345,7 @@ export const identityServiceFactory = ({
deleteIdentity,
listOrgIdentities,
getIdentityById,
searchOrgIdentities,
listProjectIdentitiesByIdentityId
};
};

View File

@@ -1,4 +1,5 @@
import { IPType } from "@app/lib/ip";
import { TSearchResourceOperator } from "@app/lib/search-resource/search";
import { OrderByDirection, TOrgPermission } from "@app/lib/types";
export type TCreateIdentityDTO = {
@@ -46,3 +47,17 @@ export enum OrgIdentityOrderBy {
Name = "name"
// Role = "role"
}
export type TSearchOrgIdentitiesByOrgIdDAL = {
limit?: number;
offset?: number;
orderBy?: OrgIdentityOrderBy;
orderDirection?: OrderByDirection;
orgId: string;
searchFilter?: Partial<{
name: Omit<TSearchResourceOperator, "number">;
role: Omit<TSearchResourceOperator, "number">;
}>;
};
export type TSearchOrgIdentitiesByOrgIdDTO = TSearchOrgIdentitiesByOrgIdDAL & TOrgPermission;