Merge pull request #1552 from akhilmhdh/feat/temp-roles

feat: multiple project role and temporary role support
This commit is contained in:
Maidul Islam
2024-03-12 12:57:00 -04:00
committed by GitHub
46 changed files with 2145 additions and 595 deletions
+21
View File
@@ -45,6 +45,7 @@
"knex": "^3.0.1", "knex": "^3.0.1",
"libsodium-wrappers": "^0.7.13", "libsodium-wrappers": "^0.7.13",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"ms": "^2.1.3",
"mysql2": "^3.9.1", "mysql2": "^3.9.1",
"nanoid": "^5.0.4", "nanoid": "^5.0.4",
"nodemailer": "^6.9.9", "nodemailer": "^6.9.9",
@@ -54,6 +55,7 @@
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
"passport-ldapauth": "^3.0.1", "passport-ldapauth": "^3.0.1",
"pg": "^8.11.3", "pg": "^8.11.3",
"pg-query-stream": "^4.5.3",
"picomatch": "^3.0.1", "picomatch": "^3.0.1",
"pino": "^8.16.2", "pino": "^8.16.2",
"posthog-node": "^3.6.2", "posthog-node": "^3.6.2",
@@ -10028,6 +10030,14 @@
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz",
"integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg=="
}, },
"node_modules/pg-cursor": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.10.3.tgz",
"integrity": "sha512-rDyBVoqPVnx/PTmnwQAYgusSeAKlTL++gmpf5klVK+mYMFEqsOc6VHHZnPKc/4lOvr4r6fiMuoxSFuBF1dx4FQ==",
"peerDependencies": {
"pg": "^8"
}
},
"node_modules/pg-int8": { "node_modules/pg-int8": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
@@ -10058,6 +10068,17 @@
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.0.tgz", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.6.0.tgz",
"integrity": "sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q==" "integrity": "sha512-M+PDm637OY5WM307051+bsDia5Xej6d9IR4GwJse1qA1DIhiKlksvrneZOYQq42OM+spubpcNYEo2FcKQrDk+Q=="
}, },
"node_modules/pg-query-stream": {
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/pg-query-stream/-/pg-query-stream-4.5.3.tgz",
"integrity": "sha512-ufa94r/lHJdjAm3+zPZEO0gXAmCb4tZPaOt7O76mjcxdL/HxwTuryy76km+u0odBBgtfdKFYq/9XGfiYeQF0yA==",
"dependencies": {
"pg-cursor": "^2.10.3"
},
"peerDependencies": {
"pg": "^8"
}
},
"node_modules/pg-types": { "node_modules/pg-types": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+2
View File
@@ -106,6 +106,7 @@
"knex": "^3.0.1", "knex": "^3.0.1",
"libsodium-wrappers": "^0.7.13", "libsodium-wrappers": "^0.7.13",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"ms": "^2.1.3",
"mysql2": "^3.9.1", "mysql2": "^3.9.1",
"nanoid": "^5.0.4", "nanoid": "^5.0.4",
"nodemailer": "^6.9.9", "nodemailer": "^6.9.9",
@@ -115,6 +116,7 @@
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
"passport-ldapauth": "^3.0.1", "passport-ldapauth": "^3.0.1",
"pg": "^8.11.3", "pg": "^8.11.3",
"pg-query-stream": "^4.5.3",
"picomatch": "^3.0.1", "picomatch": "^3.0.1",
"pino": "^8.16.2", "pino": "^8.16.2",
"posthog-node": "^3.6.2", "posthog-node": "^3.6.2",
+16
View File
@@ -32,6 +32,9 @@ import {
TIdentityOrgMemberships, TIdentityOrgMemberships,
TIdentityOrgMembershipsInsert, TIdentityOrgMembershipsInsert,
TIdentityOrgMembershipsUpdate, TIdentityOrgMembershipsUpdate,
TIdentityProjectMembershipRole,
TIdentityProjectMembershipRoleInsert,
TIdentityProjectMembershipRoleUpdate,
TIdentityProjectMemberships, TIdentityProjectMemberships,
TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsInsert,
TIdentityProjectMembershipsUpdate, TIdentityProjectMembershipsUpdate,
@@ -83,6 +86,9 @@ import {
TProjects, TProjects,
TProjectsInsert, TProjectsInsert,
TProjectsUpdate, TProjectsUpdate,
TProjectUserMembershipRoles,
TProjectUserMembershipRolesInsert,
TProjectUserMembershipRolesUpdate,
TSamlConfigs, TSamlConfigs,
TSamlConfigsInsert, TSamlConfigsInsert,
TSamlConfigsUpdate, TSamlConfigsUpdate,
@@ -221,6 +227,11 @@ declare module "knex/types/tables" {
TProjectEnvironmentsUpdate TProjectEnvironmentsUpdate
>; >;
[TableName.ProjectBot]: Knex.CompositeTableType<TProjectBots, TProjectBotsInsert, TProjectBotsUpdate>; [TableName.ProjectBot]: Knex.CompositeTableType<TProjectBots, TProjectBotsInsert, TProjectBotsUpdate>;
[TableName.ProjectUserMembershipRole]: Knex.CompositeTableType<
TProjectUserMembershipRoles,
TProjectUserMembershipRolesInsert,
TProjectUserMembershipRolesUpdate
>;
[TableName.ProjectRoles]: Knex.CompositeTableType<TProjectRoles, TProjectRolesInsert, TProjectRolesUpdate>; [TableName.ProjectRoles]: Knex.CompositeTableType<TProjectRoles, TProjectRolesInsert, TProjectRolesUpdate>;
[TableName.ProjectKeys]: Knex.CompositeTableType<TProjectKeys, TProjectKeysInsert, TProjectKeysUpdate>; [TableName.ProjectKeys]: Knex.CompositeTableType<TProjectKeys, TProjectKeysInsert, TProjectKeysUpdate>;
[TableName.Secret]: Knex.CompositeTableType<TSecrets, TSecretsInsert, TSecretsUpdate>; [TableName.Secret]: Knex.CompositeTableType<TSecrets, TSecretsInsert, TSecretsUpdate>;
@@ -272,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,
@@ -0,0 +1,61 @@
import { Knex } from "knex";
import { TableName, TProjectUserMembershipRolesInsert } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
const doesTableExist = await knex.schema.hasTable(TableName.ProjectUserMembershipRole);
if (!doesTableExist) {
await knex.schema.createTable(TableName.ProjectUserMembershipRole, (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.ProjectMembership).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.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);
// will be dropped later
// await knex.schema.alterTable(TableName.ProjectMembership, (t) => {
// t.dropColumn("roleId");
// t.dropColumn("role");
// });
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.ProjectUserMembershipRole);
await dropOnUpdateTrigger(knex, TableName.ProjectUserMembershipRole);
}
@@ -0,0 +1,63 @@
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> {
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>
>;
+2
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";
@@ -25,6 +26,7 @@ export * from "./project-environments";
export * from "./project-keys"; export * from "./project-keys";
export * from "./project-memberships"; export * from "./project-memberships";
export * from "./project-roles"; export * from "./project-roles";
export * from "./project-user-membership-roles";
export * from "./projects"; export * from "./projects";
export * from "./saml-configs"; export * from "./saml-configs";
export * from "./scim-tokens"; export * from "./scim-tokens";
+2
View File
@@ -20,6 +20,7 @@ export enum TableName {
Environment = "project_environments", Environment = "project_environments",
ProjectMembership = "project_memberships", ProjectMembership = "project_memberships",
ProjectRoles = "project_roles", ProjectRoles = "project_roles",
ProjectUserMembershipRole = "project_user_membership_roles",
ProjectKeys = "project_keys", ProjectKeys = "project_keys",
Secret = "secrets", Secret = "secrets",
SecretBlindIndex = "secret_blind_indexes", SecretBlindIndex = "secret_blind_indexes",
@@ -41,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",
@@ -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 ProjectUserMembershipRolesSchema = 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 TProjectUserMembershipRoles = z.infer<typeof ProjectUserMembershipRolesSchema>;
export type TProjectUserMembershipRolesInsert = Omit<
z.input<typeof ProjectUserMembershipRolesSchema>,
TImmutableDBKeys
>;
export type TProjectUserMembershipRolesUpdate = Partial<
Omit<z.input<typeof ProjectUserMembershipRolesSchema>, TImmutableDBKeys>
>;
+11 -5
View File
@@ -4,7 +4,7 @@ import { Knex } from "knex";
import { encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
import { OrgMembershipRole, SecretEncryptionAlgo, SecretKeyEncoding, TableName } from "../schemas"; import { ProjectMembershipRole, SecretEncryptionAlgo, SecretKeyEncoding, TableName } from "../schemas";
import { buildUserProjectKey, getUserPrivateKey, seedData1 } from "../seed-data"; import { buildUserProjectKey, getUserPrivateKey, seedData1 } from "../seed-data";
export const DEFAULT_PROJECT_ENVS = [ export const DEFAULT_PROJECT_ENVS = [
@@ -30,10 +30,16 @@ export async function seed(knex: Knex): Promise<void> {
}) })
.returning("*"); .returning("*");
await knex(TableName.ProjectMembership).insert({ const projectMembership = await knex(TableName.ProjectMembership)
projectId: project.id, .insert({
role: OrgMembershipRole.Admin, projectId: project.id,
userId: seedData1.id userId: seedData1.id,
role: ProjectMembershipRole.Admin
})
.returning("*");
await knex(TableName.ProjectUserMembershipRole).insert({
role: ProjectMembershipRole.Admin,
projectMembershipId: projectMembership[0].id
}); });
const user = await knex(TableName.UserEncryptionKey).where({ userId: seedData1.id }).first(); const user = await knex(TableName.UserEncryptionKey).where({ userId: seedData1.id }).first();
+10 -3
View File
@@ -75,9 +75,16 @@ 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,
role: ProjectMembershipRole.Admin
})
.returning("*");
await knex(TableName.IdentityProjectMembershipRole).insert({
role: ProjectMembershipRole.Admin, role: ProjectMembershipRole.Admin,
projectId: seedData1.project.id projectMembershipId: identityProjectMembership[0].id
}); });
} }
+24 -3
View File
@@ -1,6 +1,7 @@
import slugify from "@sindresorhus/slugify";
import { z } from "zod"; import { z } from "zod";
import { OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; import { OrgMembershipRole, OrgMembershipsSchema, OrgRolesSchema } 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";
@@ -13,7 +14,17 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
organizationId: z.string().trim() organizationId: z.string().trim()
}), }),
body: z.object({ body: z.object({
slug: z.string().trim(), slug: z
.string()
.min(1)
.trim()
.refine(
(val) => Object.keys(OrgMembershipRole).includes(val),
"Please choose a different slug, the slug you have entered is reserved"
)
.refine((v) => slugify(v) === v, {
message: "Slug must be a valid"
}),
name: z.string().trim(), name: z.string().trim(),
description: z.string().trim().optional(), description: z.string().trim().optional(),
permissions: z.any().array() permissions: z.any().array()
@@ -45,7 +56,17 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
roleId: z.string().trim() roleId: z.string().trim()
}), }),
body: z.object({ body: z.object({
slug: z.string().trim().optional(), slug: z
.string()
.trim()
.optional()
.refine(
(val) => typeof val === "undefined" || Object.keys(OrgMembershipRole).includes(val),
"Please choose a different slug, the slug you have entered is reserved."
)
.refine((val) => typeof val === "undefined" || slugify(val) === val, {
message: "Slug must be a valid"
}),
name: z.string().trim().optional(), name: z.string().trim().optional(),
description: z.string().trim().optional(), description: z.string().trim().optional(),
permissions: z.any().array() permissions: z.any().array()
@@ -1,7 +1,9 @@
import { z } from "zod";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { 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 } from "@app/lib/knex"; import { selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
export type TPermissionDALFactory = ReturnType<typeof permissionDALFactory>; export type TPermissionDALFactory = ReturnType<typeof permissionDALFactory>;
@@ -43,21 +45,72 @@ export const permissionDALFactory = (db: TDbClient) => {
const getProjectPermission = async (userId: string, projectId: string) => { const getProjectPermission = async (userId: string, projectId: string) => {
try { try {
const membership = await db(TableName.ProjectMembership) const docs = await db(TableName.ProjectMembership)
.leftJoin(TableName.ProjectRoles, `${TableName.ProjectMembership}.roleId`, `${TableName.ProjectRoles}.id`) .join(
TableName.ProjectUserMembershipRole,
`${TableName.ProjectUserMembershipRole}.projectMembershipId`,
`${TableName.ProjectMembership}.id`
)
.leftJoin(
TableName.ProjectRoles,
`${TableName.ProjectUserMembershipRole}.customRoleId`,
`${TableName.ProjectRoles}.id`
)
.join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`)
.join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`)
.where("userId", userId) .where("userId", userId)
.where(`${TableName.ProjectMembership}.projectId`, projectId) .where(`${TableName.ProjectMembership}.projectId`, projectId)
.select(selectAllTableCols(TableName.ProjectMembership)) .select(selectAllTableCols(TableName.ProjectUserMembershipRole))
.select( .select(
db.ref("id").withSchema(TableName.ProjectMembership).as("membershipId"),
// TODO(roll-forward-migration): remove this field when we drop this in next migration after a week
db.ref("role").withSchema(TableName.ProjectMembership).as("oldRoleField"),
db.ref("createdAt").withSchema(TableName.ProjectMembership).as("membershipCreatedAt"),
db.ref("updatedAt").withSchema(TableName.ProjectMembership).as("membershipUpdatedAt"),
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
db.ref("orgId").withSchema(TableName.Project) db.ref("orgId").withSchema(TableName.Project),
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug")
) )
.select("permissions") .select("permissions");
.first();
return membership; const permission = sqlNestRelationships({
data: docs,
key: "membershipId",
parentMapper: ({
orgId,
orgAuthEnforced,
membershipId,
membershipCreatedAt,
membershipUpdatedAt,
oldRoleField
}) => ({
orgId,
orgAuthEnforced,
userId,
role: oldRoleField,
id: membershipId,
projectId,
createdAt: membershipCreatedAt,
updatedAt: membershipUpdatedAt
}),
childrenMapper: [
{
key: "id",
label: "roles" as const,
mapper: (data) =>
ProjectUserMembershipRolesSchema.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: "GetProjectPermission" }); throw new DatabaseError({ error, name: "GetProjectPermission" });
} }
@@ -65,18 +118,62 @@ 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("role").withSchema(TableName.IdentityProjectMembership).as("oldRoleField"),
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, oldRoleField }) => ({
id: membershipId,
identityId,
projectId,
role: oldRoleField,
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" });
} }
@@ -18,6 +18,7 @@ import { TServiceTokenDALFactory } from "@app/services/service-token/service-tok
import { orgAdminPermissions, orgMemberPermissions, orgNoAccessPermissions, OrgPermissionSet } from "./org-permission"; import { orgAdminPermissions, orgMemberPermissions, orgNoAccessPermissions, OrgPermissionSet } from "./org-permission";
import { TPermissionDALFactory } from "./permission-dal"; import { TPermissionDALFactory } from "./permission-dal";
import { TBuildProjectPermissionDTO } from "./permission-types";
import { import {
buildServiceTokenProjectPermission, buildServiceTokenProjectPermission,
projectAdminPermissions, projectAdminPermissions,
@@ -64,31 +65,35 @@ export const permissionServiceFactory = ({
} }
}; };
const buildProjectPermission = (role: string, permission?: unknown) => { const buildProjectPermission = (projectUserRoles: TBuildProjectPermissionDTO) => {
switch (role) { const rules = projectUserRoles
case ProjectMembershipRole.Admin: .map(({ role, permissions }) => {
return projectAdminPermissions; switch (role) {
case ProjectMembershipRole.Member: case ProjectMembershipRole.Admin:
return projectMemberPermissions; return projectAdminPermissions;
case ProjectMembershipRole.Viewer: case ProjectMembershipRole.Member:
return projectViewerPermission; return projectMemberPermissions;
case ProjectMembershipRole.NoAccess: case ProjectMembershipRole.Viewer:
return projectNoAccessPermissions; return projectViewerPermission;
case ProjectMembershipRole.Custom: case ProjectMembershipRole.NoAccess:
return createMongoAbility<ProjectPermissionSet>( return projectNoAccessPermissions;
unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>( case ProjectMembershipRole.Custom: {
permission as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[] return unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>(
), permissions as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[]
{ );
conditionsMatcher
} }
); default:
default: throw new BadRequestError({
throw new BadRequestError({ name: "ProjectRoleInvalid",
name: "ProjectRoleInvalid", message: "Project role not found"
message: "Project role not found" });
}); }
} })
.reduce((curr, prev) => prev.concat(curr), []);
return createMongoAbility<ProjectPermissionSet>(rules, {
conditionsMatcher
});
}; };
/* /*
@@ -145,33 +150,56 @@ 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 (
const membership = await permissionDAL.getProjectPermission(userId, projectId); userId: string,
if (!membership) throw new UnauthorizedError({ name: "User not in project" }); projectId: string,
if (membership.role === ProjectMembershipRole.Custom && !membership.permissions) { userOrgId?: string
): Promise<TProjectPermissionRT<ActorType.USER>> => {
const userProjectPermission = await permissionDAL.getProjectPermission(userId, projectId);
if (!userProjectPermission) throw new UnauthorizedError({ name: "User not in project" });
if (
userProjectPermission.roles.some(({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions)
) {
throw new BadRequestError({ name: "Custom permission not found" }); throw new BadRequestError({ name: "Custom permission not found" });
} }
if (membership.orgAuthEnforced && membership.orgId !== userOrgId) { if (userProjectPermission.orgAuthEnforced && userProjectPermission.orgId !== userOrgId) {
throw new BadRequestError({ name: "Cannot access org-scoped resource" }); throw new BadRequestError({ name: "Cannot access org-scoped resource" });
} }
return { return {
permission: buildProjectPermission(membership.role, membership.permissions), permission: buildProjectPermission(userProjectPermission.roles),
membership membership: userProjectPermission,
hasRole: (role: string) =>
userProjectPermission.roles.findIndex(
({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug
) !== -1
}; };
}; };
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(membership.role, membership.permissions), permission: buildProjectPermission(identityProjectPermission.roles),
membership membership: identityProjectPermission,
hasRole: (role: string) =>
identityProjectPermission.roles.findIndex(
({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug
) !== -1
}; };
}; };
@@ -191,14 +219,19 @@ 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 } ? {
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) & {
orgAuthEnforced: boolean; orgAuthEnforced: boolean | null | undefined;
orgId: string; orgId: string;
permissions?: unknown; roles: Array<{ role: string }>;
}; };
hasRole: (role: string) => boolean;
}; };
const getProjectPermission = async <T extends ActorType>( const getProjectPermission = async <T extends ActorType>(
@@ -228,11 +261,13 @@ export const permissionServiceFactory = ({
const projectRole = await projectRoleDAL.findOne({ slug: role, projectId }); const projectRole = await projectRoleDAL.findOne({ slug: role, projectId });
if (!projectRole) throw new BadRequestError({ message: "Role not found" }); if (!projectRole) throw new BadRequestError({ message: "Role not found" });
return { return {
permission: buildProjectPermission(ProjectMembershipRole.Custom, projectRole.permissions), permission: buildProjectPermission([
{ role: ProjectMembershipRole.Custom, permissions: projectRole.permissions }
]),
role: projectRole role: projectRole
}; };
} }
return { permission: buildProjectPermission(role, []) }; return { permission: buildProjectPermission([{ role, permissions: [] }]) };
}; };
return { return {
@@ -0,0 +1,4 @@
export type TBuildProjectPermissionDTO = {
permissions?: unknown;
role: string;
}[];
@@ -56,8 +56,8 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback]
| [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback]; | [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback];
const buildAdminPermission = () => { const buildAdminPermissionRules = () => {
const { can, build } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility); const { can, rules } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets);
can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets);
@@ -135,13 +135,13 @@ const buildAdminPermission = () => {
can(ProjectPermissionActions.Edit, ProjectPermissionSub.Project); can(ProjectPermissionActions.Edit, ProjectPermissionSub.Project);
can(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Project);
return build({ conditionsMatcher }); return rules;
}; };
export const projectAdminPermissions = buildAdminPermission(); export const projectAdminPermissions = buildAdminPermissionRules();
const buildMemberPermission = () => { const buildMemberPermissionRules = () => {
const { can, build } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility); const { can, rules } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets);
can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets);
@@ -196,13 +196,13 @@ const buildMemberPermission = () => {
can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs);
can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList);
return build({ conditionsMatcher }); return rules;
}; };
export const projectMemberPermissions = buildMemberPermission(); export const projectMemberPermissions = buildMemberPermissionRules();
const buildViewerPermission = () => { const buildViewerPermissionRules = () => {
const { can, build } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility); const { can, rules } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
@@ -220,14 +220,14 @@ const buildViewerPermission = () => {
can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs);
can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList);
return build({ conditionsMatcher }); return rules;
}; };
export const projectViewerPermission = buildViewerPermission(); export const projectViewerPermission = buildViewerPermissionRules();
const buildNoAccessProjectPermission = () => { const buildNoAccessProjectPermission = () => {
const { build } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility); const { rules } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility);
return build({ conditionsMatcher }); return rules;
}; };
export const buildServiceTokenProjectPermission = ( export const buildServiceTokenProjectPermission = (
@@ -129,14 +129,14 @@ export const secretApprovalRequestServiceFactory = ({
if (!secretApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" }); if (!secretApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" });
const { policy } = secretApprovalRequest; const { policy } = secretApprovalRequest;
const { membership } = await permissionService.getProjectPermission( const { membership, hasRole } = await permissionService.getProjectPermission(
actor, actor,
actorId, actorId,
secretApprovalRequest.projectId, secretApprovalRequest.projectId,
actorOrgId actorOrgId
); );
if ( if (
membership.role !== ProjectMembershipRole.Admin && !hasRole(ProjectMembershipRole.Admin) &&
secretApprovalRequest.committerId !== membership.id && secretApprovalRequest.committerId !== membership.id &&
!policy.approvers.find((approverId) => approverId === membership.id) !policy.approvers.find((approverId) => approverId === membership.id)
) { ) {
@@ -156,14 +156,14 @@ export const secretApprovalRequestServiceFactory = ({
if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" });
const { policy } = secretApprovalRequest; const { policy } = secretApprovalRequest;
const { membership } = await permissionService.getProjectPermission( const { membership, hasRole } = await permissionService.getProjectPermission(
ActorType.USER, ActorType.USER,
actorId, actorId,
secretApprovalRequest.projectId, secretApprovalRequest.projectId,
actorOrgId actorOrgId
); );
if ( if (
membership.role !== ProjectMembershipRole.Admin && !hasRole(ProjectMembershipRole.Admin) &&
secretApprovalRequest.committerId !== membership.id && secretApprovalRequest.committerId !== membership.id &&
!policy.approvers.find((approverId) => approverId === membership.id) !policy.approvers.find((approverId) => approverId === membership.id)
) { ) {
@@ -198,14 +198,14 @@ export const secretApprovalRequestServiceFactory = ({
if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" });
const { policy } = secretApprovalRequest; const { policy } = secretApprovalRequest;
const { membership } = await permissionService.getProjectPermission( const { membership, hasRole } = await permissionService.getProjectPermission(
ActorType.USER, ActorType.USER,
actorId, actorId,
secretApprovalRequest.projectId, secretApprovalRequest.projectId,
actorOrgId actorOrgId
); );
if ( if (
membership.role !== ProjectMembershipRole.Admin && !hasRole(ProjectMembershipRole.Admin) &&
secretApprovalRequest.committerId !== membership.id && secretApprovalRequest.committerId !== membership.id &&
!policy.approvers.find((approverId) => approverId === membership.id) !policy.approvers.find((approverId) => approverId === membership.id)
) { ) {
@@ -236,9 +236,14 @@ export const secretApprovalRequestServiceFactory = ({
if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" });
const { policy, folderId, projectId } = secretApprovalRequest; const { policy, folderId, projectId } = secretApprovalRequest;
const { membership } = await permissionService.getProjectPermission(ActorType.USER, actorId, projectId, actorOrgId); const { membership, hasRole } = await permissionService.getProjectPermission(
ActorType.USER,
actorId,
projectId,
actorOrgId
);
if ( if (
membership.role !== ProjectMembershipRole.Admin && !hasRole(ProjectMembershipRole.Admin) &&
secretApprovalRequest.committerId !== membership.id && secretApprovalRequest.committerId !== membership.id &&
!policy.approvers.find((approverId) => approverId === membership.id) !policy.approvers.find((approverId) => approverId === membership.id)
) { ) {
+14 -3
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";
@@ -78,6 +79,7 @@ import { projectKeyDALFactory } from "@app/services/project-key/project-key-dal"
import { projectKeyServiceFactory } from "@app/services/project-key/project-key-service"; import { projectKeyServiceFactory } from "@app/services/project-key/project-key-service";
import { projectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { projectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { projectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service"; import { projectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service";
import { projectUserMembershipRoleDALFactory } from "@app/services/project-membership/project-user-membership-role-dal";
import { projectRoleDALFactory } from "@app/services/project-role/project-role-dal"; import { projectRoleDALFactory } from "@app/services/project-role/project-role-dal";
import { projectRoleServiceFactory } from "@app/services/project-role/project-role-service"; import { projectRoleServiceFactory } from "@app/services/project-role/project-role-service";
import { secretDALFactory } from "@app/services/secret/secret-dal"; import { secretDALFactory } from "@app/services/secret/secret-dal";
@@ -141,6 +143,7 @@ export const registerRoutes = async (
const projectDAL = projectDALFactory(db); const projectDAL = projectDALFactory(db);
const projectMembershipDAL = projectMembershipDALFactory(db); const projectMembershipDAL = projectMembershipDALFactory(db);
const projectUserMembershipRoleDAL = projectUserMembershipRoleDALFactory(db);
const projectRoleDAL = projectRoleDALFactory(db); const projectRoleDAL = projectRoleDALFactory(db);
const projectEnvDAL = projectEnvDALFactory(db); const projectEnvDAL = projectEnvDALFactory(db);
const projectKeyDAL = projectKeyDALFactory(db); const projectKeyDAL = projectKeyDALFactory(db);
@@ -164,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);
@@ -321,6 +325,7 @@ export const registerRoutes = async (
const projectMembershipService = projectMembershipServiceFactory({ const projectMembershipService = projectMembershipServiceFactory({
projectMembershipDAL, projectMembershipDAL,
projectUserMembershipRoleDAL,
projectDAL, projectDAL,
permissionService, permissionService,
projectBotDAL, projectBotDAL,
@@ -352,7 +357,8 @@ export const registerRoutes = async (
projectBotDAL, projectBotDAL,
projectMembershipDAL, projectMembershipDAL,
secretApprovalRequestDAL, secretApprovalRequestDAL,
secretApprovalSecretDAL: sarSecretDAL secretApprovalSecretDAL: sarSecretDAL,
projectUserMembershipRoleDAL
}); });
const projectService = projectServiceFactory({ const projectService = projectServiceFactory({
@@ -369,8 +375,11 @@ export const registerRoutes = async (
orgService, orgService,
projectMembershipDAL, projectMembershipDAL,
folderDAL, folderDAL,
licenseService licenseService,
projectUserMembershipRoleDAL,
identityProjectMembershipRoleDAL
}); });
const projectEnvService = projectEnvServiceFactory({ const projectEnvService = projectEnvServiceFactory({
permissionService, permissionService,
projectEnvDAL, projectEnvDAL,
@@ -521,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,
@@ -1,15 +1,17 @@
import ms from "ms";
import { z } from "zod"; import { z } from "zod";
import { import {
OrgMembershipsSchema, OrgMembershipsSchema,
ProjectMembershipRole,
ProjectMembershipsSchema, ProjectMembershipsSchema,
ProjectUserMembershipRolesSchema,
UserEncryptionKeysSchema, UserEncryptionKeysSchema,
UsersSchema UsersSchema
} from "@app/db/schemas"; } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType } from "@app/ee/services/audit-log/audit-log-types";
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 registerProjectMembershipRouter = async (server: FastifyZodProvider) => { export const registerProjectMembershipRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
@@ -28,16 +30,31 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
}), }),
response: { response: {
200: z.object({ 200: z.object({
memberships: ProjectMembershipsSchema.merge( memberships: ProjectMembershipsSchema.omit({ role: true })
z.object({ .merge(
user: UsersSchema.pick({ z.object({
email: true, user: UsersSchema.pick({
firstName: true, email: true,
lastName: true, firstName: true,
id: true lastName: true,
}).merge(UserEncryptionKeysSchema.pick({ publicKey: true })) id: true
}) }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })),
) roles: z.array(
z.object({
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()
})
)
})
)
.omit({ createdAt: true, updatedAt: true }) .omit({ createdAt: true, updatedAt: true })
.array() .array()
}) })
@@ -86,10 +103,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
actor: req.permission.type, actor: req.permission.type,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
projectId: req.params.workspaceId, projectId: req.params.workspaceId,
members: req.body.members.map((member) => ({ members: req.body.members
...member,
projectRole: ProjectMembershipRole.Member
}))
}); });
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
@@ -124,39 +138,56 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
membershipId: z.string().trim() membershipId: z.string().trim()
}), }),
body: z.object({ body: z.object({
role: z.string().trim() 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().refine((val) => ms(val) > 0, "Temporary range must be a positive number"),
temporaryAccessStartTime: z.string().datetime()
})
])
)
.min(1)
.refine((data) => data.some(({ isTemporary }) => !isTemporary), "At least long lived role is required")
}), }),
response: { response: {
200: z.object({ 200: z.object({
membership: ProjectMembershipsSchema roles: ProjectUserMembershipRolesSchema.array()
}) })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const membership = await server.services.projectMembership.updateProjectMembership({ const roles = await server.services.projectMembership.updateProjectMembership({
actorId: req.permission.id, actorId: req.permission.id,
actor: req.permission.type, actor: req.permission.type,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
projectId: req.params.workspaceId, projectId: req.params.workspaceId,
membershipId: req.params.membershipId, membershipId: req.params.membershipId,
role: req.body.role roles: req.body.roles
}); });
await server.services.auditLog.createAuditLog({ // await server.services.auditLog.createAuditLog({
...req.auditLogInfo, // ...req.auditLogInfo,
projectId: req.params.workspaceId, // projectId: req.params.workspaceId,
event: { // event: {
type: EventType.UPDATE_USER_WORKSPACE_ROLE, // type: EventType.UPDATE_USER_WORKSPACE_ROLE,
metadata: { // metadata: {
userId: membership.userId, // userId: membership.userId,
newRole: req.body.role, // newRole: req.body.role,
oldRole: membership.role, // oldRole: membership.role,
email: "" // email: ""
} // }
} // }
}); // });
return { membership }; return { roles };
} }
}); });
+26 -11
View File
@@ -60,17 +60,32 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
}), }),
response: { response: {
200: z.object({ 200: z.object({
users: ProjectMembershipsSchema.merge( users: ProjectMembershipsSchema.omit({ role: true })
z.object({ .merge(
user: UsersSchema.pick({ z.object({
username: true, user: UsersSchema.pick({
email: true, username: true,
firstName: true, email: true,
lastName: true, firstName: true,
id: true lastName: true,
}).merge(UserEncryptionKeysSchema.pick({ publicKey: true })) id: true
}) }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })),
) roles: z.array(
z.object({
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()
})
)
})
)
.omit({ createdAt: true, updatedAt: true }) .omit({ createdAt: true, updatedAt: true })
.array() .array()
}) })
@@ -1,13 +1,15 @@
import ms from "ms";
import { z } from "zod"; import { z } from "zod";
import { 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 +59,40 @@ 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().refine((val) => ms(val) > 0, "Temporary range must be a positive number"),
temporaryAccessStartTime: z.string().datetime()
})
])
)
.min(1)
}), }),
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,26 @@ 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,
role: isCustomRole ? ProjectMembershipRole.Custom : role,
roleId: customRole?.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 +109,7 @@ export const identityProjectServiceFactory = ({
const updateProjectIdentity = async ({ const updateProjectIdentity = async ({
projectId, projectId,
identityId, identityId,
role, roles,
actor, actor,
actorId, actorId,
actorOrgId actorOrgId
@@ -106,28 +133,51 @@ 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);
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;
+2 -2
View File
@@ -58,7 +58,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol
{ id: roleId, orgId }, { id: roleId, orgId },
{ ...data, permissions: data.permissions ? JSON.stringify(data.permissions) : undefined } { ...data, permissions: data.permissions ? JSON.stringify(data.permissions) : undefined }
); );
if (!updateRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); if (!updatedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" });
return updatedRole; return updatedRole;
}; };
@@ -66,7 +66,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgId); const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Role); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Role);
const [deletedRole] = await orgRoleDAL.delete({ id: roleId, orgId }); const [deletedRole] = await orgRoleDAL.delete({ id: roleId, orgId });
if (!deleteRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); if (!deletedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" });
return deletedRole; return deletedRole;
}; };
@@ -1,7 +1,7 @@
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; import { TableName, TUserEncryptionKeys } 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, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
export type TProjectMembershipDALFactory = ReturnType<typeof projectMembershipDALFactory>; export type TProjectMembershipDALFactory = ReturnType<typeof projectMembershipDALFactory>;
@@ -11,32 +11,86 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
// special query // special query
const findAllProjectMembers = async (projectId: string) => { const findAllProjectMembers = async (projectId: string) => {
try { try {
const members = await db(TableName.ProjectMembership) const docs = await db(TableName.ProjectMembership)
.where({ projectId }) .where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId })
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
.join<TUserEncryptionKeys>( .join<TUserEncryptionKeys>(
TableName.UserEncryptionKey, TableName.UserEncryptionKey,
`${TableName.UserEncryptionKey}.userId`, `${TableName.UserEncryptionKey}.userId`,
`${TableName.Users}.id` `${TableName.Users}.id`
) )
.join(
TableName.ProjectUserMembershipRole,
`${TableName.ProjectUserMembershipRole}.projectMembershipId`,
`${TableName.ProjectMembership}.id`
)
.leftJoin(
TableName.ProjectRoles,
`${TableName.ProjectUserMembershipRole}.customRoleId`,
`${TableName.ProjectRoles}.id`
)
.select( .select(
db.ref("id").withSchema(TableName.ProjectMembership), db.ref("id").withSchema(TableName.ProjectMembership),
db.ref("projectId").withSchema(TableName.ProjectMembership),
db.ref("role").withSchema(TableName.ProjectMembership),
db.ref("roleId").withSchema(TableName.ProjectMembership),
db.ref("isGhost").withSchema(TableName.Users), db.ref("isGhost").withSchema(TableName.Users),
db.ref("username").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users),
db.ref("email").withSchema(TableName.Users), db.ref("email").withSchema(TableName.Users),
db.ref("publicKey").withSchema(TableName.UserEncryptionKey), db.ref("publicKey").withSchema(TableName.UserEncryptionKey),
db.ref("firstName").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users),
db.ref("lastName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users),
db.ref("id").withSchema(TableName.Users).as("userId") db.ref("id").withSchema(TableName.Users).as("userId"),
db.ref("role").withSchema(TableName.ProjectUserMembershipRole),
db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("membershipRoleId"),
db.ref("customRoleId").withSchema(TableName.ProjectUserMembershipRole),
db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"),
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"),
db.ref("temporaryMode").withSchema(TableName.ProjectUserMembershipRole),
db.ref("isTemporary").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole)
) )
.where({ isGhost: false }); .where({ isGhost: false });
return members.map(({ username, email, firstName, lastName, publicKey, isGhost, ...data }) => ({
...data, const members = sqlNestRelationships({
user: { username, email, firstName, lastName, id: data.userId, publicKey, isGhost } data: docs,
})); parentMapper: ({ email, firstName, username, lastName, publicKey, isGhost, id, userId }) => ({
id,
userId,
projectId,
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }
}),
key: "id",
childrenMapper: [
{
label: "roles" as const,
key: "membershipRoleId",
mapper: ({
role,
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: "Find all project members" }); throw new DatabaseError({ error, name: "Find all project members" });
} }
@@ -1,14 +1,13 @@
/* eslint-disable no-await-in-loop */ /* eslint-disable no-await-in-loop */
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import ms from "ms";
import { import {
OrgMembershipStatus,
ProjectMembershipRole, ProjectMembershipRole,
ProjectVersion, ProjectVersion,
SecretKeyEncoding, SecretKeyEncoding,
TableName, TableName,
TProjectMemberships, TProjectMemberships
TUsers
} from "@app/db/schemas"; } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
@@ -29,22 +28,24 @@ import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TUserDALFactory } from "../user/user-dal"; import { TUserDALFactory } from "../user/user-dal";
import { TProjectMembershipDALFactory } from "./project-membership-dal"; import { TProjectMembershipDALFactory } from "./project-membership-dal";
import { import {
ProjectUserMembershipTemporaryMode,
TAddUsersToWorkspaceDTO, TAddUsersToWorkspaceDTO,
TAddUsersToWorkspaceNonE2EEDTO, TAddUsersToWorkspaceNonE2EEDTO,
TDeleteProjectMembershipOldDTO, TDeleteProjectMembershipOldDTO,
TDeleteProjectMembershipsDTO, TDeleteProjectMembershipsDTO,
TGetProjectMembershipDTO, TGetProjectMembershipDTO,
TInviteUserToProjectDTO,
TUpdateProjectMembershipDTO TUpdateProjectMembershipDTO
} from "./project-membership-types"; } from "./project-membership-types";
import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal";
type TProjectMembershipServiceFactoryDep = { type TProjectMembershipServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
smtpService: TSmtpService; smtpService: TSmtpService;
projectBotDAL: TProjectBotDALFactory; projectBotDAL: TProjectBotDALFactory;
projectMembershipDAL: TProjectMembershipDALFactory; projectMembershipDAL: TProjectMembershipDALFactory;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "insertMany" | "find" | "delete">;
userDAL: Pick<TUserDALFactory, "findById" | "findOne" | "findUserByProjectMembershipId" | "find">; userDAL: Pick<TUserDALFactory, "findById" | "findOne" | "findUserByProjectMembershipId" | "find">;
projectRoleDAL: Pick<TProjectRoleDALFactory, "findOne">; projectRoleDAL: Pick<TProjectRoleDALFactory, "find">;
orgDAL: Pick<TOrgDALFactory, "findMembership" | "findOrgMembersByUsername">; orgDAL: Pick<TOrgDALFactory, "findMembership" | "findOrgMembersByUsername">;
projectDAL: Pick<TProjectDALFactory, "findById" | "findProjectGhostUser" | "transaction">; projectDAL: Pick<TProjectDALFactory, "findById" | "findProjectGhostUser" | "transaction">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany">; projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany">;
@@ -56,6 +57,7 @@ export type TProjectMembershipServiceFactory = ReturnType<typeof projectMembersh
export const projectMembershipServiceFactory = ({ export const projectMembershipServiceFactory = ({
permissionService, permissionService,
projectMembershipDAL, projectMembershipDAL,
projectUserMembershipRoleDAL,
smtpService, smtpService,
projectRoleDAL, projectRoleDAL,
projectBotDAL, projectBotDAL,
@@ -72,82 +74,6 @@ export const projectMembershipServiceFactory = ({
return projectMembershipDAL.findAllProjectMembers(projectId); return projectMembershipDAL.findAllProjectMembers(projectId);
}; };
const inviteUserToProject = async ({ actorId, actor, actorOrgId, projectId, emails }: TInviteUserToProjectDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member);
const invitees: TUsers[] = [];
const project = await projectDAL.findById(projectId);
const users = await userDAL.find({
$in: { email: emails }
});
await projectDAL.transaction(async (tx) => {
for (const invitee of users) {
if (!invitee.isAccepted)
throw new BadRequestError({
message: "Failed to validate invitee",
name: "Invite user to project"
});
const inviteeMembership = await projectMembershipDAL.findOne(
{
userId: invitee.id,
projectId
},
tx
);
if (inviteeMembership) {
throw new BadRequestError({
message: "Existing member of project",
name: "Invite user to project"
});
}
const inviteeMembershipOrg = await orgDAL.findMembership({
userId: invitee.id,
orgId: project.orgId,
status: OrgMembershipStatus.Accepted
});
if (!inviteeMembershipOrg) {
throw new BadRequestError({
message: "Failed to validate invitee org membership",
name: "Invite user to project"
});
}
await projectMembershipDAL.create(
{
userId: invitee.id,
projectId,
role: ProjectMembershipRole.Member
},
tx
);
invitees.push(invitee);
}
const appCfg = getConfig();
await smtpService.sendMail({
template: SmtpTemplates.WorkspaceInvite,
subjectLine: "Infisical project invitation",
recipients: invitees.filter((i) => i.email).map((i) => i.email as string),
substitutions: {
workspaceName: project.name,
callback_url: `${appCfg.SITE_URL}/login`
}
});
});
const latestKey = await projectKeyDAL.findLatestProjectKey(actorId, projectId);
return { invitees, latestKey };
};
const addUsersToProject = async ({ const addUsersToProject = async ({
projectId, projectId,
actorId, actorId,
@@ -176,17 +102,16 @@ export const projectMembershipServiceFactory = ({
if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" }); if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" });
await projectMembershipDAL.transaction(async (tx) => { await projectMembershipDAL.transaction(async (tx) => {
await projectMembershipDAL.insertMany( const projectMemberships = await projectMembershipDAL.insertMany(
orgMembers.map(({ userId, id: membershipId }) => { orgMembers.map(({ userId }) => ({
const role = projectId,
members.find((i) => i.orgMembershipId === membershipId)?.projectRole || ProjectMembershipRole.Member; userId: userId as string,
role: ProjectMembershipRole.Member
return { })),
projectId, tx
userId: userId as string, );
role await projectUserMembershipRoleDAL.insertMany(
}; projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: ProjectMembershipRole.Member })),
}),
tx tx
); );
const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId); const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId);
@@ -296,7 +221,7 @@ export const projectMembershipServiceFactory = ({
const members: TProjectMemberships[] = []; const members: TProjectMemberships[] = [];
await projectMembershipDAL.transaction(async (tx) => { await projectMembershipDAL.transaction(async (tx) => {
const result = await projectMembershipDAL.insertMany( const projectMemberships = await projectMembershipDAL.insertMany(
orgMembers.map(({ user }) => ({ orgMembers.map(({ user }) => ({
projectId, projectId,
userId: user.id, userId: user.id,
@@ -304,8 +229,12 @@ export const projectMembershipServiceFactory = ({
})), })),
tx tx
); );
await projectUserMembershipRoleDAL.insertMany(
projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: ProjectMembershipRole.Member })),
tx
);
members.push(...result); members.push(...projectMemberships);
const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId); const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId);
await projectKeyDAL.insertMany( await projectKeyDAL.insertMany(
@@ -346,43 +275,71 @@ export const projectMembershipServiceFactory = ({
actorOrgId, actorOrgId,
projectId, projectId,
membershipId, membershipId,
role roles
}: TUpdateProjectMembershipDTO) => { }: TUpdateProjectMembershipDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member);
const membershipUser = await userDAL.findUserByProjectMembershipId(membershipId); const membershipUser = await userDAL.findUserByProjectMembershipId(membershipId);
if (membershipUser?.isGhost || membershipUser?.projectId !== projectId) {
if (membershipUser?.isGhost) {
throw new BadRequestError({ throw new BadRequestError({
message: "Unauthorized member update", message: "Unauthorized member update",
name: "Update project membership" name: "Update project membership"
}); });
} }
const isCustomRole = !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole); // validate custom roles input
if (isCustomRole) { const customInputRoles = roles.filter(
const customRole = await projectRoleDAL.findOne({ slug: role, projectId }); ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole)
if (!customRole) throw new BadRequestError({ name: "Update project membership", message: "Role not found" }); );
const project = await projectDAL.findById(customRole.projectId); const hasCustomRole = Boolean(customInputRoles.length);
const plan = await licenseService.getPlan(project.orgId); if (hasCustomRole) {
const plan = await licenseService.getPlan(actorOrgId as string);
if (!plan?.rbac) if (!plan?.rbac)
throw new BadRequestError({ throw new BadRequestError({
message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member."
}); });
const [membership] = await projectMembershipDAL.update(
{ id: membershipId, projectId },
{
role: ProjectMembershipRole.Custom,
roleId: customRole.id
}
);
return membership;
} }
const [membership] = await projectMembershipDAL.update({ id: membershipId, projectId }, { role, roleId: null }); const customRoles = hasCustomRole
return membership; ? 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: membershipId,
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);
return {
projectMembershipId: membershipId,
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 projectMembershipDAL.transaction(async (tx) => {
await projectUserMembershipRoleDAL.delete({ projectMembershipId: membershipId }, tx);
return projectUserMembershipRoleDAL.insertMany(santiziedProjectMembershipRoles, tx);
});
return updatedRoles;
}; };
// This is old and should be removed later. Its not used anywhere, but it is exposed in our API. So to avoid breaking changes, we are keeping it for now. // This is old and should be removed later. Its not used anywhere, but it is exposed in our API. So to avoid breaking changes, we are keeping it for now.
@@ -481,7 +438,6 @@ export const projectMembershipServiceFactory = ({
return { return {
getProjectMemberships, getProjectMemberships,
inviteUserToProject,
updateProjectMembership, updateProjectMembership,
addUsersToProjectNonE2EE, addUsersToProjectNonE2EE,
deleteProjectMemberships, deleteProjectMemberships,
@@ -1,7 +1,9 @@
import { ProjectMembershipRole } from "@app/db/schemas";
import { TProjectPermission } from "@app/lib/types"; import { TProjectPermission } from "@app/lib/types";
export type TGetProjectMembershipDTO = TProjectPermission; export type TGetProjectMembershipDTO = TProjectPermission;
export enum ProjectUserMembershipTemporaryMode {
Relative = "relative"
}
export type TInviteUserToProjectDTO = { export type TInviteUserToProjectDTO = {
emails: string[]; emails: string[];
@@ -9,7 +11,19 @@ export type TInviteUserToProjectDTO = {
export type TUpdateProjectMembershipDTO = { export type TUpdateProjectMembershipDTO = {
membershipId: string; membershipId: string;
role: string; roles: (
| {
role: string;
isTemporary?: false;
}
| {
role: string;
isTemporary: true;
temporaryMode: ProjectUserMembershipTemporaryMode.Relative;
temporaryRange: string;
temporaryAccessStartTime: string;
}
)[];
} & TProjectPermission; } & TProjectPermission;
export type TDeleteProjectMembershipOldDTO = { export type TDeleteProjectMembershipOldDTO = {
@@ -27,7 +41,6 @@ export type TAddUsersToWorkspaceDTO = {
orgMembershipId: string; orgMembershipId: string;
workspaceEncryptedKey: string; workspaceEncryptedKey: string;
workspaceEncryptedNonce: string; workspaceEncryptedNonce: string;
projectRole: ProjectMembershipRole;
}[]; }[];
} & TProjectPermission; } & TProjectPermission;
@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TProjectUserMembershipRoleDALFactory = ReturnType<typeof projectUserMembershipRoleDALFactory>;
export const projectUserMembershipRoleDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.ProjectUserMembershipRole);
return orm;
};
@@ -76,7 +76,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }:
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Role); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Role);
const [deletedRole] = await projectRoleDAL.delete({ id: roleId, projectId }); const [deletedRole] = await projectRoleDAL.delete({ id: roleId, projectId });
if (!deleteRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); if (!deletedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" });
return deletedRole; return deletedRole;
}; };
@@ -92,7 +92,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }:
name: "Admin", name: "Admin",
slug: ProjectMembershipRole.Admin, slug: ProjectMembershipRole.Admin,
description: "Complete administration access over the project", description: "Complete administration access over the project",
permissions: packRules(projectAdminPermissions.rules), permissions: packRules(projectAdminPermissions),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date() updatedAt: new Date()
}, },
@@ -102,7 +102,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }:
name: "Developer", name: "Developer",
slug: ProjectMembershipRole.Member, slug: ProjectMembershipRole.Member,
description: "Non-administrative role in an project", description: "Non-administrative role in an project",
permissions: packRules(projectMemberPermissions.rules), permissions: packRules(projectMemberPermissions),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date() updatedAt: new Date()
}, },
@@ -112,7 +112,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }:
name: "Viewer", name: "Viewer",
slug: ProjectMembershipRole.Viewer, slug: ProjectMembershipRole.Viewer,
description: "Non-administrative role in an project", description: "Non-administrative role in an project",
permissions: packRules(projectViewerPermission.rules), permissions: packRules(projectViewerPermission),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date() updatedAt: new Date()
}, },
@@ -122,7 +122,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }:
name: "No Access", name: "No Access",
slug: "no-access", slug: "no-access",
description: "No access to any resources in the project", description: "No access to any resources in the project",
permissions: packRules(projectNoAccessPermissions.rules), permissions: packRules(projectNoAccessPermissions),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date() updatedAt: new Date()
}, },
@@ -38,6 +38,7 @@ import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
import { TSecretDALFactory } from "../secret/secret-dal"; import { TSecretDALFactory } from "../secret/secret-dal";
import { TSecretVersionDALFactory } from "../secret/secret-version-dal"; import { TSecretVersionDALFactory } from "../secret/secret-version-dal";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
@@ -58,9 +59,9 @@ type TProjectQueueFactoryDep = {
projectBotDAL: Pick<TProjectBotDALFactory, "findOne" | "delete" | "create">; projectBotDAL: Pick<TProjectBotDALFactory, "findOne" | "delete" | "create">;
orgService: Pick<TOrgServiceFactory, "addGhostUser">; orgService: Pick<TOrgServiceFactory, "addGhostUser">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create">; projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create">;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">;
integrationAuthDAL: TIntegrationAuthDALFactory; integrationAuthDAL: TIntegrationAuthDALFactory;
userDAL: Pick<TUserDALFactory, "findUserEncKeyByUserId">; userDAL: Pick<TUserDALFactory, "findUserEncKeyByUserId">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "find">; projectEnvDAL: Pick<TProjectEnvDALFactory, "find">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "transaction" | "updateById" | "setProjectUpgradeStatus" | "find">; projectDAL: Pick<TProjectDALFactory, "findOne" | "transaction" | "updateById" | "setProjectUpgradeStatus" | "find">;
orgDAL: Pick<TOrgDALFactory, "findMembership">; orgDAL: Pick<TOrgDALFactory, "findMembership">;
@@ -81,7 +82,8 @@ export const projectQueueFactory = ({
orgDAL, orgDAL,
projectDAL, projectDAL,
orgService, orgService,
projectMembershipDAL projectMembershipDAL,
projectUserMembershipRoleDAL
}: TProjectQueueFactoryDep) => { }: TProjectQueueFactoryDep) => {
const upgradeProject = async (dto: TQueueJobTypes["upgrade-project-to-ghost"]["payload"]) => { const upgradeProject = async (dto: TQueueJobTypes["upgrade-project-to-ghost"]["payload"]) => {
await queueService.queue(QueueName.UpgradeProjectToGhost, QueueJobs.UpgradeProjectToGhost, dto, { await queueService.queue(QueueName.UpgradeProjectToGhost, QueueJobs.UpgradeProjectToGhost, dto, {
@@ -227,7 +229,7 @@ export const projectQueueFactory = ({
); );
// Create a membership for the ghost user // Create a membership for the ghost user
await projectMembershipDAL.create( const projectMembership = await projectMembershipDAL.create(
{ {
projectId: project.id, projectId: project.id,
userId: ghostUser.user.id, userId: ghostUser.user.id,
@@ -235,6 +237,10 @@ export const projectQueueFactory = ({
}, },
tx tx
); );
await projectUserMembershipRoleDAL.create(
{ projectMembershipId: projectMembership.id, role: ProjectMembershipRole.Admin },
tx
);
// If a bot already exists, delete it // If a bot already exists, delete it
if (existingBot) { if (existingBot) {
@@ -17,11 +17,13 @@ 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";
import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal"; import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal";
import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TUserDALFactory } from "../user/user-dal"; import { TUserDALFactory } from "../user/user-dal";
@@ -50,9 +52,11 @@ 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">;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">;
secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "create">; secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "create">;
permissionService: TPermissionServiceFactory; permissionService: TPermissionServiceFactory;
orgService: Pick<TOrgServiceFactory, "addGhostUser">; orgService: Pick<TOrgServiceFactory, "addGhostUser">;
@@ -75,7 +79,9 @@ export const projectServiceFactory = ({
secretBlindIndexDAL, secretBlindIndexDAL,
projectMembershipDAL, projectMembershipDAL,
projectEnvDAL, projectEnvDAL,
licenseService licenseService,
projectUserMembershipRoleDAL,
identityProjectMembershipRoleDAL
}: TProjectServiceFactoryDep) => { }: TProjectServiceFactoryDep) => {
/* /*
* Create workspace. Make user the admin * Create workspace. Make user the admin
@@ -114,14 +120,18 @@ export const projectServiceFactory = ({
tx tx
); );
// set ghost user as admin of project // set ghost user as admin of project
await projectMembershipDAL.create( const projectMembership = await projectMembershipDAL.create(
{ {
userId: ghostUser.user.id, userId: ghostUser.user.id,
role: ProjectMembershipRole.Admin, projectId: project.id,
projectId: project.id role: ProjectMembershipRole.Admin
}, },
tx tx
); );
await projectUserMembershipRoleDAL.create(
{ projectMembershipId: projectMembership.id, role: ProjectMembershipRole.Admin },
tx
);
// generate the blind index for project // generate the blind index for project
await secretBlindIndexDAL.create( await secretBlindIndexDAL.create(
@@ -213,7 +223,7 @@ export const projectServiceFactory = ({
}); });
// Create a membership for the user // Create a membership for the user
await projectMembershipDAL.create( const userProjectMembership = await projectMembershipDAL.create(
{ {
projectId: project.id, projectId: project.id,
userId: user.id, userId: user.id,
@@ -221,6 +231,10 @@ export const projectServiceFactory = ({
}, },
tx tx
); );
await projectUserMembershipRoleDAL.create(
{ projectMembershipId: userProjectMembership.id, role: projectAdmin.projectRole },
tx
);
// Create a project key for the user // Create a project key for the user
await projectKeyDAL.create( await projectKeyDAL.create(
@@ -266,7 +280,7 @@ 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,
@@ -275,6 +289,15 @@ export const projectServiceFactory = ({
}, },
tx tx
); );
await identityProjectMembershipRoleDAL.create(
{
projectMembershipId: identityProjectMembership.id,
role: isCustomRole ? ProjectMembershipRole.Custom : ProjectMembershipRole.Admin,
customRoleId: customRole?.id
},
tx
);
} }
return { return {
@@ -350,11 +373,11 @@ export const projectServiceFactory = ({
}; };
const upgradeProject = async ({ projectId, actor, actorId, userPrivateKey }: TUpgradeProjectDTO) => { const upgradeProject = async ({ projectId, actor, actorId, userPrivateKey }: TUpgradeProjectDTO) => {
const { permission, membership } = await permissionService.getProjectPermission(actor, actorId, projectId); const { permission, hasRole } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project);
if (membership?.role !== ProjectMembershipRole.Admin) { if (!hasRole(ProjectMembershipRole.Admin)) {
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "User must be admin" message: "User must be admin"
}); });
@@ -37,8 +37,8 @@ export const secretBlindIndexServiceFactory = ({
}; };
const getProjectSecrets = async ({ projectId, actorId, actor }: TGetProjectSecretsDTO) => { const getProjectSecrets = async ({ projectId, actorId, actor }: TGetProjectSecretsDTO) => {
const { membership } = await permissionService.getProjectPermission(actor, actorId, projectId); const { hasRole } = await permissionService.getProjectPermission(actor, actorId, projectId);
if (membership?.role !== ProjectMembershipRole.Admin) { if (!hasRole(ProjectMembershipRole.Admin)) {
throw new UnauthorizedError({ message: "User must be admin" }); throw new UnauthorizedError({ message: "User must be admin" });
} }
@@ -53,8 +53,8 @@ export const secretBlindIndexServiceFactory = ({
actorOrgId, actorOrgId,
secretsToUpdate secretsToUpdate
}: TUpdateProjectSecretNameDTO) => { }: TUpdateProjectSecretNameDTO) => {
const { membership } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); const { hasRole } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
if (membership?.role !== ProjectMembershipRole.Admin) { if (!hasRole(ProjectMembershipRole.Admin)) {
throw new UnauthorizedError({ message: "User must be admin" }); throw new UnauthorizedError({ message: "User must be admin" });
} }
@@ -12,6 +12,7 @@ export const Popover = PopoverPrimitive.Root;
export type PopoverContentProps = { export type PopoverContentProps = {
children?: ReactNode; children?: ReactNode;
arrowClassName?: string;
hideCloseBtn?: boolean; hideCloseBtn?: boolean;
} & PopoverPrimitive.PopoverContentProps; } & PopoverPrimitive.PopoverContentProps;
@@ -19,6 +20,7 @@ export const PopoverContent = ({
children, children,
className, className,
hideCloseBtn, hideCloseBtn,
arrowClassName,
...props ...props
}: PopoverContentProps) => ( }: PopoverContentProps) => (
<PopoverPrimitive.Portal> <PopoverPrimitive.Portal>
@@ -48,7 +50,7 @@ export const PopoverContent = ({
</IconButton> </IconButton>
</PopoverPrimitive.Close> </PopoverPrimitive.Close>
)} )}
<PopoverPrimitive.Arrow className="fill-inherit" /> <PopoverPrimitive.Arrow className={twMerge("fill-inherit", arrowClassName)} />
</PopoverPrimitive.Content> </PopoverPrimitive.Content>
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
); );
+13 -4
View File
@@ -1,4 +1,4 @@
import { TOrgRole, TProjectRole } from "../roles/types"; import { TOrgRole } from "../roles/types";
import { IdentityAuthMethod } from "./enums"; import { IdentityAuthMethod } from "./enums";
export type IdentityTrustedIp = { export type IdentityTrustedIp = {
@@ -29,9 +29,18 @@ export type IdentityMembershipOrg = {
export type IdentityMembership = { export type IdentityMembership = {
id: string; id: string;
identity: Identity; identity: Identity;
organization: string; roles: {
role: "admin" | "member" | "viewer" | "no-access" | "custom"; id: string;
customRole?: TProjectRole; role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
isTemporary: boolean;
temporaryMode: string | null;
temporaryRange: string | null;
temporaryAccessStartTime: string | null;
temporaryAccessEndTime: string | null;
}[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
+27 -1
View File
@@ -64,7 +64,33 @@ export type TProjectMembership = {
roleId: string; roleId: string;
}; };
export type TWorkspaceUser = OrgUser; export type TWorkspaceUser = {
id: string;
user: {
email: string;
username: string;
firstName: string;
lastName: string;
id: string;
publicKey: string;
};
inviteEmail: string;
organization: string;
roles: {
id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
isTemporary: boolean;
temporaryMode: string | null;
temporaryRange: string | null;
temporaryAccessStartTime: string | null;
temporaryAccessEndTime: string | null;
}[];
status: "invited" | "accepted" | "verified" | "completed";
deniedPermissions: any[];
};
export type AddUserToWsDTOE2EE = { export type AddUserToWsDTOE2EE = {
workspaceId: string; workspaceId: string;
+9 -19
View File
@@ -16,6 +16,8 @@ import {
RenameWorkspaceDTO, RenameWorkspaceDTO,
TGetUpgradeProjectStatusDTO, TGetUpgradeProjectStatusDTO,
ToggleAutoCapitalizationDTO, ToggleAutoCapitalizationDTO,
TUpdateWorkspaceIdentityRoleDTO,
TUpdateWorkspaceUserRoleDTO,
UpdateEnvironmentDTO, UpdateEnvironmentDTO,
Workspace Workspace
} from "./types"; } from "./types";
@@ -340,27 +342,19 @@ export const useDeleteUserFromWorkspace = () => {
export const useUpdateUserWorkspaceRole = () => { export const useUpdateUserWorkspaceRole = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ mutationFn: async ({ membershipId, roles, workspaceId }: TUpdateWorkspaceUserRoleDTO) => {
membershipId,
role,
workspaceId
}: {
membershipId: string;
role: string;
workspaceId: string;
}) => {
const { const {
data: { membership } data: { membership }
} = await apiRequest.patch<{ membership: { projectId: string } }>( } = await apiRequest.patch<{ membership: { projectId: string } }>(
`/api/v1/workspace/${workspaceId}/memberships/${membershipId}`, `/api/v1/workspace/${workspaceId}/memberships/${membershipId}`,
{ {
role roles
} }
); );
return membership; return membership;
}, },
onSuccess: (res) => { onSuccess: (_, { workspaceId }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(res.projectId)); queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId));
} }
}); });
}; };
@@ -400,18 +394,14 @@ export const useUpdateIdentityWorkspaceRole = () => {
mutationFn: async ({ mutationFn: async ({
identityId, identityId,
workspaceId, workspaceId,
role roles
}: { }:TUpdateWorkspaceIdentityRoleDTO)=> {
identityId: string;
workspaceId: string;
role?: string;
}) => {
const { const {
data: { identityMembership } data: { identityMembership }
} = await apiRequest.patch( } = await apiRequest.patch(
`/api/v2/workspace/${workspaceId}/identity-memberships/${identityId}`, `/api/v2/workspace/${workspaceId}/identity-memberships/${identityId}`,
{ {
role roles
} }
); );
+40
View File
@@ -3,6 +3,10 @@ export enum ProjectVersion {
V2 = 2 V2 = 2
} }
export enum ProjectUserMembershipTemporaryMode {
Relative = "relative"
}
export type Workspace = { export type Workspace = {
__v: number; __v: number;
id: string; id: string;
@@ -72,3 +76,39 @@ export type UpdateEnvironmentDTO = {
}; };
export type DeleteEnvironmentDTO = { workspaceId: string; id: string }; export type DeleteEnvironmentDTO = { workspaceId: string; id: string };
export type TUpdateWorkspaceUserRoleDTO = {
membershipId: string;
workspaceId: string;
roles: (
| {
role: string;
isTemporary?: false;
}
| {
role: string;
isTemporary: true;
temporaryMode: ProjectUserMembershipTemporaryMode;
temporaryRange: string;
temporaryAccessStartTime: string;
}
)[];
};
export type TUpdateWorkspaceIdentityRoleDTO = {
identityId: string;
workspaceId: string;
roles: (
| {
role: string;
isTemporary?: false;
}
| {
role: string;
isTemporary: true;
temporaryMode: ProjectUserMembershipTemporaryMode;
temporaryRange: string;
temporaryAccessStartTime: string;
}
)[];
};
+15
View File
@@ -0,0 +1,15 @@
/**
* Sorts an array of items into groups. The return value is a map where the keys are
* the group ids the given getGroupId function produced and the value is an array of
* each item in that group.
*/
export const groupBy = <T, Key extends string | number | symbol>(
array: readonly T[],
getGroupId: (item: T) => Key
): Record<Key, T[]> =>
array.reduce((acc, item) => {
const groupId = getGroupId(item);
if (!acc[groupId]) acc[groupId] = [];
acc[groupId].push(item);
return acc;
}, {} as Record<Key, T[]>);
@@ -5,12 +5,7 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc"; import { withProjectPermission } from "@app/hoc";
import { import { IdentityTab, MemberListTab, ProjectRoleListTab, ServiceTokenTab } from "./components";
IdentityTab,
MemberListTab,
ProjectRoleListTab,
ServiceTokenTab
} from "./components";
enum TabSections { enum TabSections {
Member = "members", Member = "members",
@@ -23,19 +18,14 @@ export const MembersPage = withProjectPermission(
() => { () => {
return ( return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white"> <div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mb-6 w-full py-6 px-6 max-w-7xl mx-auto"> <div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
<p className="mr-4 mb-4 text-3xl font-semibold text-white"> <p className="mr-4 mb-4 text-3xl font-semibold text-white">Project Access Control</p>
Project Access Control
</p>
<Tabs defaultValue={TabSections.Member}> <Tabs defaultValue={TabSections.Member}>
<TabList> <TabList>
<Tab value={TabSections.Member}>People</Tab> <Tab value={TabSections.Member}>People</Tab>
<Tab value={TabSections.Identities}> <Tab value={TabSections.Identities}>
<div className="flex items-center"> <div className="flex items-center">
<p>Machine Identities</p> <p>Machine Identities</p>
<div className="ml-2 rounded-md text-yellow text-sm inline-block bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] opacity-80 hover:opacity-100 cursor-default">
New
</div>
</div> </div>
</Tab> </Tab>
<Tab value={TabSections.ServiceTokens}>Service Tokens</Tab> <Tab value={TabSections.ServiceTokens}>Service Tokens</Tab>
@@ -0,0 +1,459 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
Checkbox,
FormControl,
HoverCard,
HoverCardContent,
HoverCardTrigger,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Spinner,
Tag,
Tooltip
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { TWorkspaceUser } from "@app/hooks/api/types";
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
import { groupBy } from "@app/lib/fn/array";
const temporaryRoleFormSchema = z.object({
temporaryRange: z.string().min(1, "Required")
});
type TTemporaryRoleFormSchema = z.infer<typeof temporaryRoleFormSchema>;
type TTemporaryRoleFormProps = {
temporaryConfig?: {
isTemporary?: boolean;
temporaryAccessEndTime?: string | null;
temporaryAccessStartTime?: string | null;
temporaryRange?: string | null;
};
onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void;
onRemoveTemporary: () => void;
};
const IdentityTemporaryRoleForm = ({
temporaryConfig: defaultValues = {},
onSetTemporary,
onRemoveTemporary
}: TTemporaryRoleFormProps) => {
const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const);
const { control, handleSubmit } = useForm<TTemporaryRoleFormSchema>({
resolver: zodResolver(temporaryRoleFormSchema),
values: {
temporaryRange: defaultValues.temporaryRange || "1h"
}
});
const isTemporaryFieldValue = defaultValues.isTemporary;
const isExpired =
isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || "");
return (
<Popover
open={popUp.setTempRole.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("setTempRole", isOpen);
}}
>
<PopoverTrigger>
<IconButton ariaLabel="role-temp" variant="plain" size="md">
<Tooltip content={isExpired ? "Access Expired" : "Grant Temporary Access"}>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
isTemporaryFieldValue && "text-primary",
isExpired && "text-red-600"
)}
/>
</Tooltip>
</IconButton>
</PopoverTrigger>
<PopoverContent
arrowClassName="fill-gray-600"
side="right"
sideOffset={12}
hideCloseBtn
className="border border-gray-600 pt-4"
>
<div className="flex flex-col space-y-4">
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
Set Role Temporarily
</div>
{isExpired && <Tag colorSchema="red">Expired</Tag>}
<Controller
control={control}
name="temporaryRange"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Validity"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText={
<span>
1m, 2h, 3d.{" "}
<a
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
target="_blank"
rel="noopener noreferrer"
className="text-primary-700"
>
More
</a>
</span>
}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
{isTemporaryFieldValue && (
<Button
size="xs"
type="submit"
onClick={() => {
handleSubmit(({ temporaryRange }) => {
onSetTemporary({
temporaryRange,
temporaryAccessStartTime: new Date().toISOString()
});
handlePopUpToggle("setTempRole");
})();
}}
>
Restart
</Button>
)}
{!isTemporaryFieldValue ? (
<Button
size="xs"
type="submit"
onClick={() =>
handleSubmit(({ temporaryRange }) => {
onSetTemporary({
temporaryRange,
temporaryAccessStartTime:
defaultValues.temporaryAccessStartTime || new Date().toISOString()
});
handlePopUpToggle("setTempRole");
})()
}
>
Grant access
</Button>
) : (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
onRemoveTemporary();
handlePopUpToggle("setTempRole");
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
};
const formSchema = z.record(
z.object({
isChecked: z.boolean().optional(),
temporaryAccess: z.union([
z.object({
isTemporary: z.literal(true),
temporaryRange: z.string().min(1),
temporaryAccessStartTime: z.string().datetime(),
temporaryAccessEndTime: z.string().datetime().nullable().optional()
}),
z.boolean()
])
})
);
type TForm = z.infer<typeof formSchema>;
export type TMemberRolesProp = {
disableEdit?: boolean;
identityId: string;
roles: TWorkspaceUser["roles"];
};
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
export const IdentityRoles = ({
roles = [],
disableEdit = false,
identityId
}: TMemberRolesProp) => {
const { currentWorkspace } = useWorkspace();
const { createNotification } = useNotificationContext();
const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const);
const [searchRoles, setSearchRoles] = useState("");
const {
handleSubmit,
control,
reset,
setValue,
formState: { isSubmitting, isDirty }
} = useForm<TForm>({
resolver: zodResolver(formSchema)
});
const workspaceId = currentWorkspace?.id || "";
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role);
const updateIdentityWorkspaceRole = useUpdateIdentityWorkspaceRole();
const handleRoleUpdate = async (data: TForm) => {
const selectedRoles = Object.keys(data)
.filter((el) => Boolean(data[el].isChecked))
.map((el) => {
const isTemporary = Boolean(data[el].temporaryAccess);
if (!isTemporary) {
return { role: el, isTemporary: false as const };
}
const tempCfg = data[el].temporaryAccess as {
temporaryRange: string;
temporaryAccessStartTime: string;
};
return {
role: el,
isTemporary: true as const,
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
temporaryRange: tempCfg.temporaryRange,
temporaryAccessStartTime: tempCfg.temporaryAccessStartTime
};
});
try {
await updateIdentityWorkspaceRole.mutateAsync({
workspaceId,
identityId,
roles: selectedRoles
});
createNotification({ text: "Successfully updated identity role", type: "success" });
handlePopUpToggle("editRole");
setSearchRoles("");
} catch (err) {
createNotification({ text: "Failed to update identity role", type: "error" });
}
};
const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Member) return "Developer";
return role;
};
return (
<div className="flex items-center space-x-2">
{roles
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={id} className="capitalize">
<div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && (
<div>
<Tooltip content={isExpired ? "Expired Temporary Access" : "Temporary Access"}>
<FontAwesomeIcon
icon={faClock}
className={twMerge(isExpired && "text-red-600")}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
})}
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
<HoverCard>
<HoverCardTrigger>
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
</HoverCardTrigger>
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
{roles
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={id} className="capitalize">
<div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && (
<div>
<Tooltip
content={isExpired ? "Expired Temporary Access" : "Temporary Access"}
>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
new Date() > new Date(temporaryAccessEndTime as string) &&
"text-red-600"
)}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
})}{" "}
</HoverCardContent>
</HoverCard>
)}
<div>
<Popover
open={popUp.editRole.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("editRole", isOpen);
reset();
}}
>
{!disableEdit && (
<PopoverTrigger>
<IconButton size="sm" variant="plain" ariaLabel="update">
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</PopoverTrigger>
)}
<PopoverContent hideCloseBtn className="pt-4">
{isRolesLoading ? (
<div className="flex h-8 w-full items-center justify-center">
<Spinner />
</div>
) : (
<form onSubmit={handleSubmit(handleRoleUpdate)} id="role-update-form">
<div className="thin-scrollbar max-h-80 space-y-4 overflow-y-auto">
{projectRoles
?.filter(
({ name, slug }) =>
name.toLowerCase().includes(searchRoles.toLowerCase()) ||
slug.toLowerCase().includes(searchRoles.toLowerCase())
)
?.map(({ id, name, slug }) => {
const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0];
return (
<div key={id} className="flex items-center space-x-4">
<div className="flex-grow">
<Controller
control={control}
defaultValue={Boolean(userProjectRoleDetails?.id)}
name={`${slug}.isChecked`}
render={({ field }) => (
<Checkbox
id={slug}
isChecked={field.value}
onCheckedChange={(isChecked) => {
field.onChange(isChecked);
setValue(`${slug}.temporaryAccess`, false);
}}
>
{name}
</Checkbox>
)}
/>
</div>
<div>
<Controller
control={control}
name={`${slug}.temporaryAccess`}
defaultValue={
userProjectRoleDetails?.isTemporary
? {
isTemporary: true,
temporaryAccessStartTime:
userProjectRoleDetails.temporaryAccessStartTime as string,
temporaryRange:
userProjectRoleDetails.temporaryRange as string,
temporaryAccessEndTime:
userProjectRoleDetails.temporaryAccessEndTime
}
: false
}
render={({ field }) => (
<IdentityTemporaryRoleForm
temporaryConfig={
typeof field.value === "boolean"
? { isTemporary: field.value }
: field.value
}
onSetTemporary={(data) => {
setValue(`${slug}.isChecked`, true, { shouldDirty: true });
console.log(data);
field.onChange({ isTemporary: true, ...data });
}}
onRemoveTemporary={() => {
setValue(`${slug}.isChecked`, false, { shouldDirty: true });
field.onChange(false);
}}
/>
)}
/>
</div>
</div>
);
})}
</div>
<div className="mt-3 flex items-center space-x-2 border-t border-t-gray-700 pt-3">
<div>
<Input
className="w-full p-1.5 pl-8"
size="xs"
value={searchRoles}
onChange={(el) => setSearchRoles(el.target.value)}
leftIcon={<FontAwesomeIcon icon={faSearch} />}
placeholder="Search roles.."
/>
</div>
<div>
<Button
size="xs"
type="submit"
form="role-update-form"
leftIcon={<FontAwesomeIcon icon={faCheck} />}
isDisabled={!isDirty || isSubmitting}
isLoading={isSubmitting}
>
Save
</Button>
</div>
</div>
</form>
)}
</PopoverContent>
</Popover>
</div>
</div>
);
};
@@ -2,13 +2,10 @@ import { faServer, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns"; import { format } from "date-fns";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
EmptyState, EmptyState,
IconButton, IconButton,
Select,
SelectItem,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -19,13 +16,11 @@ import {
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { import { useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
useGetProjectRoles,
useGetWorkspaceIdentityMemberships,
useUpdateIdentityWorkspaceRole
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import { IdentityRoles } from "./IdentityRoles";
type Props = { type Props = {
handlePopUpOpen: ( handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteIdentity", "identity"]>, popUpName: keyof UsePopUpState<["deleteIdentity", "identity"]>,
@@ -37,39 +32,9 @@ type Props = {
}; };
export const IdentityTable = ({ handlePopUpOpen }: Props) => { export const IdentityTable = ({ handlePopUpOpen }: Props) => {
const { createNotification } = useNotificationContext();
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || ""); const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || "");
const { data: roles } = useGetProjectRoles(workspaceId);
const { mutateAsync: updateMutateAsync } = useUpdateIdentityWorkspaceRole();
const handleChangeRole = async ({ identityId, role }: { identityId: string; role: string }) => {
try {
await updateMutateAsync({
identityId,
workspaceId,
role
});
createNotification({
text: "Successfully updated identity role",
type: "success"
});
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to update identity role";
createNotification({
text,
type: "error"
});
}
};
return ( return (
<TableContainer> <TableContainer>
<Table> <Table>
@@ -86,7 +51,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
{!isLoading && {!isLoading &&
data && data &&
data.length > 0 && data.length > 0 &&
data.map(({ identity: { id, name }, role, customRole, createdAt }) => { data.map(({ identity: { id, name }, roles, createdAt }) => {
return ( return (
<Tr className="h-10" key={`st-v3-${id}`}> <Tr className="h-10" key={`st-v3-${id}`}>
<Td>{name}</Td> <Td>{name}</Td>
@@ -95,28 +60,9 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Identity} a={ProjectPermissionSub.Identity}
> >
{(isAllowed) => { {(isAllowed) => (
return ( <IdentityRoles roles={roles} disableEdit={!isAllowed} identityId={id} />
<Select )}
value={role === "custom" ? (customRole?.slug as string) : role}
isDisabled={!isAllowed}
className="w-40 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
handleChangeRole({
identityId: id,
role: selectedRole
})
}
>
{(roles || []).map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
);
}}
</ProjectPermissionCan> </ProjectPermissionCan>
</Td> </Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td> <Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Link from "next/link"; import Link from "next/link";
@@ -34,7 +34,6 @@ import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionSub, ProjectPermissionSub,
useOrganization, useOrganization,
useSubscription,
useUser, useUser,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
@@ -44,14 +43,13 @@ import {
useAddUserToWsNonE2EE, useAddUserToWsNonE2EE,
useDeleteUserFromWorkspace, useDeleteUserFromWorkspace,
useGetOrgUsers, useGetOrgUsers,
useGetProjectRoles,
useGetUserWsKey, useGetUserWsKey,
useGetWorkspaceUsers, useGetWorkspaceUsers
useUpdateUserWorkspaceRole
} from "@app/hooks/api"; } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { MemberRoles } from "./MemberRoles";
const addMemberFormSchema = z.object({ const addMemberFormSchema = z.object({
orgMembershipId: z.string().trim() orgMembershipId: z.string().trim()
}); });
@@ -60,7 +58,6 @@ type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
export const MemberListTab = () => { export const MemberListTab = () => {
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const { subscription } = useSubscription();
const { t } = useTranslation(); const { t } = useTranslation();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
@@ -71,8 +68,6 @@ export const MemberListTab = () => {
const orgId = currentOrg?.id || ""; const orgId = currentOrg?.id || "";
const workspaceId = currentWorkspace?.id || ""; const workspaceId = currentWorkspace?.id || "";
const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
const { data: wsKey } = useGetUserWsKey(workspaceId); const { data: wsKey } = useGetUserWsKey(workspaceId);
const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId); const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
const { data: orgUsers } = useGetOrgUsers(orgId); const { data: orgUsers } = useGetOrgUsers(orgId);
@@ -95,7 +90,6 @@ export const MemberListTab = () => {
const { mutateAsync: addUserToWorkspace } = useAddUserToWsE2EE(); const { mutateAsync: addUserToWorkspace } = useAddUserToWsE2EE();
const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE(); const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE();
const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace(); const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace();
const { mutateAsync: updateUserWorkspaceRole } = useUpdateUserWorkspaceRole();
const onAddMember = async ({ orgMembershipId }: TAddMemberForm) => { const onAddMember = async ({ orgMembershipId }: TAddMemberForm) => {
if (!currentWorkspace) return; if (!currentWorkspace) return;
@@ -167,47 +161,6 @@ export const MemberListTab = () => {
handlePopUpClose("removeMember"); handlePopUpClose("removeMember");
}; };
const isIamOwner = useMemo(
() => members?.find(({ user: u }) => userId === u?.id)?.role === "owner",
[userId, members]
);
const findRoleFromId = useCallback(
(roleId: string) => {
return (roles || []).find(({ id }) => id === roleId);
},
[roles]
);
const onRoleChange = async (membershipId: string, role: string) => {
if (!currentOrg?.id) return;
try {
const isCustomRole = !Object.values(ProjectMembershipRole).includes(
role as ProjectMembershipRole
);
if (isCustomRole && subscription && !subscription?.rbac) {
handlePopUpOpen("upgradePlan", {
description: "You can assign custom roles to members if you upgrade your Infisical plan."
});
return;
}
await updateUserWorkspaceRole({ membershipId, role, workspaceId });
createNotification({
text: "Successfully updated user role",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to update user role",
type: "error"
});
}
};
const filterdUsers = useMemo( const filterdUsers = useMemo(
() => () =>
members?.filter( members?.filter(
@@ -231,8 +184,6 @@ export const MemberListTab = () => {
); );
}, [orgUsers, members]); }, [orgUsers, members]);
const isLoading = isMembersLoading || isRolesLoading;
return ( return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
@@ -269,75 +220,61 @@ export const MemberListTab = () => {
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isLoading && <TableSkeleton columns={4} innerKey="project-members" />} {isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />}
{!isLoading && {!isMembersLoading &&
filterdUsers?.map( filterdUsers?.map(({ user: u, inviteEmail, id: membershipId, roles }) => {
({ user: u, id: membershipId, roleId, role }) => { const name = u ? `${u.firstName} ${u.lastName}` : "-";
const name = u ? `${u.firstName} ${u.lastName}` : "-"; const email = u?.email || inviteEmail;
const username = u?.username ?? "-";
return ( return (
<Tr key={`membership-${membershipId}`} className="w-full"> <Tr key={`membership-${membershipId}`} className="w-full">
<Td>{name}</Td> <Td>{name}</Td>
<Td>{username}</Td> <Td>{email}</Td>
<Td> <Td>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<MemberRoles
roles={roles}
disableEdit={u.id === user?.id || !isAllowed}
onOpenUpgradeModal={(description) =>
handlePopUpOpen("upgradePlan", { description })
}
membershipId={membershipId}
/>
)}
</ProjectPermissionCan>
</Td>
<Td>
{userId !== u?.id && (
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Member} a={ProjectPermissionSub.Member}
> >
{(isAllowed) => ( {(isAllowed) => (
<Select <IconButton
value={role === "custom" ? findRoleFromId(roleId)?.slug : role} size="lg"
isDisabled={userId === u?.id || !isAllowed} colorSchema="danger"
className="w-40 bg-mineshaft-600" variant="plain"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800" ariaLabel="update"
onValueChange={(selectedRole) => className="ml-4"
onRoleChange(membershipId, selectedRole) isDisabled={userId === u?.id || !isAllowed}
} onClick={() => handlePopUpOpen("removeMember", { email: u.email })}
> >
{(roles || []) <FontAwesomeIcon icon={faXmark} />
.filter(({ slug }) => </IconButton>
slug === "owner" ? isIamOwner || role === "owner" : true
)
.map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</Td> )}
<Td> </Td>
{userId !== u?.id && ( </Tr>
<ProjectPermissionCan );
I={ProjectPermissionActions.Delete} })}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<IconButton
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={userId === u?.id || !isAllowed}
onClick={() =>
handlePopUpOpen("removeMember", { username: u.username })
}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
)}
</Td>
</Tr>
);
}
)}
</TBody> </TBody>
</Table> </Table>
{!isLoading && filterdUsers?.length === 0 && ( {!isMembersLoading && filterdUsers?.length === 0 && (
<EmptyState title="No project members found" icon={faUsers} /> <EmptyState title="No project members found" icon={faUsers} />
)} )}
</TableContainer> </TableContainer>
@@ -0,0 +1,473 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
Checkbox,
FormControl,
HoverCard,
HoverCardContent,
HoverCardTrigger,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Spinner,
Tag,
Tooltip
} from "@app/components/v2";
import { useSubscription, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { TWorkspaceUser } from "@app/hooks/api/types";
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
import { groupBy } from "@app/lib/fn/array";
const temporaryRoleFormSchema = z.object({
temporaryRange: z.string().min(1, "Required")
});
type TTemporaryRoleFormSchema = z.infer<typeof temporaryRoleFormSchema>;
type TTemporaryRoleFormProps = {
temporaryConfig?: {
isTemporary?: boolean;
temporaryAccessEndTime?: string | null;
temporaryAccessStartTime?: string | null;
temporaryRange?: string | null;
};
onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void;
onRemoveTemporary: () => void;
};
const TemporaryRoleForm = ({
temporaryConfig: defaultValues = {},
onSetTemporary,
onRemoveTemporary
}: TTemporaryRoleFormProps) => {
const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const);
const { control, handleSubmit } = useForm<TTemporaryRoleFormSchema>({
resolver: zodResolver(temporaryRoleFormSchema),
values: {
temporaryRange: defaultValues.temporaryRange || "1h"
}
});
const isTemporaryFieldValue = defaultValues.isTemporary;
const isExpired =
isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || "");
return (
<Popover
open={popUp.setTempRole.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("setTempRole", isOpen);
}}
>
<PopoverTrigger>
<IconButton ariaLabel="role-temp" variant="plain" size="md">
<Tooltip content={isExpired ? "Timed access expired" : "Grant timed access"}>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
isTemporaryFieldValue && "text-primary",
isExpired && "text-red-600"
)}
/>
</Tooltip>
</IconButton>
</PopoverTrigger>
<PopoverContent
arrowClassName="fill-gray-600"
side="right"
sideOffset={12}
hideCloseBtn
className="border border-gray-600 pt-4"
>
<div className="flex flex-col space-y-4">
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
Configure timed access
</div>
{isExpired && <Tag colorSchema="red">Expired</Tag>}
<Controller
control={control}
name="temporaryRange"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Validity"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText={
<span>
1m, 2h, 3d.{" "}
<a
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
target="_blank"
rel="noopener noreferrer"
className="text-primary-700"
>
More
</a>
</span>
}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
{isTemporaryFieldValue && (
<Button
size="xs"
type="submit"
onClick={() => {
handleSubmit(({ temporaryRange }) => {
onSetTemporary({
temporaryRange,
temporaryAccessStartTime: new Date().toISOString()
});
handlePopUpToggle("setTempRole");
})();
}}
>
Restart
</Button>
)}
{!isTemporaryFieldValue ? (
<Button
size="xs"
type="submit"
onClick={() =>
handleSubmit(({ temporaryRange }) => {
onSetTemporary({
temporaryRange,
temporaryAccessStartTime:
defaultValues.temporaryAccessStartTime || new Date().toISOString()
});
handlePopUpToggle("setTempRole");
})()
}
>
Grant access
</Button>
) : (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
onRemoveTemporary();
handlePopUpToggle("setTempRole");
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
};
const formSchema = z.record(
z.object({
isChecked: z.boolean().optional(),
temporaryAccess: z.union([
z.object({
isTemporary: z.literal(true),
temporaryRange: z.string().min(1),
temporaryAccessStartTime: z.string().datetime(),
temporaryAccessEndTime: z.string().datetime().nullable().optional()
}),
z.boolean()
])
})
);
type TForm = z.infer<typeof formSchema>;
export type TMemberRolesProp = {
disableEdit?: boolean;
membershipId: string;
onOpenUpgradeModal: (description: string) => void;
roles: TWorkspaceUser["roles"];
};
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
export const MemberRoles = ({
roles = [],
disableEdit = false,
membershipId,
onOpenUpgradeModal
}: TMemberRolesProp) => {
const { currentWorkspace } = useWorkspace();
const { createNotification } = useNotificationContext();
const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const);
const [searchRoles, setSearchRoles] = useState("");
const { subscription } = useSubscription();
const {
handleSubmit,
control,
reset,
setValue,
formState: { isSubmitting, isDirty }
} = useForm<TForm>({
resolver: zodResolver(formSchema)
});
const workspaceId = currentWorkspace?.id || "";
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role);
const updateMembershipRole = useUpdateUserWorkspaceRole();
const handleRoleUpdate = async (data: TForm) => {
const selectedRoles = Object.keys(data)
.filter((el) => Boolean(data[el].isChecked))
.map((el) => {
const isTemporary = Boolean(data[el].temporaryAccess);
if (!isTemporary) {
return { role: el, isTemporary: false as const };
}
const tempCfg = data[el].temporaryAccess as {
temporaryRange: string;
temporaryAccessStartTime: string;
};
return {
role: el,
isTemporary: true as const,
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
temporaryRange: tempCfg.temporaryRange,
temporaryAccessStartTime: tempCfg.temporaryAccessStartTime
};
});
const hasCustomRoleSelected = selectedRoles.some(
(el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole)
);
if (hasCustomRoleSelected && subscription && !subscription?.rbac) {
onOpenUpgradeModal(
"You can assign custom roles to members if you upgrade your Infisical plan."
);
return;
}
try {
await updateMembershipRole.mutateAsync({
workspaceId,
membershipId,
roles: selectedRoles
});
createNotification({ text: "Successfully updated role", type: "success" });
handlePopUpToggle("editRole");
setSearchRoles("");
} catch (err) {
createNotification({ text: "Failed to update role", type: "error" });
}
};
const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Member) return "Developer";
return role;
};
return (
<div className="flex items-center space-x-2">
{roles
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={id}>
<div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && (
<div>
<Tooltip content={isExpired ? "Timed role expired" : "Timed role access"}>
<FontAwesomeIcon
icon={faClock}
className={twMerge(isExpired && "text-red-600")}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
})}
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
<HoverCard>
<HoverCardTrigger>
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
</HoverCardTrigger>
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
{roles
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={id} className="capitalize">
<div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && (
<div>
<Tooltip
content={isExpired ? "Access expired" : "Temporary access"}
>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
new Date() > new Date(temporaryAccessEndTime as string) &&
"text-red-600"
)}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
})}{" "}
</HoverCardContent>
</HoverCard>
)}
<div>
<Popover
open={popUp.editRole.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("editRole", isOpen);
reset();
}}
>
{!disableEdit && (
<PopoverTrigger>
<IconButton size="sm" variant="plain" ariaLabel="update">
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</PopoverTrigger>
)}
<PopoverContent hideCloseBtn className="pt-4">
{isRolesLoading ? (
<div className="flex h-8 w-full items-center justify-center">
<Spinner />
</div>
) : (
<form onSubmit={handleSubmit(handleRoleUpdate)} id="role-update-form">
<div className="thin-scrollbar max-h-80 space-y-4 overflow-y-auto">
{projectRoles
?.filter(
({ name, slug }) =>
name.toLowerCase().includes(searchRoles.toLowerCase()) ||
slug.toLowerCase().includes(searchRoles.toLowerCase())
)
?.map(({ id, name, slug }) => {
const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0];
return (
<div key={id} className="flex items-center space-x-4">
<div className="flex-grow">
<Controller
control={control}
defaultValue={Boolean(userProjectRoleDetails?.id)}
name={`${slug}.isChecked`}
render={({ field }) => (
<Checkbox
id={slug}
isChecked={field.value}
onCheckedChange={(isChecked) => {
field.onChange(isChecked);
setValue(`${slug}.temporaryAccess`, false);
}}
>
{name}
</Checkbox>
)}
/>
</div>
<div>
<Controller
control={control}
name={`${slug}.temporaryAccess`}
defaultValue={
userProjectRoleDetails?.isTemporary
? {
isTemporary: true,
temporaryAccessStartTime:
userProjectRoleDetails.temporaryAccessStartTime as string,
temporaryRange:
userProjectRoleDetails.temporaryRange as string,
temporaryAccessEndTime:
userProjectRoleDetails.temporaryAccessEndTime
}
: false
}
render={({ field }) => (
<TemporaryRoleForm
temporaryConfig={
typeof field.value === "boolean"
? { isTemporary: field.value }
: field.value
}
onSetTemporary={(data) => {
setValue(`${slug}.isChecked`, true, { shouldDirty: true });
console.log(data);
field.onChange({ isTemporary: true, ...data });
}}
onRemoveTemporary={() => {
setValue(`${slug}.isChecked`, false, { shouldDirty: true });
field.onChange(false);
}}
/>
)}
/>
</div>
</div>
);
})}
</div>
<div className="mt-3 flex items-center space-x-2 border-t border-t-gray-700 pt-3">
<div>
<Input
className="w-full p-1.5 pl-8"
size="xs"
value={searchRoles}
onChange={(el) => setSearchRoles(el.target.value)}
leftIcon={<FontAwesomeIcon icon={faSearch} />}
placeholder="Search roles.."
/>
</div>
<div>
<Button
size="xs"
type="submit"
form="role-update-form"
leftIcon={<FontAwesomeIcon icon={faCheck} />}
isDisabled={!isDirty || isSubmitting}
isLoading={isSubmitting}
>
Save
</Button>
</div>
</div>
</form>
)}
</PopoverContent>
</Popover>
</div>
</div>
);
};
@@ -78,18 +78,26 @@ export const ProjectNameChangeSection = () => {
</CopyButton> </CopyButton>
</div> </div>
</div> </div>
<div className="max-w-md"> <div className="max-w-md">
<Controller <ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Workspace}>
defaultValue="" {(isAllowed) => (
render={({ field, fieldState: { error } }) => ( <Controller
<FormControl isError={Boolean(error)} errorText={error?.message}> defaultValue=""
<Input placeholder="Project name" {...field} className="bg-mineshaft-800" /> render={({ field, fieldState: { error } }) => (
</FormControl> <FormControl isError={Boolean(error)} errorText={error?.message}>
<Input
placeholder="Project name"
{...field}
className="bg-mineshaft-800"
isDisabled={!isAllowed}
/>
</FormControl>
)}
control={control}
name="name"
/>
)} )}
control={control} </ProjectPermissionCan>
name="name"
/>
</div> </div>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Workspace}> <ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Workspace}>
{(isAllowed) => ( {(isAllowed) => (