feat(server): multi role with temporary support for identity

This commit is contained in:
Akhil Mohan
2024-03-12 21:49:33 +05:30
parent fa41b8bb47
commit 929f91a738
20 changed files with 502 additions and 162 deletions
+8
View File
@@ -32,6 +32,9 @@ import {
TIdentityOrgMemberships, TIdentityOrgMemberships,
TIdentityOrgMembershipsInsert, TIdentityOrgMembershipsInsert,
TIdentityOrgMembershipsUpdate, TIdentityOrgMembershipsUpdate,
TIdentityProjectMembershipRole,
TIdentityProjectMembershipRoleInsert,
TIdentityProjectMembershipRoleUpdate,
TIdentityProjectMemberships, TIdentityProjectMemberships,
TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsInsert,
TIdentityProjectMembershipsUpdate, TIdentityProjectMembershipsUpdate,
@@ -280,6 +283,11 @@ declare module "knex/types/tables" {
TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsInsert,
TIdentityProjectMembershipsUpdate TIdentityProjectMembershipsUpdate
>; >;
[TableName.IdentityProjectMembershipRole]: Knex.CompositeTableType<
TIdentityProjectMembershipRole,
TIdentityProjectMembershipRoleInsert,
TIdentityProjectMembershipRoleUpdate
>;
[TableName.ScimToken]: Knex.CompositeTableType<TScimTokens, TScimTokensInsert, TScimTokensUpdate>; [TableName.ScimToken]: Knex.CompositeTableType<TScimTokens, TScimTokensInsert, TScimTokensUpdate>;
[TableName.SecretApprovalPolicy]: Knex.CompositeTableType< [TableName.SecretApprovalPolicy]: Knex.CompositeTableType<
TSecretApprovalPolicies, TSecretApprovalPolicies,
@@ -21,37 +21,37 @@ export async function up(knex: Knex): Promise<void> {
t.datetime("temporaryAccessEndTime"); t.datetime("temporaryAccessEndTime");
t.timestamps(true, true, true); t.timestamps(true, true, true);
}); });
await createOnUpdateTrigger(knex, TableName.ProjectUserMembershipRole);
const projectMembershipStream = knex.select("*").from(TableName.ProjectMembership).stream();
const chunkSize = 1000;
let rows: TProjectUserMembershipRolesInsert[] = [];
for await (const row of projectMembershipStream) {
// disabling eslint just this part because the latest ts type doesn't have these values after migration as they are removed
/* eslint-disable */
// @ts-ignore - created at is inserted from old data
rows = rows.concat({
// @ts-ignore - missing in ts type post migration
role: row.role,
// @ts-ignore - missing in ts type post migration
customRoleId: row.roleId,
projectMembershipId: row.id,
createdAt: row.createdAt,
updatedAt: row.updatedAt
});
/* eslint-disable */
if (rows.length >= chunkSize) {
await knex(TableName.ProjectUserMembershipRole).insert(rows);
rows.splice(0, rows.length);
}
}
await knex(TableName.ProjectUserMembershipRole).insert(rows);
await knex.schema.alterTable(TableName.ProjectMembership, (t) => {
t.dropColumn("roleId");
t.dropColumn("role");
});
} }
await createOnUpdateTrigger(knex, TableName.ProjectUserMembershipRole);
const projectMembershipStream = knex.select("*").from(TableName.ProjectMembership).stream();
const chunkSize = 1000;
let rows: TProjectUserMembershipRolesInsert[] = [];
for await (const row of projectMembershipStream) {
// disabling eslint just this part because the latest ts type doesn't have these values after migration as they are removed
/* eslint-disable */
// @ts-ignore - created at is inserted from old data
rows = rows.concat({
// @ts-ignore - missing in ts type post migration
role: row.role,
// @ts-ignore - missing in ts type post migration
customRoleId: row.roleId,
projectMembershipId: row.id,
createdAt: row.createdAt,
updatedAt: row.updatedAt
});
/* eslint-disable */
if (rows.length >= chunkSize) {
await knex(TableName.ProjectUserMembershipRole).insert(rows);
rows.splice(0, rows.length);
}
}
if (rows.length) await knex(TableName.ProjectUserMembershipRole).insert(rows);
await knex.schema.alterTable(TableName.ProjectMembership, (t) => {
t.dropColumn("roleId");
t.dropColumn("role");
});
} }
export async function down(knex: Knex): Promise<void> { export async function down(knex: Knex): Promise<void> {
@@ -0,0 +1,80 @@
import { Knex } from "knex";
import { TableName, TIdentityProjectMembershipRoleInsert } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
const doesTableExist = await knex.schema.hasTable(TableName.IdentityProjectMembershipRole);
if (!doesTableExist) {
await knex.schema.createTable(TableName.IdentityProjectMembershipRole, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("role").notNullable();
t.uuid("projectMembershipId").notNullable();
t.foreign("projectMembershipId")
.references("id")
.inTable(TableName.IdentityProjectMembership)
.onDelete("CASCADE");
// until role is changed/removed the role should not deleted
t.uuid("customRoleId");
t.foreign("customRoleId").references("id").inTable(TableName.ProjectRoles);
t.boolean("isTemporary").notNullable().defaultTo(false);
t.string("temporaryMode");
t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc
t.datetime("temporaryAccessStartTime");
t.datetime("temporaryAccessEndTime");
t.timestamps(true, true, true);
});
}
await createOnUpdateTrigger(knex, TableName.IdentityProjectMembershipRole);
const projectMembershipStream = knex.select("*").from(TableName.IdentityProjectMembership).stream();
const chunkSize = 1000;
let rows: TIdentityProjectMembershipRoleInsert[] = [];
for await (const row of projectMembershipStream) {
// disabling eslint just this part because the latest ts type doesn't have these values after migration as they are removed
/* eslint-disable */
// @ts-ignore - created at is inserted from old data
rows = rows.concat({
// @ts-ignore - missing in ts type post migration
role: row.role,
// @ts-ignore - missing in ts type post migration
customRoleId: row.roleId,
projectMembershipId: row.id,
createdAt: row.createdAt,
updatedAt: row.updatedAt
});
/* eslint-disable */
if (rows.length >= chunkSize) {
await knex(TableName.IdentityProjectMembershipRole).insert(rows);
rows.splice(0, rows.length);
}
}
if(rows.length) await knex(TableName.IdentityProjectMembershipRole).insert(rows);
await knex.schema.alterTable(TableName.IdentityProjectMembership, (t) => {
t.dropColumn("roleId");
t.dropColumn("role");
});
}
export async function down(knex: Knex): Promise<void> {
const projectIdentityMembershipRoleStream = knex.select("*").from(TableName.IdentityProjectMembershipRole).stream();
await knex.schema.alterTable(TableName.IdentityProjectMembership, (t) => {
t.string("role");
t.uuid("roleId");
t.foreign("roleId").references("id").inTable(TableName.ProjectRoles);
});
for await (const row of projectIdentityMembershipRoleStream) {
await knex(TableName.IdentityProjectMembership).where({ id: row.projectMembershipId }).update({
// @ts-ignore - since the latest one doesn't have roleId anymore there will be type error here
roleId: row.customRoleId,
role: row.role
});
}
await knex.schema.alterTable(TableName.IdentityProjectMembership, (t) => {
t.string("role").notNullable().alter({ alterNullable: true });
});
await knex.schema.dropTableIfExists(TableName.IdentityProjectMembershipRole);
await dropOnUpdateTrigger(knex, TableName.IdentityProjectMembershipRole);
}
@@ -0,0 +1,31 @@
// 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 IdentityProjectMembershipRoleSchema = z.object({
id: z.string().uuid(),
role: z.string(),
projectMembershipId: z.string().uuid(),
customRoleId: z.string().uuid().nullable().optional(),
isTemporary: z.boolean().default(false),
temporaryMode: z.string().nullable().optional(),
temporaryRange: z.string().nullable().optional(),
temporaryAccessStartTime: z.date().nullable().optional(),
temporaryAccessEndTime: z.date().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TIdentityProjectMembershipRole = z.infer<typeof IdentityProjectMembershipRoleSchema>;
export type TIdentityProjectMembershipRoleInsert = Omit<
z.input<typeof IdentityProjectMembershipRoleSchema>,
TImmutableDBKeys
>;
export type TIdentityProjectMembershipRoleUpdate = Partial<
Omit<z.input<typeof IdentityProjectMembershipRoleSchema>, TImmutableDBKeys>
>;
@@ -9,8 +9,6 @@ import { TImmutableDBKeys } from "./models";
export const IdentityProjectMembershipsSchema = z.object({ export const IdentityProjectMembershipsSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
role: z.string(),
roleId: z.string().uuid().nullable().optional(),
projectId: z.string(), projectId: z.string(),
identityId: z.string().uuid(), identityId: z.string().uuid(),
createdAt: z.date(), createdAt: z.date(),
+1
View File
@@ -8,6 +8,7 @@ export * from "./git-app-org";
export * from "./identities"; export * from "./identities";
export * from "./identity-access-tokens"; export * from "./identity-access-tokens";
export * from "./identity-org-memberships"; export * from "./identity-org-memberships";
export * from "./identity-project-membership-role";
export * from "./identity-project-memberships"; export * from "./identity-project-memberships";
export * from "./identity-ua-client-secrets"; export * from "./identity-ua-client-secrets";
export * from "./identity-universal-auths"; export * from "./identity-universal-auths";
+1
View File
@@ -42,6 +42,7 @@ export enum TableName {
IdentityUaClientSecret = "identity_ua_client_secrets", IdentityUaClientSecret = "identity_ua_client_secrets",
IdentityOrgMembership = "identity_org_memberships", IdentityOrgMembership = "identity_org_memberships",
IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembership = "identity_project_memberships",
IdentityProjectMembershipRole = "identity_project_membership_role",
ScimToken = "scim_tokens", ScimToken = "scim_tokens",
SecretApprovalPolicy = "secret_approval_policies", SecretApprovalPolicy = "secret_approval_policies",
SecretApprovalPolicyApprover = "secret_approval_policies_approvers", SecretApprovalPolicyApprover = "secret_approval_policies_approvers",
+9 -3
View File
@@ -75,9 +75,15 @@ export async function seed(knex: Knex): Promise<void> {
} }
]); ]);
await knex(TableName.IdentityProjectMembership).insert({ const identityProjectMembership = await knex(TableName.IdentityProjectMembership)
identityId: seedData1.machineIdentity.id, .insert({
identityId: seedData1.machineIdentity.id,
projectId: seedData1.project.id
})
.returning("*");
await knex(TableName.IdentityProjectMembershipRole).insert({
role: ProjectMembershipRole.Admin, role: ProjectMembershipRole.Admin,
projectId: seedData1.project.id projectMembershipId: identityProjectMembership[0].id
}); });
} }
@@ -18,7 +18,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
secretVersioning: true, secretVersioning: true,
pitRecovery: false, pitRecovery: false,
ipAllowlisting: false, ipAllowlisting: false,
rbac: true, rbac: false,
customRateLimits: false, customRateLimits: false,
customAlerts: false, customAlerts: false,
auditLogs: false, auditLogs: false,
@@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { ProjectUserMembershipRolesSchema, TableName } from "@app/db/schemas"; import { IdentityProjectMembershipRoleSchema, ProjectUserMembershipRolesSchema, TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
import { selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; import { selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
@@ -108,18 +108,60 @@ export const permissionDALFactory = (db: TDbClient) => {
const getProjectIdentityPermission = async (identityId: string, projectId: string) => { const getProjectIdentityPermission = async (identityId: string, projectId: string) => {
try { try {
const membership = await db(TableName.IdentityProjectMembership) const docs = await db(TableName.IdentityProjectMembership)
.join(
TableName.IdentityProjectMembershipRole,
`${TableName.IdentityProjectMembershipRole}.projectMembershipId`,
`${TableName.IdentityProjectMembership}.id`
)
.leftJoin( .leftJoin(
TableName.ProjectRoles, TableName.ProjectRoles,
`${TableName.IdentityProjectMembership}.roleId`, `${TableName.IdentityProjectMembershipRole}.customRoleId`,
`${TableName.ProjectRoles}.id` `${TableName.ProjectRoles}.id`
) )
.where("identityId", identityId) .where("identityId", identityId)
.where(`${TableName.IdentityProjectMembership}.projectId`, projectId) .where(`${TableName.IdentityProjectMembership}.projectId`, projectId)
.select(selectAllTableCols(TableName.IdentityProjectMembership)) .select(selectAllTableCols(TableName.IdentityProjectMembershipRole))
.select("permissions") .select(
.first(); db.ref("id").withSchema(TableName.IdentityProjectMembership).as("membershipId"),
return membership; db.ref("createdAt").withSchema(TableName.IdentityProjectMembership).as("membershipCreatedAt"),
db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership).as("membershipUpdatedAt"),
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug")
)
.select("permissions");
const permission = sqlNestRelationships({
data: docs,
key: "membershipId",
parentMapper: ({ membershipId, membershipCreatedAt, membershipUpdatedAt }) => ({
id: membershipId,
identityId,
projectId,
createdAt: membershipCreatedAt,
updatedAt: membershipUpdatedAt,
// just a prefilled value
orgAuthEnforced: false,
orgId: ""
}),
childrenMapper: [
{
key: "id",
label: "roles" as const,
mapper: (data) =>
IdentityProjectMembershipRoleSchema.extend({
permissions: z.unknown(),
customRoleSlug: z.string().optional().nullable()
}).parse(data)
}
]
});
// when introducting cron mode change it here
const activeRoles = permission?.[0]?.roles.filter(
({ isTemporary, temporaryAccessEndTime }) =>
!isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime)
);
return permission?.[0] ? { ...permission[0], roles: activeRoles } : undefined;
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "GetProjectIdentityPermission" }); throw new DatabaseError({ error, name: "GetProjectIdentityPermission" });
} }
@@ -67,7 +67,7 @@ export const permissionServiceFactory = ({
const buildProjectPermission = (projectUserRoles: TBuildProjectPermissionDTO) => { const buildProjectPermission = (projectUserRoles: TBuildProjectPermissionDTO) => {
const rules = projectUserRoles const rules = projectUserRoles
.map(({ role, permission }) => { .map(({ role, permissions }) => {
switch (role) { switch (role) {
case ProjectMembershipRole.Admin: case ProjectMembershipRole.Admin:
return projectAdminPermissions; return projectAdminPermissions;
@@ -77,10 +77,11 @@ export const permissionServiceFactory = ({
return projectViewerPermission; return projectViewerPermission;
case ProjectMembershipRole.NoAccess: case ProjectMembershipRole.NoAccess:
return projectNoAccessPermissions; return projectNoAccessPermissions;
case ProjectMembershipRole.Custom: case ProjectMembershipRole.Custom: {
return unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>( return unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>(
permission as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[] permissions as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[]
); );
}
default: default:
throw new BadRequestError({ throw new BadRequestError({
name: "ProjectRoleInvalid", name: "ProjectRoleInvalid",
@@ -149,7 +150,11 @@ export const permissionServiceFactory = ({
}; };
// user permission for a project in an organization // user permission for a project in an organization
const getUserProjectPermission = async (userId: string, projectId: string, userOrgId?: string) => { const getUserProjectPermission = async (
userId: string,
projectId: string,
userOrgId?: string
): Promise<TProjectPermissionRT<ActorType.USER>> => {
const userProjectPermission = await permissionDAL.getProjectPermission(userId, projectId); const userProjectPermission = await permissionDAL.getProjectPermission(userId, projectId);
if (!userProjectPermission) throw new UnauthorizedError({ name: "User not in project" }); if (!userProjectPermission) throw new UnauthorizedError({ name: "User not in project" });
@@ -173,16 +178,28 @@ export const permissionServiceFactory = ({
}; };
}; };
const getIdentityProjectPermission = async (identityId: string, projectId: string) => { const getIdentityProjectPermission = async (
const membership = await permissionDAL.getProjectIdentityPermission(identityId, projectId); identityId: string,
if (!membership) throw new UnauthorizedError({ name: "Identity not in project" }); projectId: string
if (membership.role === ProjectMembershipRole.Custom && !membership.permissions) { ): Promise<TProjectPermissionRT<ActorType.IDENTITY>> => {
const identityProjectPermission = await permissionDAL.getProjectIdentityPermission(identityId, projectId);
if (!identityProjectPermission) throw new UnauthorizedError({ name: "Identity not in project" });
if (
identityProjectPermission.roles.some(
({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions
)
) {
throw new BadRequestError({ name: "Custom permission not found" }); throw new BadRequestError({ name: "Custom permission not found" });
} }
return { return {
permission: buildProjectPermission([{ role: membership.role, permission: membership.permissions }]), permission: buildProjectPermission(identityProjectPermission.roles),
membership membership: identityProjectPermission,
hasRole: (role: string) =>
identityProjectPermission.roles.findIndex(
({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug
) !== -1
}; };
}; };
@@ -202,7 +219,11 @@ export const permissionServiceFactory = ({
}; };
type TProjectPermissionRT<T extends ActorType> = T extends ActorType.SERVICE type TProjectPermissionRT<T extends ActorType> = T extends ActorType.SERVICE
? { permission: MongoAbility<ProjectPermissionSet, MongoQuery>; membership: undefined; hasRole: () => false } // service token doesn't have both membership and roles ? {
permission: MongoAbility<ProjectPermissionSet, MongoQuery>;
membership: undefined;
hasRole: (arg: string) => boolean;
} // service token doesn't have both membership and roles
: { : {
permission: MongoAbility<ProjectPermissionSet, MongoQuery>; permission: MongoAbility<ProjectPermissionSet, MongoQuery>;
membership: (T extends ActorType.USER ? TProjectMemberships : TIdentityProjectMemberships) & { membership: (T extends ActorType.USER ? TProjectMemberships : TIdentityProjectMemberships) & {
@@ -241,12 +262,12 @@ export const permissionServiceFactory = ({
if (!projectRole) throw new BadRequestError({ message: "Role not found" }); if (!projectRole) throw new BadRequestError({ message: "Role not found" });
return { return {
permission: buildProjectPermission([ permission: buildProjectPermission([
{ role: ProjectMembershipRole.Custom, permission: projectRole.permissions } { role: ProjectMembershipRole.Custom, permissions: projectRole.permissions }
]), ]),
role: projectRole role: projectRole
}; };
} }
return { permission: buildProjectPermission([{ role, permission: [] }]) }; return { permission: buildProjectPermission([{ role, permissions: [] }]) };
}; };
return { return {
@@ -1,4 +1,4 @@
export type TBuildProjectPermissionDTO = { export type TBuildProjectPermissionDTO = {
permission?: unknown; permissions?: unknown;
role: string; role: string;
}[]; }[];
+8 -2
View File
@@ -53,6 +53,7 @@ import { identityServiceFactory } from "@app/services/identity/identity-service"
import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal"; import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal";
import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service";
import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
import { identityProjectMembershipRoleDALFactory } from "@app/services/identity-project/identity-project-membership-role-dal";
import { identityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; import { identityProjectServiceFactory } from "@app/services/identity-project/identity-project-service";
import { identityUaClientSecretDALFactory } from "@app/services/identity-ua/identity-ua-client-secret-dal"; import { identityUaClientSecretDALFactory } from "@app/services/identity-ua/identity-ua-client-secret-dal";
import { identityUaDALFactory } from "@app/services/identity-ua/identity-ua-dal"; import { identityUaDALFactory } from "@app/services/identity-ua/identity-ua-dal";
@@ -166,6 +167,7 @@ export const registerRoutes = async (
const identityAccessTokenDAL = identityAccessTokenDALFactory(db); const identityAccessTokenDAL = identityAccessTokenDALFactory(db);
const identityOrgMembershipDAL = identityOrgDALFactory(db); const identityOrgMembershipDAL = identityOrgDALFactory(db);
const identityProjectDAL = identityProjectDALFactory(db); const identityProjectDAL = identityProjectDALFactory(db);
const identityProjectMembershipRoleDAL = identityProjectMembershipRoleDALFactory(db);
const identityUaDAL = identityUaDALFactory(db); const identityUaDAL = identityUaDALFactory(db);
const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db);
@@ -374,8 +376,10 @@ export const registerRoutes = async (
projectMembershipDAL, projectMembershipDAL,
folderDAL, folderDAL,
licenseService, licenseService,
projectUserMembershipRoleDAL projectUserMembershipRoleDAL,
identityProjectMembershipRoleDAL
}); });
const projectEnvService = projectEnvServiceFactory({ const projectEnvService = projectEnvServiceFactory({
permissionService, permissionService,
projectEnvDAL, projectEnvDAL,
@@ -526,7 +530,9 @@ export const registerRoutes = async (
permissionService, permissionService,
projectDAL, projectDAL,
identityProjectDAL, identityProjectDAL,
identityOrgMembershipDAL identityOrgMembershipDAL,
identityProjectMembershipRoleDAL,
projectRoleDAL
}); });
const identityUaService = identityUaServiceFactory({ const identityUaService = identityUaServiceFactory({
identityOrgMembershipDAL, identityOrgMembershipDAL,
@@ -136,21 +136,24 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
membershipId: z.string().trim() membershipId: z.string().trim()
}), }),
body: z.object({ body: z.object({
roles: z.array( roles: z
z.union([ .array(
z.object({ z.union([
role: z.string(), z.object({
isTemporary: z.literal(false).default(false) role: z.string(),
}), isTemporary: z.literal(false).default(false)
z.object({ }),
role: z.string(), z.object({
isTemporary: z.literal(true), role: z.string(),
temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), isTemporary: z.literal(true),
temporaryRange: z.string(), temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode),
temporaryAccessStartTime: z.string().datetime() temporaryRange: z.string(),
}) temporaryAccessStartTime: z.string().datetime()
]) })
) ])
)
.min(1)
.refine((data) => data.some(({ isTemporary }) => !isTemporary), "Atleast one permanent role required")
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -4,10 +4,11 @@ import {
IdentitiesSchema, IdentitiesSchema,
IdentityProjectMembershipsSchema, IdentityProjectMembershipsSchema,
ProjectMembershipRole, ProjectMembershipRole,
ProjectRolesSchema ProjectUserMembershipRolesSchema
} from "@app/db/schemas"; } from "@app/db/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types";
export const registerIdentityProjectRouter = async (server: FastifyZodProvider) => { export const registerIdentityProjectRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
@@ -57,24 +58,41 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider)
identityId: z.string().trim() identityId: z.string().trim()
}), }),
body: z.object({ body: z.object({
role: z.string().trim().min(1).default(ProjectMembershipRole.NoAccess) roles: z
.array(
z.union([
z.object({
role: z.string(),
isTemporary: z.literal(false).default(false)
}),
z.object({
role: z.string(),
isTemporary: z.literal(true),
temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode),
temporaryRange: z.string(),
temporaryAccessStartTime: z.string().datetime()
})
])
)
.min(1)
.refine((data) => data.some(({ isTemporary }) => !isTemporary), "Atleast one permanent role required")
}), }),
response: { response: {
200: z.object({ 200: z.object({
identityMembership: IdentityProjectMembershipsSchema roles: ProjectUserMembershipRolesSchema.array()
}) })
} }
}, },
handler: async (req) => { handler: async (req) => {
const identityMembership = await server.services.identityProject.updateProjectIdentity({ const roles = await server.services.identityProject.updateProjectIdentity({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
identityId: req.params.identityId, identityId: req.params.identityId,
projectId: req.params.projectId, projectId: req.params.projectId,
role: req.body.role roles: req.body.roles
}); });
return { identityMembership }; return { roles };
} }
}); });
@@ -127,18 +145,29 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider)
}), }),
response: { response: {
200: z.object({ 200: z.object({
identityMemberships: IdentityProjectMembershipsSchema.merge( identityMemberships: z
z.object({ .object({
customRole: ProjectRolesSchema.pick({ id: z.string(),
id: true, identityId: z.string(),
name: true, createdAt: z.date(),
slug: true, updatedAt: z.date(),
permissions: true, roles: z.array(
description: true z.object({
}).optional(), id: z.string(),
role: z.string(),
customRoleId: z.string().optional().nullable(),
customRoleName: z.string().optional().nullable(),
customRoleSlug: z.string().optional().nullable(),
isTemporary: z.boolean(),
temporaryMode: z.string().optional().nullable(),
temporaryRange: z.string().nullable().optional(),
temporaryAccessStartTime: z.date().nullable().optional(),
temporaryAccessEndTime: z.date().nullable().optional()
})
),
identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }) identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true })
}) })
).array() .array()
}) })
} }
}, },
@@ -3,7 +3,7 @@ import { Knex } from "knex";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas"; import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex"; import { ormify, sqlNestRelationships } from "@app/lib/knex";
export type TIdentityProjectDALFactory = ReturnType<typeof identityProjectDALFactory>; export type TIdentityProjectDALFactory = ReturnType<typeof identityProjectDALFactory>;
@@ -15,52 +15,81 @@ export const identityProjectDALFactory = (db: TDbClient) => {
const docs = await (tx || db)(TableName.IdentityProjectMembership) const docs = await (tx || db)(TableName.IdentityProjectMembership)
.where(`${TableName.IdentityProjectMembership}.projectId`, projectId) .where(`${TableName.IdentityProjectMembership}.projectId`, projectId)
.join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`) .join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`)
.join(
TableName.IdentityProjectMembershipRole,
`${TableName.IdentityProjectMembershipRole}.projectMembershipId`,
`${TableName.IdentityProjectMembership}.id`
)
.leftJoin( .leftJoin(
TableName.ProjectRoles, TableName.ProjectRoles,
`${TableName.IdentityProjectMembership}.roleId`, `${TableName.IdentityProjectMembershipRole}.customRoleId`,
`${TableName.ProjectRoles}.id` `${TableName.ProjectRoles}.id`
) )
.select(selectAllTableCols(TableName.IdentityProjectMembership)) .select(
// cr stands for custom role db.ref("id").withSchema(TableName.IdentityProjectMembership),
.select(db.ref("id").as("crId").withSchema(TableName.ProjectRoles)) db.ref("createdAt").withSchema(TableName.IdentityProjectMembership),
.select(db.ref("name").as("crName").withSchema(TableName.ProjectRoles)) db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership),
.select(db.ref("slug").as("crSlug").withSchema(TableName.ProjectRoles)) db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity),
.select(db.ref("description").as("crDescription").withSchema(TableName.ProjectRoles)) db.ref("id").as("identityId").withSchema(TableName.Identity),
.select(db.ref("permissions").as("crPermission").withSchema(TableName.ProjectRoles)) db.ref("name").as("identityName").withSchema(TableName.Identity),
.select(db.ref("permissions").as("crPermission").withSchema(TableName.ProjectRoles)) db.ref("id").withSchema(TableName.IdentityProjectMembership),
.select(db.ref("id").as("identityId").withSchema(TableName.Identity)) db.ref("role").withSchema(TableName.IdentityProjectMembershipRole),
.select(db.ref("name").as("identityName").withSchema(TableName.Identity)) db.ref("id").withSchema(TableName.IdentityProjectMembershipRole).as("membershipRoleId"),
.select(db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity)); db.ref("customRoleId").withSchema(TableName.IdentityProjectMembershipRole),
return docs.map( db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"),
({ db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"),
crId, db.ref("temporaryMode").withSchema(TableName.IdentityProjectMembershipRole),
crDescription, db.ref("isTemporary").withSchema(TableName.IdentityProjectMembershipRole),
crSlug, db.ref("temporaryRange").withSchema(TableName.IdentityProjectMembershipRole),
crPermission, db.ref("temporaryAccessStartTime").withSchema(TableName.IdentityProjectMembershipRole),
crName, db.ref("temporaryAccessEndTime").withSchema(TableName.IdentityProjectMembershipRole)
identityId, );
identityName,
identityAuthMethod, const members = sqlNestRelationships({
...el data: docs,
}) => ({ parentMapper: ({ identityId, identityName, identityAuthMethod, id, createdAt, updatedAt }) => ({
...el, id,
identityId, identityId,
createdAt,
updatedAt,
identity: { identity: {
id: identityId, id: identityId,
name: identityName, name: identityName,
authMethod: identityAuthMethod authMethod: identityAuthMethod
}, }
customRole: el.roleId }),
? { key: "id",
id: crId, childrenMapper: [
name: crName, {
slug: crSlug, label: "roles" as const,
permissions: crPermission, key: "membershipRoleId",
description: crDescription mapper: ({
} role,
: undefined customRoleId,
}) customRoleName,
); customRoleSlug,
membershipRoleId,
temporaryRange,
temporaryMode,
temporaryAccessEndTime,
temporaryAccessStartTime,
isTemporary
}) => ({
id: membershipRoleId,
role,
customRoleId,
customRoleName,
customRoleSlug,
temporaryRange,
temporaryMode,
temporaryAccessEndTime,
temporaryAccessStartTime,
isTemporary
})
}
]
});
return members;
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "FindByProjectId" }); throw new DatabaseError({ error, name: "FindByProjectId" });
} }
@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TIdentityProjectMembershipRoleDALFactory = ReturnType<typeof identityProjectMembershipRoleDALFactory>;
export const identityProjectMembershipRoleDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.IdentityProjectMembershipRole);
return orm;
};
@@ -1,15 +1,20 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import ms from "ms";
import { ProjectMembershipRole, TProjectRoles } from "@app/db/schemas"; import { ProjectMembershipRole } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { isAtLeastAsPrivileged } from "@app/lib/casl";
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn";
import { ActorType } from "../auth/auth-type"; import { ActorType } from "../auth/auth-type";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
import { TProjectDALFactory } from "../project/project-dal"; import { TProjectDALFactory } from "../project/project-dal";
import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types";
import { TProjectRoleDALFactory } from "../project-role/project-role-dal";
import { TIdentityProjectDALFactory } from "./identity-project-dal"; import { TIdentityProjectDALFactory } from "./identity-project-dal";
import { TIdentityProjectMembershipRoleDALFactory } from "./identity-project-membership-role-dal";
import { import {
TCreateProjectIdentityDTO, TCreateProjectIdentityDTO,
TDeleteProjectIdentityDTO, TDeleteProjectIdentityDTO,
@@ -19,7 +24,12 @@ import {
type TIdentityProjectServiceFactoryDep = { type TIdentityProjectServiceFactoryDep = {
identityProjectDAL: TIdentityProjectDALFactory; identityProjectDAL: TIdentityProjectDALFactory;
identityProjectMembershipRoleDAL: Pick<
TIdentityProjectMembershipRoleDALFactory,
"create" | "transaction" | "insertMany" | "delete"
>;
projectDAL: Pick<TProjectDALFactory, "findById">; projectDAL: Pick<TProjectDALFactory, "findById">;
projectRoleDAL: Pick<TProjectRoleDALFactory, "find">;
identityOrgMembershipDAL: Pick<TIdentityOrgDALFactory, "findOne">; identityOrgMembershipDAL: Pick<TIdentityOrgDALFactory, "findOne">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getProjectPermissionByRole">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getProjectPermissionByRole">;
}; };
@@ -30,7 +40,9 @@ export const identityProjectServiceFactory = ({
identityProjectDAL, identityProjectDAL,
permissionService, permissionService,
identityOrgMembershipDAL, identityOrgMembershipDAL,
projectDAL identityProjectMembershipRoleDAL,
projectDAL,
projectRoleDAL
}: TIdentityProjectServiceFactoryDep) => { }: TIdentityProjectServiceFactoryDep) => {
const createProjectIdentity = async ({ const createProjectIdentity = async ({
identityId, identityId,
@@ -70,11 +82,24 @@ export const identityProjectServiceFactory = ({
}); });
const isCustomRole = Boolean(customRole); const isCustomRole = Boolean(customRole);
const projectIdentity = await identityProjectDAL.create({ const projectIdentity = await identityProjectDAL.transaction(async (tx) => {
identityId, const identityProjectMembership = await identityProjectDAL.create(
projectId: project.id, {
role: isCustomRole ? ProjectMembershipRole.Custom : role, identityId,
roleId: customRole?.id projectId: project.id
},
tx
);
await identityProjectMembershipRoleDAL.create(
{
projectMembershipId: identityProjectMembership.id,
role: isCustomRole ? ProjectMembershipRole.Custom : role,
customRoleId: customRole?.id
},
tx
);
return identityProjectMembership;
}); });
return projectIdentity; return projectIdentity;
}; };
@@ -82,7 +107,7 @@ export const identityProjectServiceFactory = ({
const updateProjectIdentity = async ({ const updateProjectIdentity = async ({
projectId, projectId,
identityId, identityId,
role, roles,
actor, actor,
actorId, actorId,
actorOrgId actorOrgId
@@ -106,28 +131,54 @@ export const identityProjectServiceFactory = ({
if (!hasRequiredPriviledges) if (!hasRequiredPriviledges)
throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" });
let customRole: TProjectRoles | undefined; // validate custom roles input
if (role) { const customInputRoles = roles.filter(
const { permission: rolePermission, role: customOrgRole } = await permissionService.getProjectPermissionByRole( ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole)
role,
projectIdentity.projectId
);
const isCustomRole = Boolean(customOrgRole);
const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission);
if (!hasRequiredNewRolePermission)
throw new BadRequestError({ message: "Failed to create a more privileged identity" });
if (isCustomRole) customRole = customOrgRole;
}
const [updatedProjectIdentity] = await identityProjectDAL.update(
{ projectId, identityId: projectIdentity.identityId },
{
role: customRole ? ProjectMembershipRole.Custom : role,
roleId: customRole ? customRole.id : null
}
); );
return updatedProjectIdentity; const hasCustomRole = Boolean(customInputRoles.length);
const customRoles = hasCustomRole
? await projectRoleDAL.find({
projectId,
$in: { slug: customInputRoles.map(({ role }) => role) }
})
: [];
if (customRoles.length !== customInputRoles.length) throw new BadRequestError({ message: "Custom role not found" });
const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug);
const santiziedProjectMembershipRoles = roles.map((inputRole) => {
const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]);
if (!inputRole.isTemporary) {
return {
projectMembershipId: projectIdentity.id,
role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role,
customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null
};
}
// check cron or relative here later for now its just relative
const relativeTimeInMs = ms(inputRole.temporaryRange);
if (relativeTimeInMs <= 0) {
throw new BadRequestError({ message: "Temporary relative time range must be positive" });
}
return {
projectMembershipId: projectIdentity.id,
role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role,
customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null,
isTemporary: true,
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
temporaryRange: inputRole.temporaryRange,
temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime),
temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs)
};
});
const updatedRoles = await identityProjectMembershipRoleDAL.transaction(async (tx) => {
await identityProjectMembershipRoleDAL.delete({ projectMembershipId: projectIdentity.id }, tx);
return identityProjectMembershipRoleDAL.insertMany(santiziedProjectMembershipRoles, tx);
});
return updatedRoles;
}; };
const deleteProjectIdentity = async ({ const deleteProjectIdentity = async ({
@@ -1,12 +1,26 @@
import { TProjectPermission } from "@app/lib/types"; import { TProjectPermission } from "@app/lib/types";
import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types";
export type TCreateProjectIdentityDTO = { export type TCreateProjectIdentityDTO = {
identityId: string; identityId: string;
role: string; role: string;
} & TProjectPermission; } & TProjectPermission;
export type TUpdateProjectIdentityDTO = { export type TUpdateProjectIdentityDTO = {
role: string; roles: (
| {
role: string;
isTemporary?: false;
}
| {
role: string;
isTemporary: true;
temporaryMode: ProjectUserMembershipTemporaryMode.Relative;
temporaryRange: string;
temporaryAccessStartTime: string;
}
)[];
identityId: string; identityId: string;
} & TProjectPermission; } & TProjectPermission;
@@ -17,6 +17,7 @@ import { TProjectPermission } from "@app/lib/types";
import { ActorType } from "../auth/auth-type"; import { ActorType } from "../auth/auth-type";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal"; import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal";
import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal";
import { TOrgServiceFactory } from "../org/org-service"; import { TOrgServiceFactory } from "../org/org-service";
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
@@ -51,6 +52,7 @@ type TProjectServiceFactoryDep = {
projectEnvDAL: Pick<TProjectEnvDALFactory, "insertMany" | "find">; projectEnvDAL: Pick<TProjectEnvDALFactory, "insertMany" | "find">;
identityOrgMembershipDAL: TIdentityOrgDALFactory; identityOrgMembershipDAL: TIdentityOrgDALFactory;
identityProjectDAL: TIdentityProjectDALFactory; identityProjectDAL: TIdentityProjectDALFactory;
identityProjectMembershipRoleDAL: Pick<TIdentityProjectMembershipRoleDALFactory, "create">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "create" | "findLatestProjectKey" | "delete" | "find" | "insertMany">; projectKeyDAL: Pick<TProjectKeyDALFactory, "create" | "findLatestProjectKey" | "delete" | "find" | "insertMany">;
projectBotDAL: Pick<TProjectBotDALFactory, "create" | "findById" | "delete" | "findOne">; projectBotDAL: Pick<TProjectBotDALFactory, "create" | "findById" | "delete" | "findOne">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create" | "findProjectGhostUser" | "findOne">; projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create" | "findProjectGhostUser" | "findOne">;
@@ -78,7 +80,8 @@ export const projectServiceFactory = ({
projectMembershipDAL, projectMembershipDAL,
projectEnvDAL, projectEnvDAL,
licenseService, licenseService,
projectUserMembershipRoleDAL projectUserMembershipRoleDAL,
identityProjectMembershipRoleDAL
}: TProjectServiceFactoryDep) => { }: TProjectServiceFactoryDep) => {
/* /*
* Create workspace. Make user the admin * Create workspace. Make user the admin
@@ -275,12 +278,19 @@ export const projectServiceFactory = ({
}); });
const isCustomRole = Boolean(customRole); const isCustomRole = Boolean(customRole);
await identityProjectDAL.create( const identityProjectMembership = await identityProjectDAL.create(
{ {
identityId: actorId, identityId: actorId,
projectId: project.id, projectId: project.id
},
tx
);
await identityProjectMembershipRoleDAL.create(
{
projectMembershipId: identityProjectMembership.id,
role: isCustomRole ? ProjectMembershipRole.Custom : ProjectMembershipRole.Admin, role: isCustomRole ? ProjectMembershipRole.Custom : ProjectMembershipRole.Admin,
roleId: customRole?.id customRoleId: customRole?.id
}, },
tx tx
); );