feat: implemented ui and api for managing user,identity metadata

This commit is contained in:
=
2024-10-02 00:39:43 +05:30
parent 15c98a1d2e
commit 77e191d63e
24 changed files with 570 additions and 92 deletions
+8
View File
@@ -101,6 +101,9 @@ import {
TIdentityKubernetesAuths,
TIdentityKubernetesAuthsInsert,
TIdentityKubernetesAuthsUpdate,
TIdentityMetadata,
TIdentityMetadataInsert,
TIdentityMetadataUpdate,
TIdentityOidcAuths,
TIdentityOidcAuthsInsert,
TIdentityOidcAuthsUpdate,
@@ -546,6 +549,11 @@ declare module "knex/types/tables" {
TIdentityUniversalAuthsInsert,
TIdentityUniversalAuthsUpdate
>;
[TableName.IdentityMetadata]: KnexOriginal.CompositeTableType<
TIdentityMetadata,
TIdentityMetadataInsert,
TIdentityMetadataUpdate
>;
[TableName.IdentityKubernetesAuth]: KnexOriginal.CompositeTableType<
TIdentityKubernetesAuths,
TIdentityKubernetesAuthsInsert,
@@ -0,0 +1,25 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.IdentityMetadata))) {
await knex.schema.createTable(TableName.IdentityMetadata, (tb) => {
tb.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
tb.string("key").notNullable();
tb.string("value").notNullable();
tb.uuid("userOrgMembershipId");
tb.foreign("userOrgMembershipId").references("id").inTable(TableName.OrgMembership).onDelete("CASCADE");
tb.uuid("identityOrgMembershipId");
tb.foreign("identityOrgMembershipId")
.references("id")
.inTable(TableName.IdentityOrgMembership)
.onDelete("CASCADE");
tb.timestamps(true, true, true);
});
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.IdentityMetadata);
}
@@ -0,0 +1,22 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const IdentityMetadataSchema = z.object({
id: z.string().uuid(),
key: z.string(),
value: z.string(),
userOrgMembershipId: z.string().uuid().nullable().optional(),
identityOrgMembershipId: z.string().uuid().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TIdentityMetadata = z.infer<typeof IdentityMetadataSchema>;
export type TIdentityMetadataInsert = Omit<z.input<typeof IdentityMetadataSchema>, TImmutableDBKeys>;
export type TIdentityMetadataUpdate = Partial<Omit<z.input<typeof IdentityMetadataSchema>, TImmutableDBKeys>>;
+1
View File
@@ -31,6 +31,7 @@ export * from "./identity-aws-auths";
export * from "./identity-azure-auths";
export * from "./identity-gcp-auths";
export * from "./identity-kubernetes-auths";
export * from "./identity-metadata";
export * from "./identity-oidc-auths";
export * from "./identity-org-memberships";
export * from "./identity-project-additional-privilege";
+2
View File
@@ -70,6 +70,8 @@ export enum TableName {
IdentityProjectMembership = "identity_project_memberships",
IdentityProjectMembershipRole = "identity_project_membership_role",
IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege",
// used by both identity and users
IdentityMetadata = "identity_metadata",
ScimToken = "scim_tokens",
AccessApprovalPolicy = "access_approval_policies",
AccessApprovalPolicyApprover = "access_approval_policies_approvers",
+5 -1
View File
@@ -360,7 +360,11 @@ export const ORGANIZATIONS = {
organizationId: "The ID of the organization to update the membership for.",
membershipId: "The ID of the membership to update.",
role: "The new role of the membership.",
isActive: "The active status of the membership"
isActive: "The active status of the membership",
metadata: {
key: "The key for user metadata tag.",
value: "The value for user metadata tag."
}
},
DELETE_USER_MEMBERSHIP: {
organizationId: "The ID of the organization to delete the membership from.",
+5 -1
View File
@@ -101,6 +101,7 @@ import { groupProjectDALFactory } from "@app/services/group-project/group-projec
import { groupProjectMembershipRoleDALFactory } from "@app/services/group-project/group-project-membership-role-dal";
import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service";
import { identityDALFactory } from "@app/services/identity/identity-dal";
import { identityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal";
import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal";
import { identityServiceFactory } from "@app/services/identity/identity-service";
import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal";
@@ -265,6 +266,7 @@ export const registerRoutes = async (
const serviceTokenDAL = serviceTokenDALFactory(db);
const identityDAL = identityDALFactory(db);
const identityMetadataDAL = identityMetadataDALFactory(db);
const identityAccessTokenDAL = identityAccessTokenDALFactory(db);
const identityOrgMembershipDAL = identityOrgDALFactory(db);
const identityProjectDAL = identityProjectDALFactory(db);
@@ -489,6 +491,7 @@ export const registerRoutes = async (
});
const orgService = orgServiceFactory({
userAliasDAL,
identityMetadataDAL,
licenseService,
samlConfigDAL,
orgRoleDAL,
@@ -1027,7 +1030,8 @@ export const registerRoutes = async (
identityDAL,
identityOrgMembershipDAL,
identityProjectDAL,
licenseService
licenseService,
identityMetadataDAL
});
const identityAccessTokenService = identityAccessTokenServiceFactory({
@@ -29,7 +29,11 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
body: z.object({
name: z.string().trim().describe(IDENTITIES.CREATE.name),
organizationId: z.string().trim().describe(IDENTITIES.CREATE.organizationId),
role: z.string().trim().min(1).default(OrgMembershipRole.NoAccess).describe(IDENTITIES.CREATE.role)
role: z.string().trim().min(1).default(OrgMembershipRole.NoAccess).describe(IDENTITIES.CREATE.role),
metadata: z
.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) })
.array()
.optional()
}),
response: {
200: z.object({
@@ -93,7 +97,11 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
}),
body: z.object({
name: z.string().trim().optional().describe(IDENTITIES.UPDATE.name),
role: z.string().trim().min(1).optional().describe(IDENTITIES.UPDATE.role)
role: z.string().trim().min(1).optional().describe(IDENTITIES.UPDATE.role),
metadata: z
.object({ key: z.string().trim().min(1), value: z.string().trim().min(1) })
.array()
.optional()
}),
response: {
200: z.object({
@@ -193,6 +201,14 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => {
response: {
200: z.object({
identity: IdentityOrgMembershipsSchema.extend({
metadata: z
.object({
key: z.string().trim().min(1),
id: z.string().trim().min(1),
value: z.string().trim().min(1)
})
.array()
.optional(),
customRole: OrgRolesSchema.pick({
id: true,
name: true,
@@ -130,18 +130,24 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
}),
response: {
200: z.object({
membership: OrgMembershipsSchema.merge(
z.object({
user: UsersSchema.pick({
username: true,
email: true,
isEmailVerified: true,
firstName: true,
lastName: true,
id: true
}).merge(z.object({ publicKey: z.string().nullable() }))
})
).omit({ createdAt: true, updatedAt: true })
membership: OrgMembershipsSchema.extend({
metadata: z
.object({
key: z.string().trim().min(1),
id: z.string().trim().min(1),
value: z.string().trim().min(1)
})
.array()
.optional(),
user: UsersSchema.pick({
username: true,
email: true,
isEmailVerified: true,
firstName: true,
lastName: true,
id: true
}).extend({ publicKey: z.string().nullable() })
}).omit({ createdAt: true, updatedAt: true })
})
}
},
@@ -178,7 +184,14 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
}),
body: z.object({
role: z.string().trim().optional().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.role),
isActive: z.boolean().optional().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.isActive)
isActive: z.boolean().optional().describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.isActive),
metadata: z
.object({
key: z.string().trim().min(1).describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.metadata.key),
value: z.string().trim().min(1).describe(ORGANIZATIONS.UPDATE_USER_MEMBERSHIP.metadata.value)
})
.array()
.optional()
}),
response: {
200: z.object({
@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TIdentityMetadataDALFactory = ReturnType<typeof identityMetadataDALFactory>;
export const identityMetadataDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.IdentityMetadata);
return orm;
};
@@ -3,7 +3,7 @@ import { Knex } from "knex";
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 { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types";
import { TListOrgIdentitiesByOrgIdDTO } from "@app/services/identity/identity-types";
@@ -42,10 +42,25 @@ export const identityOrgDALFactory = (db: TDbClient) => {
tx?: Knex
) => {
try {
const paginatedFetchIdentity = (tx || db.replicaNode())(TableName.Identity)
.where((queryBuilder) => {
if (limit) {
void queryBuilder.offset(offset).limit(limit);
}
})
.as(TableName.Identity);
const query = (tx || db.replicaNode())(TableName.IdentityOrgMembership)
.where(filter)
.join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`)
.join<Awaited<typeof paginatedFetchIdentity>>(paginatedFetchIdentity, (queryBuilder) => {
queryBuilder.on(`${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`);
})
.leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`)
.leftJoin(
TableName.IdentityMetadata,
`${TableName.IdentityMetadata}.identityOrgMembershipId`,
`${TableName.IdentityOrgMembership}.id`
)
.select(selectAllTableCols(TableName.IdentityOrgMembership))
// cr stands for custom role
.select(db.ref("id").as("crId").withSchema(TableName.OrgRoles))
@@ -55,12 +70,15 @@ export const identityOrgDALFactory = (db: TDbClient) => {
.select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles))
.select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles))
.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);
}
.select(
db.ref("name").as("identityName").withSchema(TableName.Identity),
db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity)
)
.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) {
switch (orderBy) {
@@ -80,9 +98,10 @@ export const identityOrgDALFactory = (db: TDbClient) => {
}
const docs = await query;
return docs.map(
({
const formattedDocs = sqlNestRelationships({
data: docs,
key: "id",
parentMapper: ({
crId,
crDescription,
crSlug,
@@ -91,16 +110,21 @@ export const identityOrgDALFactory = (db: TDbClient) => {
identityId,
identityName,
identityAuthMethod,
...el
role,
roleId,
id,
orgId,
createdAt,
updatedAt
}) => ({
...el,
role,
roleId,
identityId,
identity: {
id: identityId,
name: identityName,
authMethod: identityAuthMethod
},
customRole: el.roleId
id,
orgId,
createdAt,
updatedAt,
customRole: roleId
? {
id: crId,
name: crName,
@@ -108,9 +132,27 @@ export const identityOrgDALFactory = (db: TDbClient) => {
permissions: crPermission,
description: crDescription
}
: undefined
})
);
: undefined,
identity: {
id: identityId,
name: identityName,
authMethod: identityAuthMethod as string
}
}),
childrenMapper: [
{
key: "metadataId",
label: "metadata" as const,
mapper: ({ metadataKey, metadataValue, metadataId }) => ({
id: metadataId,
key: metadataKey,
value: metadataValue
})
}
]
});
return formattedDocs;
} catch (error) {
throw new DatabaseError({ error, name: "FindByOrgId" });
}
@@ -10,6 +10,7 @@ import { TIdentityProjectDALFactory } from "@app/services/identity-project/ident
import { ActorType } from "../auth/auth-type";
import { TIdentityDALFactory } from "./identity-dal";
import { TIdentityMetadataDALFactory } from "./identity-metadata-dal";
import { TIdentityOrgDALFactory } from "./identity-org-dal";
import {
TCreateIdentityDTO,
@@ -22,6 +23,7 @@ import {
type TIdentityServiceFactoryDep = {
identityDAL: TIdentityDALFactory;
identityMetadataDAL: TIdentityMetadataDALFactory;
identityOrgMembershipDAL: TIdentityOrgDALFactory;
identityProjectDAL: Pick<TIdentityProjectDALFactory, "findByIdentityId">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRole">;
@@ -32,6 +34,7 @@ export type TIdentityServiceFactory = ReturnType<typeof identityServiceFactory>;
export const identityServiceFactory = ({
identityDAL,
identityMetadataDAL,
identityOrgMembershipDAL,
identityProjectDAL,
permissionService,
@@ -44,7 +47,8 @@ export const identityServiceFactory = ({
orgId,
actorId,
actorAuthMethod,
actorOrgId
actorOrgId,
metadata
}: TCreateIdentityDTO) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity);
@@ -69,7 +73,7 @@ export const identityServiceFactory = ({
const identity = await identityDAL.transaction(async (tx) => {
const newIdentity = await identityDAL.create({ name }, tx);
await identityOrgMembershipDAL.create(
const identityOrgMembership = await identityOrgMembershipDAL.create(
{
identityId: newIdentity.id,
orgId,
@@ -78,6 +82,16 @@ export const identityServiceFactory = ({
},
tx
);
if (metadata && metadata.length) {
await identityMetadataDAL.insertMany(
metadata.map(({ key, value }) => ({
identityOrgMembershipId: identityOrgMembership.id,
key,
value
})),
tx
);
}
return newIdentity;
});
await licenseService.updateSubscriptionOrgMemberCount(orgId);
@@ -92,7 +106,8 @@ export const identityServiceFactory = ({
actor,
actorId,
actorAuthMethod,
actorOrgId
actorOrgId,
metadata
}: TUpdateIdentityDTO) => {
const identityOrgMembership = await identityOrgMembershipDAL.findOne({ identityId: id });
if (!identityOrgMembership) throw new NotFoundError({ message: `Failed to find identity with id ${id}` });
@@ -134,8 +149,8 @@ export const identityServiceFactory = ({
const identity = await identityDAL.transaction(async (tx) => {
const newIdentity = name ? await identityDAL.updateById(id, { name }, tx) : await identityDAL.findById(id, tx);
if (role) {
await identityOrgMembershipDAL.update(
{ identityId: id },
await identityOrgMembershipDAL.updateById(
identityOrgMembership.id,
{
role: customRole ? OrgMembershipRole.Custom : role,
roleId: customRole?.id || null
@@ -143,6 +158,19 @@ export const identityServiceFactory = ({
tx
);
}
if (metadata) {
await identityMetadataDAL.delete({ identityOrgMembershipId: identityOrgMembership.id }, tx);
if (metadata.length) {
await identityMetadataDAL.insertMany(
metadata.map(({ key, value }) => ({
identityOrgMembershipId: identityOrgMembership.id,
key,
value
})),
tx
);
}
}
return newIdentity;
});
@@ -4,12 +4,14 @@ import { OrderByDirection, TOrgPermission } from "@app/lib/types";
export type TCreateIdentityDTO = {
role: string;
name: string;
metadata?: { key: string; value: string }[];
} & TOrgPermission;
export type TUpdateIdentityDTO = {
id: string;
role?: string;
name?: string;
metadata?: { key: string; value: string }[];
} & Omit<TOrgPermission, "orgId">;
export type TDeleteIdentityDTO = {
@@ -1,7 +1,7 @@
import { TDbClient } from "@app/db";
import { TableName, TUserEncryptionKeys } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
import { ormify, sqlNestRelationships } from "@app/lib/knex";
export type TOrgMembershipDALFactory = ReturnType<typeof orgMembershipDALFactory>;
@@ -19,6 +19,11 @@ export const orgMembershipDALFactory = (db: TDbClient) => {
`${TableName.UserEncryptionKey}.userId`,
`${TableName.Users}.id`
)
.leftJoin(
TableName.IdentityMetadata,
`${TableName.IdentityMetadata}.userOrgMembershipId`,
`${TableName.OrgMembership}.id`
)
.select(
db.ref("id").withSchema(TableName.OrgMembership),
db.ref("inviteEmail").withSchema(TableName.OrgMembership),
@@ -33,19 +38,66 @@ export const orgMembershipDALFactory = (db: TDbClient) => {
db.ref("lastName").withSchema(TableName.Users),
db.ref("isEmailVerified").withSchema(TableName.Users),
db.ref("id").withSchema(TableName.Users).as("userId"),
db.ref("publicKey").withSchema(TableName.UserEncryptionKey)
db.ref("publicKey").withSchema(TableName.UserEncryptionKey),
db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"),
db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue")
)
.where({ isGhost: false }) // MAKE SURE USER IS NOT A GHOST USER
.first();
.where({ isGhost: false }); // MAKE SURE USER IS NOT A GHOST USER
if (!member) return undefined;
const { email, isEmailVerified, username, firstName, lastName, userId, publicKey, ...data } = member;
const doc = sqlNestRelationships({
data: member,
key: "id",
parentMapper: ({
email,
isEmailVerified,
username,
firstName,
lastName,
userId,
publicKey,
roleId,
orgId,
id,
role,
status,
isActive,
inviteEmail
}) => ({
roleId,
orgId,
id,
role,
status,
isActive,
inviteEmail,
user: {
id: userId,
email,
isEmailVerified,
username,
firstName,
lastName,
userId,
publicKey
}
}),
childrenMapper: [
{
key: "metadataId",
label: "metadata" as const,
mapper: ({ metadataKey, metadataValue, metadataId }) => ({
id: metadataId,
key: metadataKey,
value: metadataValue
})
}
]
});
return {
...data,
user: { email, isEmailVerified, username, firstName, lastName, id: userId, publicKey }
};
return doc?.[0];
} catch (error) {
throw new DatabaseError({ error, name: "Find org membership by id" });
}
+30 -11
View File
@@ -37,6 +37,7 @@ import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
import { ActorAuthMethod, ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type";
import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service";
import { TokenType } from "../auth-token/auth-token-types";
import { TIdentityMetadataDALFactory } from "../identity/identity-metadata-dal";
import { TProjectDALFactory } from "../project/project-dal";
import { assignWorkspaceKeysToMembers } from "../project/project-fns";
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
@@ -72,6 +73,7 @@ type TOrgServiceFactoryDep = {
userDAL: TUserDALFactory;
groupDAL: TGroupDALFactory;
projectDAL: TProjectDALFactory;
identityMetadataDAL: Pick<TIdentityMetadataDALFactory, "delete" | "insertMany" | "transaction">;
projectMembershipDAL: Pick<
TProjectMembershipDALFactory,
"findProjectMembershipsByUserId" | "delete" | "create" | "find" | "insertMany" | "transaction"
@@ -115,7 +117,8 @@ export const orgServiceFactory = ({
projectRoleDAL,
samlConfigDAL,
projectBotDAL,
projectUserMembershipRoleDAL
projectUserMembershipRoleDAL,
identityMetadataDAL
}: TOrgServiceFactoryDep) => {
/*
* Get organization details by the organization id
@@ -404,7 +407,8 @@ export const orgServiceFactory = ({
userId,
membershipId,
actorAuthMethod,
actorOrgId
actorOrgId,
metadata
}: TUpdateOrgMembershipDTO) => {
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Member);
@@ -418,6 +422,8 @@ export const orgServiceFactory = ({
throw new UnauthorizedError({ message: "Cannot update own organization membership" });
const isCustomRole = !Object.values(OrgMembershipRole).includes(role as OrgMembershipRole);
let userRole = role;
let userRoleId: string | null = null;
if (role && isCustomRole) {
const customRole = await orgRoleDAL.findOne({ slug: role, orgId });
if (!customRole) throw new BadRequestError({ name: "UpdateMembership", message: "Organization role not found" });
@@ -428,17 +434,30 @@ export const orgServiceFactory = ({
message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member."
});
const [membership] = await orgDAL.updateMembership(
{ id: membershipId, orgId },
{
role: OrgMembershipRole.Custom,
roleId: customRole.id
}
);
return membership;
userRole = OrgMembershipRole.Custom;
userRoleId = customRole.id;
}
const membership = await orgDAL.transaction(async (tx) => {
const [updatedOrgMembership] = await orgDAL.updateMembership(
{ id: membershipId, orgId },
{ role: userRole, roleId: userRoleId, isActive }
);
const [membership] = await orgDAL.updateMembership({ id: membershipId, orgId }, { role, roleId: null, isActive });
if (metadata) {
await identityMetadataDAL.delete({ userOrgMembershipId: updatedOrgMembership.id }, tx);
if (metadata.length) {
await identityMetadataDAL.insertMany(
metadata.map(({ key, value }) => ({
userOrgMembershipId: updatedOrgMembership.id,
key,
value
})),
tx
);
}
}
return updatedOrgMembership;
});
return membership;
};
/*
+1
View File
@@ -9,6 +9,7 @@ export type TUpdateOrgMembershipDTO = {
role?: string;
isActive?: boolean;
actorOrgId: string | undefined;
metadata?: { key: string; value: string }[];
actorAuthMethod: ActorAuthMethod;
};