Merge pull request #1574 from akhilmhdh/feat/additional-privilege

feat: additional privilege for users and identity
This commit is contained in:
Maidul Islam
2024-04-01 11:09:05 -07:00
committed by GitHub
59 changed files with 4332 additions and 1266 deletions

View File

@ -5,9 +5,11 @@ import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-se
import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types";
import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service";
import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service";
import { TIdentityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service";
import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { TProjectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service";
import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service";
import { TScimServiceFactory } from "@app/ee/services/scim/scim-service";
import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service";
@ -121,6 +123,8 @@ declare module "fastify" {
telemetry: TTelemetryServiceFactory;
dynamicSecret: TDynamicSecretServiceFactory;
dynamicSecretLease: TDynamicSecretLeaseServiceFactory;
projectUserAdditionalPrivilege: TProjectUserAdditionalPrivilegeServiceFactory;
identityProjectAdditionalPrivilege: TIdentityProjectAdditionalPrivilegeServiceFactory;
};
// this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer

View File

@ -38,6 +38,9 @@ import {
TIdentityOrgMemberships,
TIdentityOrgMembershipsInsert,
TIdentityOrgMembershipsUpdate,
TIdentityProjectAdditionalPrivilege,
TIdentityProjectAdditionalPrivilegeInsert,
TIdentityProjectAdditionalPrivilegeUpdate,
TIdentityProjectMembershipRole,
TIdentityProjectMembershipRoleInsert,
TIdentityProjectMembershipRoleUpdate,
@ -92,6 +95,9 @@ import {
TProjects,
TProjectsInsert,
TProjectsUpdate,
TProjectUserAdditionalPrivilege,
TProjectUserAdditionalPrivilegeInsert,
TProjectUserAdditionalPrivilegeUpdate,
TProjectUserMembershipRoles,
TProjectUserMembershipRolesInsert,
TProjectUserMembershipRolesUpdate,
@ -239,6 +245,11 @@ declare module "knex/types/tables" {
TProjectUserMembershipRolesUpdate
>;
[TableName.ProjectRoles]: Knex.CompositeTableType<TProjectRoles, TProjectRolesInsert, TProjectRolesUpdate>;
[TableName.ProjectUserAdditionalPrivilege]: Knex.CompositeTableType<
TProjectUserAdditionalPrivilege,
TProjectUserAdditionalPrivilegeInsert,
TProjectUserAdditionalPrivilegeUpdate
>;
[TableName.ProjectKeys]: Knex.CompositeTableType<TProjectKeys, TProjectKeysInsert, TProjectKeysUpdate>;
[TableName.Secret]: Knex.CompositeTableType<TSecrets, TSecretsInsert, TSecretsUpdate>;
[TableName.SecretBlindIndex]: Knex.CompositeTableType<
@ -294,6 +305,11 @@ declare module "knex/types/tables" {
TIdentityProjectMembershipRoleInsert,
TIdentityProjectMembershipRoleUpdate
>;
[TableName.IdentityProjectAdditionalPrivilege]: Knex.CompositeTableType<
TIdentityProjectAdditionalPrivilege,
TIdentityProjectAdditionalPrivilegeInsert,
TIdentityProjectAdditionalPrivilegeUpdate
>;
[TableName.ScimToken]: Knex.CompositeTableType<TScimTokens, TScimTokensInsert, TScimTokensUpdate>;
[TableName.SecretApprovalPolicy]: Knex.CompositeTableType<
TSecretApprovalPolicies,

View File

@ -0,0 +1,29 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.ProjectUserAdditionalPrivilege))) {
await knex.schema.createTable(TableName.ProjectUserAdditionalPrivilege, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("slug", 60).notNullable();
t.uuid("projectMembershipId").notNullable();
t.foreign("projectMembershipId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE");
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.jsonb("permissions").notNullable();
t.timestamps(true, true, true);
});
}
await createOnUpdateTrigger(knex, TableName.ProjectUserAdditionalPrivilege);
}
export async function down(knex: Knex): Promise<void> {
await dropOnUpdateTrigger(knex, TableName.ProjectUserAdditionalPrivilege);
await knex.schema.dropTableIfExists(TableName.ProjectUserAdditionalPrivilege);
}

View File

@ -0,0 +1,32 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.IdentityProjectAdditionalPrivilege))) {
await knex.schema.createTable(TableName.IdentityProjectAdditionalPrivilege, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("slug", 60).notNullable();
t.uuid("projectMembershipId").notNullable();
t.foreign("projectMembershipId")
.references("id")
.inTable(TableName.IdentityProjectMembership)
.onDelete("CASCADE");
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.jsonb("permissions").notNullable();
t.timestamps(true, true, true);
});
}
await createOnUpdateTrigger(knex, TableName.IdentityProjectAdditionalPrivilege);
}
export async function down(knex: Knex): Promise<void> {
await dropOnUpdateTrigger(knex, TableName.IdentityProjectAdditionalPrivilege);
await knex.schema.dropTableIfExists(TableName.IdentityProjectAdditionalPrivilege);
}

View File

@ -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 IdentityProjectAdditionalPrivilegeSchema = z.object({
id: z.string().uuid(),
slug: z.string(),
projectMembershipId: z.string().uuid(),
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(),
permissions: z.unknown(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TIdentityProjectAdditionalPrivilege = z.infer<typeof IdentityProjectAdditionalPrivilegeSchema>;
export type TIdentityProjectAdditionalPrivilegeInsert = Omit<
z.input<typeof IdentityProjectAdditionalPrivilegeSchema>,
TImmutableDBKeys
>;
export type TIdentityProjectAdditionalPrivilegeUpdate = Partial<
Omit<z.input<typeof IdentityProjectAdditionalPrivilegeSchema>, TImmutableDBKeys>
>;

View File

@ -10,6 +10,7 @@ export * from "./git-app-org";
export * from "./identities";
export * from "./identity-access-tokens";
export * from "./identity-org-memberships";
export * from "./identity-project-additional-privilege";
export * from "./identity-project-membership-role";
export * from "./identity-project-memberships";
export * from "./identity-ua-client-secrets";
@ -28,6 +29,7 @@ export * from "./project-environments";
export * from "./project-keys";
export * from "./project-memberships";
export * from "./project-roles";
export * from "./project-user-additional-privilege";
export * from "./project-user-membership-roles";
export * from "./projects";
export * from "./saml-configs";

View File

@ -20,6 +20,7 @@ export enum TableName {
Environment = "project_environments",
ProjectMembership = "project_memberships",
ProjectRoles = "project_roles",
ProjectUserAdditionalPrivilege = "project_user_additional_privilege",
ProjectUserMembershipRole = "project_user_membership_roles",
ProjectKeys = "project_keys",
Secret = "secrets",
@ -43,6 +44,7 @@ export enum TableName {
IdentityOrgMembership = "identity_org_memberships",
IdentityProjectMembership = "identity_project_memberships",
IdentityProjectMembershipRole = "identity_project_membership_role",
IdentityProjectAdditionalPrivilege = "identity_project_additional_privilege",
ScimToken = "scim_tokens",
SecretApprovalPolicy = "secret_approval_policies",
SecretApprovalPolicyApprover = "secret_approval_policies_approvers",

View File

@ -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 ProjectUserAdditionalPrivilegeSchema = z.object({
id: z.string().uuid(),
slug: z.string(),
projectMembershipId: z.string().uuid(),
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(),
permissions: z.unknown(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TProjectUserAdditionalPrivilege = z.infer<typeof ProjectUserAdditionalPrivilegeSchema>;
export type TProjectUserAdditionalPrivilegeInsert = Omit<
z.input<typeof ProjectUserAdditionalPrivilegeSchema>,
TImmutableDBKeys
>;
export type TProjectUserAdditionalPrivilegeUpdate = Partial<
Omit<z.input<typeof ProjectUserAdditionalPrivilegeSchema>, TImmutableDBKeys>
>;

View File

@ -0,0 +1,272 @@
import { MongoAbility, RawRuleOf } from "@casl/ability";
import { PackRule, packRules, unpackRules } from "@casl/ability/extra";
import slugify from "@sindresorhus/slugify";
import ms from "ms";
import { z } from "zod";
import { IdentityProjectAdditionalPrivilegeSchema } from "@app/db/schemas";
import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types";
import { ProjectPermissionSet } from "@app/ee/services/permission/project-permission";
import { IDENTITY_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => {
server.route({
url: "/permanent",
method: "POST",
schema: {
body: z.object({
identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId),
projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug),
slug: z
.string()
.min(1)
.max(60)
.trim()
.default(slugify(alphaNumericNanoId(12)))
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
.refine((v) => slugify(v) === v, {
message: "Slug must be a valid slug"
})
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug),
permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions)
}),
response: {
200: z.object({
privilege: IdentityProjectAdditionalPrivilegeSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const privilege = await server.services.identityProjectAdditionalPrivilege.create({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
...req.body,
isTemporary: false,
permissions: JSON.stringify(packRules(req.body.permissions))
});
return { privilege };
}
});
server.route({
url: "/temporary",
method: "POST",
schema: {
body: z.object({
identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.identityId),
projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.projectSlug),
slug: z
.string()
.min(1)
.max(60)
.trim()
.default(slugify(alphaNumericNanoId(12)))
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
.refine((v) => slugify(v) === v, {
message: "Slug must be a valid slug"
})
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug),
permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions),
temporaryMode: z
.nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode)
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode),
temporaryRange: z
.string()
.refine((val) => ms(val) > 0, "Temporary range must be a positive number")
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryRange),
temporaryAccessStartTime: z
.string()
.datetime()
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryAccessStartTime)
}),
response: {
200: z.object({
privilege: IdentityProjectAdditionalPrivilegeSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const privilege = await server.services.identityProjectAdditionalPrivilege.create({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
...req.body,
isTemporary: true,
permissions: JSON.stringify(packRules(req.body.permissions))
});
return { privilege };
}
});
server.route({
url: "/",
method: "PATCH",
schema: {
body: z.object({
// disallow empty string
privilegeSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.slug),
identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.identityId),
projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.projectSlug),
privilegeDetails: z
.object({
slug: z
.string()
.min(1)
.max(60)
.trim()
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
.refine((v) => slugify(v) === v, {
message: "Slug must be a valid slug"
})
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug),
permissions: z.any().array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions),
isTemporary: z.boolean().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary),
temporaryMode: z
.nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode)
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryMode),
temporaryRange: z
.string()
.refine((val) => ms(val) > 0, "Temporary range must be a positive number")
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryRange),
temporaryAccessStartTime: z
.string()
.datetime()
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryAccessStartTime)
})
.partial()
}),
response: {
200: z.object({
privilege: IdentityProjectAdditionalPrivilegeSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const updatedInfo = req.body.privilegeDetails;
const privilege = await server.services.identityProjectAdditionalPrivilege.updateBySlug({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
slug: req.body.privilegeSlug,
identityId: req.body.identityId,
projectSlug: req.body.projectSlug,
data: {
...updatedInfo,
permissions: updatedInfo?.permissions ? JSON.stringify(packRules(updatedInfo.permissions)) : undefined
}
});
return { privilege };
}
});
server.route({
url: "/",
method: "DELETE",
schema: {
body: z.object({
privilegeSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.slug),
identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.identityId),
projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.DELETE.projectSlug)
}),
response: {
200: z.object({
privilege: IdentityProjectAdditionalPrivilegeSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const privilege = await server.services.identityProjectAdditionalPrivilege.deleteBySlug({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
slug: req.body.privilegeSlug,
identityId: req.body.identityId,
projectSlug: req.body.projectSlug
});
return { privilege };
}
});
server.route({
url: "/:privilegeSlug",
method: "GET",
schema: {
params: z.object({
privilegeSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.GET_BY_SLUG.slug)
}),
querystring: z.object({
identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.GET_BY_SLUG.identityId),
projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.GET_BY_SLUG.projectSlug)
}),
response: {
200: z.object({
privilege: IdentityProjectAdditionalPrivilegeSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const privilege = await server.services.identityProjectAdditionalPrivilege.getPrivilegeDetailsBySlug({
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
slug: req.params.privilegeSlug,
...req.query
});
return { privilege };
}
});
server.route({
url: "/",
method: "GET",
schema: {
querystring: z.object({
identityId: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.identityId),
projectSlug: z.string().min(1).describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.projectSlug),
unpacked: z
.enum(["false", "true"])
.transform((el) => el === "true")
.default("true")
.describe(IDENTITY_ADDITIONAL_PRIVILEGE.LIST.unpacked)
}),
response: {
200: z.object({
privileges: IdentityProjectAdditionalPrivilegeSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const privileges = await server.services.identityProjectAdditionalPrivilege.listIdentityProjectPrivileges({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.query
});
if (req.query.unpacked) {
return {
privileges: privileges.map(({ permissions, ...el }) => ({
...el,
permissions: unpackRules(permissions as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[])
}))
};
}
return { privileges };
}
});
};

View File

@ -1,5 +1,6 @@
import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router";
import { registerDynamicSecretRouter } from "./dynamic-secret-router";
import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router";
import { registerLdapRouter } from "./ldap-router";
import { registerLicenseRouter } from "./license-router";
import { registerOrgRoleRouter } from "./org-role-router";
@ -15,6 +16,7 @@ import { registerSecretScanningRouter } from "./secret-scanning-router";
import { registerSecretVersionRouter } from "./secret-version-router";
import { registerSnapshotRouter } from "./snapshot-router";
import { registerTrustedIpRouter } from "./trusted-ip-router";
import { registerUserAdditionalPrivilegeRouter } from "./user-additional-privilege-router";
export const registerV1EERoutes = async (server: FastifyZodProvider) => {
// org role starts with organization
@ -51,4 +53,11 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
await server.register(registerSecretScanningRouter, { prefix: "/secret-scanning" });
await server.register(registerSecretRotationRouter, { prefix: "/secret-rotations" });
await server.register(registerSecretVersionRouter, { prefix: "/secret" });
await server.register(
async (privilegeRouter) => {
await privilegeRouter.register(registerUserAdditionalPrivilegeRouter, { prefix: "/users" });
await privilegeRouter.register(registerIdentityProjectAdditionalPrivilegeRouter, { prefix: "/identity" });
},
{ prefix: "/additional-privilege" }
);
};

View File

@ -0,0 +1,235 @@
import slugify from "@sindresorhus/slugify";
import ms from "ms";
import { z } from "zod";
import { ProjectUserAdditionalPrivilegeSchema } from "@app/db/schemas";
import { ProjectUserAdditionalPrivilegeTemporaryMode } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-types";
import { PROJECT_USER_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => {
server.route({
url: "/permanent",
method: "POST",
schema: {
body: z.object({
projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId),
slug: z
.string()
.min(1)
.max(60)
.trim()
.default(slugify(alphaNumericNanoId(12)))
.refine((v) => v.toLowerCase() === v, "Slug must be lowercase")
.refine((v) => slugify(v) === v, {
message: "Slug must be a valid slug"
})
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug),
permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions)
}),
response: {
200: z.object({
privilege: ProjectUserAdditionalPrivilegeSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const privilege = await server.services.projectUserAdditionalPrivilege.create({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
...req.body,
isTemporary: false,
permissions: JSON.stringify(req.body.permissions)
});
return { privilege };
}
});
server.route({
url: "/temporary",
method: "POST",
schema: {
body: z.object({
projectMembershipId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.projectMembershipId),
slug: z
.string()
.min(1)
.max(60)
.trim()
.default(`privilege-${slugify(alphaNumericNanoId(12))}`)
.refine((v) => v.toLowerCase() === v, "Slug must be lowercase")
.refine((v) => slugify(v) === v, {
message: "Slug must be a valid slug"
})
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.slug),
permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.permissions),
temporaryMode: z
.nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode)
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode),
temporaryRange: z
.string()
.refine((val) => ms(val) > 0, "Temporary range must be a positive number")
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryRange),
temporaryAccessStartTime: z
.string()
.datetime()
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.CREATE.temporaryAccessStartTime)
}),
response: {
200: z.object({
privilege: ProjectUserAdditionalPrivilegeSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const privilege = await server.services.projectUserAdditionalPrivilege.create({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
...req.body,
isTemporary: true,
permissions: JSON.stringify(req.body.permissions)
});
return { privilege };
}
});
server.route({
url: "/:privilegeId",
method: "PATCH",
schema: {
params: z.object({
privilegeId: z.string().min(1).describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.privilegeId)
}),
body: z
.object({
slug: z
.string()
.max(60)
.trim()
.refine((v) => v.toLowerCase() === v, "Slug must be lowercase")
.refine((v) => slugify(v) === v, {
message: "Slug must be a valid slug"
})
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.slug),
permissions: z.any().array().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.permissions),
isTemporary: z.boolean().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary),
temporaryMode: z
.nativeEnum(ProjectUserAdditionalPrivilegeTemporaryMode)
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.temporaryMode),
temporaryRange: z
.string()
.refine((val) => ms(val) > 0, "Temporary range must be a positive number")
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.temporaryRange),
temporaryAccessStartTime: z
.string()
.datetime()
.describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.UPDATE.temporaryAccessStartTime)
})
.partial(),
response: {
200: z.object({
privilege: ProjectUserAdditionalPrivilegeSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const privilege = await server.services.projectUserAdditionalPrivilege.updateById({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
...req.body,
permissions: req.body.permissions ? JSON.stringify(req.body.permissions) : undefined,
privilegeId: req.params.privilegeId
});
return { privilege };
}
});
server.route({
url: "/:privilegeId",
method: "DELETE",
schema: {
params: z.object({
privilegeId: z.string().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.DELETE.privilegeId)
}),
response: {
200: z.object({
privilege: ProjectUserAdditionalPrivilegeSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const privilege = await server.services.projectUserAdditionalPrivilege.deleteById({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
privilegeId: req.params.privilegeId
});
return { privilege };
}
});
server.route({
url: "/",
method: "GET",
schema: {
querystring: z.object({
projectMembershipId: z.string().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.LIST.projectMembershipId)
}),
response: {
200: z.object({
privileges: ProjectUserAdditionalPrivilegeSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const privileges = await server.services.projectUserAdditionalPrivilege.listPrivileges({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
projectMembershipId: req.query.projectMembershipId
});
return { privileges };
}
});
server.route({
url: "/:privilegeId",
method: "GET",
schema: {
params: z.object({
privilegeId: z.string().describe(PROJECT_USER_ADDITIONAL_PRIVILEGE.GET_BY_PRIVILEGEID.privilegeId)
}),
response: {
200: z.object({
privilege: ProjectUserAdditionalPrivilegeSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const privilege = await server.services.projectUserAdditionalPrivilege.getPrivilegeDetailsById({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
privilegeId: req.params.privilegeId
});
return { privilege };
}
});
};

View File

@ -0,0 +1,12 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TIdentityProjectAdditionalPrivilegeDALFactory = ReturnType<
typeof identityProjectAdditionalPrivilegeDALFactory
>;
export const identityProjectAdditionalPrivilegeDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.IdentityProjectAdditionalPrivilege);
return orm;
};

View File

@ -0,0 +1,297 @@
import { ForbiddenError } from "@casl/ability";
import ms from "ms";
import { isAtLeastAsPrivileged } from "@app/lib/casl";
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
import { ActorType } from "@app/services/auth/auth-type";
import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TPermissionServiceFactory } from "../permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission";
import { TIdentityProjectAdditionalPrivilegeDALFactory } from "./identity-project-additional-privilege-dal";
import {
IdentityProjectAdditionalPrivilegeTemporaryMode,
TCreateIdentityPrivilegeDTO,
TDeleteIdentityPrivilegeDTO,
TGetIdentityPrivilegeDetailsDTO,
TListIdentityPrivilegesDTO,
TUpdateIdentityPrivilegeDTO
} from "./identity-project-additional-privilege-types";
type TIdentityProjectAdditionalPrivilegeServiceFactoryDep = {
identityProjectAdditionalPrivilegeDAL: TIdentityProjectAdditionalPrivilegeDALFactory;
identityProjectDAL: Pick<TIdentityProjectDALFactory, "findOne" | "findById">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
export type TIdentityProjectAdditionalPrivilegeServiceFactory = ReturnType<
typeof identityProjectAdditionalPrivilegeServiceFactory
>;
export const identityProjectAdditionalPrivilegeServiceFactory = ({
identityProjectAdditionalPrivilegeDAL,
identityProjectDAL,
permissionService,
projectDAL
}: TIdentityProjectAdditionalPrivilegeServiceFactoryDep) => {
const create = async ({
slug,
actor,
actorId,
identityId,
projectSlug,
permissions: customPermission,
actorOrgId,
actorAuthMethod,
...dto
}: TCreateIdentityPrivilegeDTO) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new BadRequestError({ message: "Project not found" });
const projectId = project.id;
const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId });
if (!identityProjectMembership)
throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
identityProjectMembership.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
const { permission: identityRolePermission } = await permissionService.getProjectPermission(
ActorType.IDENTITY,
identityId,
identityProjectMembership.projectId,
actorAuthMethod,
actorOrgId
);
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission);
if (!hasRequiredPriviledges)
throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" });
const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({
slug,
projectMembershipId: identityProjectMembership.id
});
if (existingSlug) throw new BadRequestError({ message: "Additional privilege of provided slug exist" });
if (!dto.isTemporary) {
const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.create({
projectMembershipId: identityProjectMembership.id,
slug,
permissions: customPermission
});
return additionalPrivilege;
}
const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange);
const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.create({
projectMembershipId: identityProjectMembership.id,
slug,
permissions: customPermission,
isTemporary: true,
temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative,
temporaryRange: dto.temporaryRange,
temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime),
temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs)
});
return additionalPrivilege;
};
const updateBySlug = async ({
projectSlug,
slug,
identityId,
data,
actorOrgId,
actor,
actorId,
actorAuthMethod
}: TUpdateIdentityPrivilegeDTO) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new BadRequestError({ message: "Project not found" });
const projectId = project.id;
const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId });
if (!identityProjectMembership)
throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
identityProjectMembership.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
const { permission: identityRolePermission } = await permissionService.getProjectPermission(
ActorType.IDENTITY,
identityProjectMembership.identityId,
identityProjectMembership.projectId,
actorAuthMethod,
actorOrgId
);
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission);
if (!hasRequiredPriviledges)
throw new ForbiddenRequestError({ message: "Failed to update more privileged identity" });
const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({
slug,
projectMembershipId: identityProjectMembership.id
});
if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" });
if (data?.slug) {
const existingSlug = await identityProjectAdditionalPrivilegeDAL.findOne({
slug: data.slug,
projectMembershipId: identityProjectMembership.id
});
if (existingSlug && existingSlug.id !== identityPrivilege.id)
throw new BadRequestError({ message: "Additional privilege of provided slug exist" });
}
const isTemporary = typeof data?.isTemporary !== "undefined" ? data.isTemporary : identityPrivilege.isTemporary;
if (isTemporary) {
const temporaryAccessStartTime = data?.temporaryAccessStartTime || identityPrivilege?.temporaryAccessStartTime;
const temporaryRange = data?.temporaryRange || identityPrivilege?.temporaryRange;
const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.updateById(identityPrivilege.id, {
...data,
temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""),
temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || ""))
});
return additionalPrivilege;
}
const additionalPrivilege = await identityProjectAdditionalPrivilegeDAL.updateById(identityPrivilege.id, {
...data,
isTemporary: false,
temporaryAccessStartTime: null,
temporaryAccessEndTime: null,
temporaryRange: null,
temporaryMode: null
});
return additionalPrivilege;
};
const deleteBySlug = async ({
actorId,
slug,
identityId,
projectSlug,
actor,
actorOrgId,
actorAuthMethod
}: TDeleteIdentityPrivilegeDTO) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new BadRequestError({ message: "Project not found" });
const projectId = project.id;
const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId });
if (!identityProjectMembership)
throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
identityProjectMembership.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
const { permission: identityRolePermission } = await permissionService.getProjectPermission(
ActorType.IDENTITY,
identityProjectMembership.identityId,
identityProjectMembership.projectId,
actorAuthMethod,
actorOrgId
);
const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, identityRolePermission);
if (!hasRequiredPriviledges)
throw new ForbiddenRequestError({ message: "Failed to edit more privileged identity" });
const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({
slug,
projectMembershipId: identityProjectMembership.id
});
if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" });
const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id);
return deletedPrivilege;
};
const getPrivilegeDetailsBySlug = async ({
projectSlug,
identityId,
slug,
actorOrgId,
actor,
actorId,
actorAuthMethod
}: TGetIdentityPrivilegeDetailsDTO) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new BadRequestError({ message: "Project not found" });
const projectId = project.id;
const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId });
if (!identityProjectMembership)
throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
identityProjectMembership.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
const identityPrivilege = await identityProjectAdditionalPrivilegeDAL.findOne({
slug,
projectMembershipId: identityProjectMembership.id
});
if (!identityPrivilege) throw new BadRequestError({ message: "Identity additional privilege not found" });
return identityPrivilege;
};
const listIdentityProjectPrivileges = async ({
identityId,
actorOrgId,
actor,
actorId,
actorAuthMethod,
projectSlug
}: TListIdentityPrivilegesDTO) => {
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new BadRequestError({ message: "Project not found" });
const projectId = project.id;
const identityProjectMembership = await identityProjectDAL.findOne({ identityId, projectId });
if (!identityProjectMembership)
throw new BadRequestError({ message: `Failed to find identity with id ${identityId}` });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
identityProjectMembership.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Identity);
const identityPrivileges = await identityProjectAdditionalPrivilegeDAL.find({
projectMembershipId: identityProjectMembership.id
});
return identityPrivileges;
};
return {
create,
updateBySlug,
deleteBySlug,
getPrivilegeDetailsBySlug,
listIdentityProjectPrivileges
};
};

View File

@ -0,0 +1,54 @@
import { TProjectPermission } from "@app/lib/types";
export enum IdentityProjectAdditionalPrivilegeTemporaryMode {
Relative = "relative"
}
export type TCreateIdentityPrivilegeDTO = {
permissions: unknown;
identityId: string;
projectSlug: string;
slug: string;
} & (
| {
isTemporary: false;
}
| {
isTemporary: true;
temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative;
temporaryRange: string;
temporaryAccessStartTime: string;
}
) &
Omit<TProjectPermission, "projectId">;
export type TUpdateIdentityPrivilegeDTO = { slug: string; identityId: string; projectSlug: string } & Omit<
TProjectPermission,
"projectId"
> & {
data: Partial<{
permissions: unknown;
slug: string;
isTemporary: boolean;
temporaryMode: IdentityProjectAdditionalPrivilegeTemporaryMode.Relative;
temporaryRange: string;
temporaryAccessStartTime: string;
}>;
};
export type TDeleteIdentityPrivilegeDTO = Omit<TProjectPermission, "projectId"> & {
slug: string;
identityId: string;
projectSlug: string;
};
export type TGetIdentityPrivilegeDetailsDTO = Omit<TProjectPermission, "projectId"> & {
slug: string;
identityId: string;
projectSlug: string;
};
export type TListIdentityPrivilegesDTO = Omit<TProjectPermission, "projectId"> & {
identityId: string;
projectSlug: string;
};

View File

@ -56,6 +56,11 @@ export const permissionDALFactory = (db: TDbClient) => {
`${TableName.ProjectUserMembershipRole}.customRoleId`,
`${TableName.ProjectRoles}.id`
)
.leftJoin(
TableName.ProjectUserAdditionalPrivilege,
`${TableName.ProjectUserAdditionalPrivilege}.projectMembershipId`,
`${TableName.ProjectMembership}.id`
)
.join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`)
.join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`)
.where("userId", userId)
@ -69,9 +74,22 @@ export const permissionDALFactory = (db: TDbClient) => {
db.ref("updatedAt").withSchema(TableName.ProjectMembership).as("membershipUpdatedAt"),
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
db.ref("orgId").withSchema(TableName.Project),
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug")
)
.select("permissions");
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"),
db.ref("permissions").withSchema(TableName.ProjectRoles),
db.ref("id").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApId"),
db.ref("permissions").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApPermissions"),
db.ref("temporaryMode").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryMode"),
db.ref("isTemporary").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApIsTemporary"),
db.ref("temporaryRange").withSchema(TableName.ProjectUserAdditionalPrivilege).as("userApTemporaryRange"),
db
.ref("temporaryAccessStartTime")
.withSchema(TableName.ProjectUserAdditionalPrivilege)
.as("userApTemporaryAccessStartTime"),
db
.ref("temporaryAccessEndTime")
.withSchema(TableName.ProjectUserAdditionalPrivilege)
.as("userApTemporaryAccessEndTime")
);
const permission = sqlNestRelationships({
data: docs,
@ -102,15 +120,44 @@ export const permissionDALFactory = (db: TDbClient) => {
permissions: z.unknown(),
customRoleSlug: z.string().optional().nullable()
}).parse(data)
},
{
key: "userApId",
label: "additionalPrivileges" as const,
mapper: ({
userApId,
userApPermissions,
userApIsTemporary,
userApTemporaryMode,
userApTemporaryRange,
userApTemporaryAccessEndTime,
userApTemporaryAccessStartTime
}) => ({
id: userApId,
permissions: userApPermissions,
temporaryRange: userApTemporaryRange,
temporaryMode: userApTemporaryMode,
temporaryAccessEndTime: userApTemporaryAccessEndTime,
temporaryAccessStartTime: userApTemporaryAccessStartTime,
isTemporary: userApIsTemporary
})
}
]
});
if (!permission?.[0]) return undefined;
// when introducting cron mode change it here
const activeRoles = permission?.[0]?.roles.filter(
const activeRoles = permission?.[0]?.roles?.filter(
({ isTemporary, temporaryAccessEndTime }) =>
!isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime)
);
return permission?.[0] ? { ...permission[0], roles: activeRoles } : undefined;
const activeAdditionalPrivileges = permission?.[0]?.additionalPrivileges?.filter(
({ isTemporary, temporaryAccessEndTime }) =>
!isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime)
);
return { ...permission[0], roles: activeRoles, additionalPrivileges: activeAdditionalPrivileges };
} catch (error) {
throw new DatabaseError({ error, name: "GetProjectPermission" });
}
@ -129,6 +176,11 @@ export const permissionDALFactory = (db: TDbClient) => {
`${TableName.IdentityProjectMembershipRole}.customRoleId`,
`${TableName.ProjectRoles}.id`
)
.leftJoin(
TableName.IdentityProjectAdditionalPrivilege,
`${TableName.IdentityProjectAdditionalPrivilege}.projectMembershipId`,
`${TableName.IdentityProjectMembership}.id`
)
.join(
// Join the Project table to later select orgId
TableName.Project,
@ -144,9 +196,28 @@ export const permissionDALFactory = (db: TDbClient) => {
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");
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"),
db.ref("permissions").withSchema(TableName.ProjectRoles),
db.ref("id").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApId"),
db.ref("permissions").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApPermissions"),
db
.ref("temporaryMode")
.withSchema(TableName.IdentityProjectAdditionalPrivilege)
.as("identityApTemporaryMode"),
db.ref("isTemporary").withSchema(TableName.IdentityProjectAdditionalPrivilege).as("identityApIsTemporary"),
db
.ref("temporaryRange")
.withSchema(TableName.IdentityProjectAdditionalPrivilege)
.as("identityApTemporaryRange"),
db
.ref("temporaryAccessStartTime")
.withSchema(TableName.IdentityProjectAdditionalPrivilege)
.as("identityApTemporaryAccessStartTime"),
db
.ref("temporaryAccessEndTime")
.withSchema(TableName.IdentityProjectAdditionalPrivilege)
.as("identityApTemporaryAccessEndTime")
);
const permission = sqlNestRelationships({
data: docs,
@ -171,16 +242,44 @@ export const permissionDALFactory = (db: TDbClient) => {
permissions: z.unknown(),
customRoleSlug: z.string().optional().nullable()
}).parse(data)
},
{
key: "identityApId",
label: "additionalPrivileges" as const,
mapper: ({
identityApId,
identityApPermissions,
identityApIsTemporary,
identityApTemporaryMode,
identityApTemporaryRange,
identityApTemporaryAccessEndTime,
identityApTemporaryAccessStartTime
}) => ({
id: identityApId,
permissions: identityApPermissions,
temporaryRange: identityApTemporaryRange,
temporaryMode: identityApTemporaryMode,
temporaryAccessEndTime: identityApTemporaryAccessEndTime,
temporaryAccessStartTime: identityApTemporaryAccessStartTime,
isTemporary: identityApIsTemporary
})
}
]
});
if (!permission?.[0]) return undefined;
// 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;
const activeAdditionalPrivileges = permission?.[0]?.additionalPrivileges?.filter(
({ isTemporary, temporaryAccessEndTime }) =>
!isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime)
);
return { ...permission[0], roles: activeRoles, additionalPrivileges: activeAdditionalPrivileges };
} catch (error) {
throw new DatabaseError({ error, name: "GetProjectIdentityPermission" });
}

View File

@ -180,10 +180,12 @@ export const permissionServiceFactory = ({
authMethod: ActorAuthMethod,
userOrgId?: string
): Promise<TProjectPermissionRT<ActorType.USER>> => {
const membership = await permissionDAL.getProjectPermission(userId, projectId);
if (!membership) throw new UnauthorizedError({ name: "User not in project" });
const userProjectPermission = await permissionDAL.getProjectPermission(userId, projectId);
if (!userProjectPermission) throw new UnauthorizedError({ name: "User not in project" });
if (membership.roles.some(({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions)) {
if (
userProjectPermission.roles.some(({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions)
) {
throw new BadRequestError({ name: "Custom permission not found" });
}
@ -192,17 +194,27 @@ export const permissionServiceFactory = ({
// Extra: This means that when users are using API keys to make requests, they can't use slug-based routes.
// Slug-based routes depend on the organization ID being present on the request, since project slugs aren't globally unique, and we need a way to filter by organization.
if (userOrgId !== "API_KEY" && membership.orgId !== userOrgId) {
if (userOrgId !== "API_KEY" && userProjectPermission.orgId !== userOrgId) {
throw new UnauthorizedError({ name: "You are not logged into this organization" });
}
validateOrgSAML(authMethod, membership.orgAuthEnforced);
validateOrgSAML(authMethod, userProjectPermission.orgAuthEnforced);
// join two permissions and pass to build the final permission set
const rolePermissions = userProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || [];
const additionalPrivileges =
userProjectPermission.additionalPrivileges?.map(({ permissions }) => ({
role: ProjectMembershipRole.Custom,
permissions
})) || [];
return {
permission: buildProjectPermission(membership.roles),
membership,
permission: buildProjectPermission(rolePermissions.concat(additionalPrivileges)),
membership: userProjectPermission,
hasRole: (role: string) =>
membership.roles.findIndex(({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug) !== -1
userProjectPermission.roles.findIndex(
({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug
) !== -1
};
};
@ -226,8 +238,16 @@ export const permissionServiceFactory = ({
throw new UnauthorizedError({ name: "You are not a member of this organization" });
}
const rolePermissions =
identityProjectPermission.roles?.map(({ role, permissions }) => ({ role, permissions })) || [];
const additionalPrivileges =
identityProjectPermission.additionalPrivileges?.map(({ permissions }) => ({
role: ProjectMembershipRole.Custom,
permissions
})) || [];
return {
permission: buildProjectPermission(identityProjectPermission.roles),
permission: buildProjectPermission(rolePermissions.concat(additionalPrivileges)),
membership: identityProjectPermission,
hasRole: (role: string) =>
identityProjectPermission.roles.findIndex(

View File

@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TProjectUserAdditionalPrivilegeDALFactory = ReturnType<typeof projectUserAdditionalPrivilegeDALFactory>;
export const projectUserAdditionalPrivilegeDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.ProjectUserAdditionalPrivilege);
return orm;
};

View File

@ -0,0 +1,212 @@
import { ForbiddenError } from "@casl/ability";
import ms from "ms";
import { BadRequestError } from "@app/lib/errors";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { TPermissionServiceFactory } from "../permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission";
import { TProjectUserAdditionalPrivilegeDALFactory } from "./project-user-additional-privilege-dal";
import {
ProjectUserAdditionalPrivilegeTemporaryMode,
TCreateUserPrivilegeDTO,
TDeleteUserPrivilegeDTO,
TGetUserPrivilegeDetailsDTO,
TListUserPrivilegesDTO,
TUpdateUserPrivilegeDTO
} from "./project-user-additional-privilege-types";
type TProjectUserAdditionalPrivilegeServiceFactoryDep = {
projectUserAdditionalPrivilegeDAL: TProjectUserAdditionalPrivilegeDALFactory;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
};
export type TProjectUserAdditionalPrivilegeServiceFactory = ReturnType<
typeof projectUserAdditionalPrivilegeServiceFactory
>;
export const projectUserAdditionalPrivilegeServiceFactory = ({
projectUserAdditionalPrivilegeDAL,
projectMembershipDAL,
permissionService
}: TProjectUserAdditionalPrivilegeServiceFactoryDep) => {
const create = async ({
slug,
actor,
actorId,
permissions: customPermission,
actorOrgId,
actorAuthMethod,
projectMembershipId,
...dto
}: TCreateUserPrivilegeDTO) => {
const projectMembership = await projectMembershipDAL.findById(projectMembershipId);
if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectMembership.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member);
const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ slug, projectMembershipId });
if (existingSlug) throw new BadRequestError({ message: "Additional privilege of provided slug exist" });
if (!dto.isTemporary) {
const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.create({
projectMembershipId,
slug,
permissions: customPermission
});
return additionalPrivilege;
}
const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange);
const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.create({
projectMembershipId,
slug,
permissions: customPermission,
isTemporary: true,
temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative,
temporaryRange: dto.temporaryRange,
temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime),
temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs)
});
return additionalPrivilege;
};
const updateById = async ({
privilegeId,
actorOrgId,
actor,
actorId,
actorAuthMethod,
...dto
}: TUpdateUserPrivilegeDTO) => {
const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId);
if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" });
const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId);
if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectMembership.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member);
if (dto?.slug) {
const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({
slug: dto.slug,
projectMembershipId: projectMembership.id
});
if (existingSlug && existingSlug.id !== userPrivilege.id)
throw new BadRequestError({ message: "Additional privilege of provided slug exist" });
}
const isTemporary = typeof dto?.isTemporary !== "undefined" ? dto.isTemporary : userPrivilege.isTemporary;
if (isTemporary) {
const temporaryAccessStartTime = dto?.temporaryAccessStartTime || userPrivilege?.temporaryAccessStartTime;
const temporaryRange = dto?.temporaryRange || userPrivilege?.temporaryRange;
const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.updateById(userPrivilege.id, {
...dto,
temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""),
temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || ""))
});
return additionalPrivilege;
}
const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.updateById(userPrivilege.id, {
...dto,
isTemporary: false,
temporaryAccessStartTime: null,
temporaryAccessEndTime: null,
temporaryRange: null,
temporaryMode: null
});
return additionalPrivilege;
};
const deleteById = async ({ actorId, actor, actorOrgId, actorAuthMethod, privilegeId }: TDeleteUserPrivilegeDTO) => {
const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId);
if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" });
const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId);
if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectMembership.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member);
const deletedPrivilege = await projectUserAdditionalPrivilegeDAL.deleteById(userPrivilege.id);
return deletedPrivilege;
};
const getPrivilegeDetailsById = async ({
privilegeId,
actorOrgId,
actor,
actorId,
actorAuthMethod
}: TGetUserPrivilegeDetailsDTO) => {
const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId);
if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" });
const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId);
if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectMembership.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member);
return userPrivilege;
};
const listPrivileges = async ({
projectMembershipId,
actorOrgId,
actor,
actorId,
actorAuthMethod
}: TListUserPrivilegesDTO) => {
const projectMembership = await projectMembershipDAL.findById(projectMembershipId);
if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" });
const { permission } = await permissionService.getProjectPermission(
actor,
actorId,
projectMembership.projectId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member);
const userPrivileges = await projectUserAdditionalPrivilegeDAL.find({ projectMembershipId });
return userPrivileges;
};
return {
create,
updateById,
deleteById,
getPrivilegeDetailsById,
listPrivileges
};
};

View File

@ -0,0 +1,40 @@
import { TProjectPermission } from "@app/lib/types";
export enum ProjectUserAdditionalPrivilegeTemporaryMode {
Relative = "relative"
}
export type TCreateUserPrivilegeDTO = (
| {
permissions: unknown;
projectMembershipId: string;
slug: string;
isTemporary: false;
}
| {
permissions: unknown;
projectMembershipId: string;
slug: string;
isTemporary: true;
temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative;
temporaryRange: string;
temporaryAccessStartTime: string;
}
) &
Omit<TProjectPermission, "projectId">;
export type TUpdateUserPrivilegeDTO = { privilegeId: string } & Omit<TProjectPermission, "projectId"> &
Partial<{
permissions: unknown;
slug: string;
isTemporary: boolean;
temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative;
temporaryRange: string;
temporaryAccessStartTime: string;
}>;
export type TDeleteUserPrivilegeDTO = Omit<TProjectPermission, "projectId"> & { privilegeId: string };
export type TGetUserPrivilegeDetailsDTO = Omit<TProjectPermission, "projectId"> & { privilegeId: string };
export type TListUserPrivilegesDTO = Omit<TProjectPermission, "projectId"> & { projectMembershipId: string };

View File

@ -397,3 +397,85 @@ export const SECRET_TAGS = {
projectId: "The ID of the project to delete the tag from."
}
} as const;
export const IDENTITY_ADDITIONAL_PRIVILEGE = {
CREATE: {
projectSlug: "The slug of the project of the identity in.",
identityId: "The ID of the identity to delete.",
slug: "The slug of the privilege to create.",
permissions:
"The permission object for the privilege. Refer https://casl.js.org/v6/en/guide/define-rules#the-shape-of-raw-rule to understand the shape",
isPackPermission: "Whether the server should pack(compact) the permission object.",
isTemporary: "Whether the privilege is temporary.",
temporaryMode: "Type of temporary access given. Types: relative",
temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d",
temporaryAccessStartTime: "ISO time for which temporary access should begin."
},
UPDATE: {
projectSlug: "The slug of the project of the identity in.",
identityId: "The ID of the identity to update.",
slug: "The slug of the privilege to update.",
newSlug: "The new slug of the privilege to update.",
permissions: `The permission object for the privilege.
Example unpacked permission shape
1. [["read", "secrets", {environment: "dev", secretPath: {$glob: "/"}}]]
2. [["read", "secrets", {environment: "dev"}], ["create", "secrets", {environment: "dev"}]]
2. [["read", "secrets", {environment: "dev"}]]
`,
isPackPermission: "Whether the server should pack(compact) the permission object.",
isTemporary: "Whether the privilege is temporary.",
temporaryMode: "Type of temporary access given. Types: relative",
temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d",
temporaryAccessStartTime: "ISO time for which temporary access should begin."
},
DELETE: {
projectSlug: "The slug of the project of the identity in.",
identityId: "The ID of the identity to delete.",
slug: "The slug of the privilege to delete."
},
GET_BY_SLUG: {
projectSlug: "The slug of the project of the identity in.",
identityId: "The ID of the identity to list.",
slug: "The slug of the privilege."
},
LIST: {
projectSlug: "The slug of the project of the identity in.",
identityId: "The ID of the identity to list.",
unpacked: "Whether the system should send the permissions as unpacked"
}
};
export const PROJECT_USER_ADDITIONAL_PRIVILEGE = {
CREATE: {
projectMembershipId: "Project membership id of user",
slug: "The slug of the privilege to create.",
permissions:
"The permission object for the privilege. Refer https://casl.js.org/v6/en/guide/define-rules#the-shape-of-raw-rule to understand the shape",
isPackPermission: "Whether the server should pack(compact) the permission object.",
isTemporary: "Whether the privilege is temporary.",
temporaryMode: "Type of temporary access given. Types: relative",
temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d",
temporaryAccessStartTime: "ISO time for which temporary access should begin."
},
UPDATE: {
privilegeId: "The id of privilege object",
slug: "The slug of the privilege to create.",
newSlug: "The new slug of the privilege to create.",
permissions:
"The permission object for the privilege. Refer https://casl.js.org/v6/en/guide/define-rules#the-shape-of-raw-rule to understand the shape",
isPackPermission: "Whether the server should pack(compact) the permission object.",
isTemporary: "Whether the privilege is temporary.",
temporaryMode: "Type of temporary access given. Types: relative",
temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d",
temporaryAccessStartTime: "ISO time for which temporary access should begin."
},
DELETE: {
privilegeId: "The id of privilege object"
},
GET_BY_PRIVILEGEID: {
privilegeId: "The id of privilege object"
},
LIST: {
projectMembershipId: "Project membership id of user"
}
};

View File

@ -11,12 +11,16 @@ import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/pro
import { dynamicSecretLeaseDALFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal";
import { dynamicSecretLeaseQueueServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue";
import { dynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service";
import { identityProjectAdditionalPrivilegeDALFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-dal";
import { identityProjectAdditionalPrivilegeServiceFactory } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service";
import { ldapConfigDALFactory } from "@app/ee/services/ldap-config/ldap-config-dal";
import { ldapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-config-service";
import { licenseDALFactory } from "@app/ee/services/license/license-dal";
import { licenseServiceFactory } from "@app/ee/services/license/license-service";
import { permissionDALFactory } from "@app/ee/services/permission/permission-dal";
import { permissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { projectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal";
import { projectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service";
import { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal";
import { samlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service";
import { scimDALFactory } from "@app/ee/services/scim/scim-dal";
@ -149,6 +153,7 @@ export const registerRoutes = async (
const projectDAL = projectDALFactory(db);
const projectMembershipDAL = projectMembershipDALFactory(db);
const projectUserAdditionalPrivilegeDAL = projectUserAdditionalPrivilegeDALFactory(db);
const projectUserMembershipRoleDAL = projectUserMembershipRoleDALFactory(db);
const projectRoleDAL = projectRoleDALFactory(db);
const projectEnvDAL = projectEnvDALFactory(db);
@ -174,6 +179,7 @@ export const registerRoutes = async (
const identityOrgMembershipDAL = identityOrgDALFactory(db);
const identityProjectDAL = identityProjectDALFactory(db);
const identityProjectMembershipRoleDAL = identityProjectMembershipRoleDALFactory(db);
const identityProjectAdditionalPrivilegeDAL = identityProjectAdditionalPrivilegeDALFactory(db);
const identityUaDAL = identityUaDALFactory(db);
const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db);
@ -345,6 +351,11 @@ export const registerRoutes = async (
projectRoleDAL,
licenseService
});
const projectUserAdditionalPrivilegeService = projectUserAdditionalPrivilegeServiceFactory({
permissionService,
projectMembershipDAL,
projectUserAdditionalPrivilegeDAL
});
const projectKeyService = projectKeyServiceFactory({
permissionService,
projectKeyDAL,
@ -548,6 +559,12 @@ export const registerRoutes = async (
identityProjectMembershipRoleDAL,
projectRoleDAL
});
const identityProjectAdditionalPrivilegeService = identityProjectAdditionalPrivilegeServiceFactory({
projectDAL,
identityProjectAdditionalPrivilegeDAL,
permissionService,
identityProjectDAL
});
const identityUaService = identityUaServiceFactory({
identityOrgMembershipDAL,
permissionService,
@ -638,7 +655,9 @@ export const registerRoutes = async (
trustedIp: trustedIpService,
scim: scimService,
secretBlindIndex: secretBlindIndexService,
telemetry: telemetryService
telemetry: telemetryService,
projectUserAdditionalPrivilege: projectUserAdditionalPrivilegeService,
identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService
});
server.decorate<FastifyZodProvider["store"]>("store", {

View File

@ -158,7 +158,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
])
)
.min(1)
.refine((data) => data.some(({ isTemporary }) => !isTemporary), "At least long lived role is required")
.refine((data) => data.some(({ isTemporary }) => !isTemporary), "At least one long lived role is required")
.describe(PROJECTS.UPDATE_USER_MEMBERSHIP.roles)
}),
response: {

View File

@ -25,6 +25,11 @@ export const identityProjectDALFactory = (db: TDbClient) => {
`${TableName.IdentityProjectMembershipRole}.customRoleId`,
`${TableName.ProjectRoles}.id`
)
.leftJoin(
TableName.IdentityProjectAdditionalPrivilege,
`${TableName.IdentityProjectMembership}.id`,
`${TableName.IdentityProjectAdditionalPrivilege}.projectMembershipId`
)
.select(
db.ref("id").withSchema(TableName.IdentityProjectMembership),
db.ref("createdAt").withSchema(TableName.IdentityProjectMembership),

View File

@ -655,7 +655,7 @@ export const secretServiceFactory = ({
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);
@ -741,7 +741,7 @@ export const secretServiceFactory = ({
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create,
ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
);

View File

@ -20,6 +20,7 @@ const tagVariants = cva(
green: "bg-primary-800 text-white"
},
size: {
xs: "text-xs px-1 py-0.5",
sm: "px-2 py-0.5"
}
}

View File

@ -29,18 +29,30 @@ export type IdentityMembershipOrg = {
export type IdentityMembership = {
id: string;
identity: Identity;
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;
}[];
roles: Array<
{
id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
} & (
| {
isTemporary: false;
temporaryRange: null;
temporaryMode: null;
temporaryAccessEndTime: null;
temporaryAccessStartTime: null;
}
| {
isTemporary: true;
temporaryRange: string;
temporaryMode: string;
temporaryAccessEndTime: string;
temporaryAccessStartTime: string;
}
)
>;
createdAt: string;
updatedAt: string;
};

View File

@ -0,0 +1,7 @@
export {
useCreateIdentityProjectAdditionalPrivilege,
useDeleteIdentityProjectAdditionalPrivilege,
useUpdateIdentityProjectAdditionalPrivilege
} from "./mutation";
export { useGetIdentityProjectPrivilegeDetails } from "./queries";
export type { TIdentityProjectPrivilege } from "./types";

View File

@ -0,0 +1,73 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { identitiyProjectPrivilegeKeys } from "./queries";
import {
TCreateIdentityProjectPrivilegeDTO,
TDeleteIdentityProjectPrivilegeDTO,
TIdentityProjectPrivilege,
TUpdateIdentityProjectPrivlegeDTO
} from "./types";
export const useCreateIdentityProjectAdditionalPrivilege = () => {
const queryClient = useQueryClient();
return useMutation<TIdentityProjectPrivilege, {}, TCreateIdentityProjectPrivilegeDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.post(
"/api/v1/additional-privilege/identity/permanent",
dto
);
return data.privilege;
},
onSuccess: (_, { projectSlug, identityId }) => {
queryClient.invalidateQueries(
identitiyProjectPrivilegeKeys.list({ projectSlug, identityId })
);
}
});
};
export const useUpdateIdentityProjectAdditionalPrivilege = () => {
const queryClient = useQueryClient();
return useMutation<TIdentityProjectPrivilege, {}, TUpdateIdentityProjectPrivlegeDTO>({
mutationFn: async ({ privilegeSlug, projectSlug, identityId, privilegeDetails }) => {
const { data: res } = await apiRequest.patch("/api/v1/additional-privilege/identity", {
privilegeSlug,
projectSlug,
identityId,
privilegeDetails
});
return res.privilege;
},
onSuccess: (_, { projectSlug, identityId }) => {
queryClient.invalidateQueries(
identitiyProjectPrivilegeKeys.list({ projectSlug, identityId })
);
}
});
};
export const useDeleteIdentityProjectAdditionalPrivilege = () => {
const queryClient = useQueryClient();
return useMutation<TIdentityProjectPrivilege, {}, TDeleteIdentityProjectPrivilegeDTO>({
mutationFn: async ({ identityId, projectSlug, privilegeSlug }) => {
const { data } = await apiRequest.delete("/api/v1/additional-privilege/identity", {
data: {
identityId,
projectSlug,
privilegeSlug
}
});
return data.privilege;
},
onSuccess: (_, { projectSlug, identityId }) => {
queryClient.invalidateQueries(
identitiyProjectPrivilegeKeys.list({ projectSlug, identityId })
);
}
});
};

View File

@ -0,0 +1,77 @@
import { PackRule, unpackRules } from "@casl/ability/extra";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TProjectPermission } from "../roles/types";
import {
TGetIdentityProejctPrivilegeDetails as TGetIdentityProjectPrivilegeDetails,
TIdentityProjectPrivilege,
TListIdentityUserPrivileges as TListIdentityProjectPrivileges
} from "./types";
export const identitiyProjectPrivilegeKeys = {
details: ({ identityId, privilegeSlug, projectSlug }: TGetIdentityProjectPrivilegeDetails) =>
[
"identity-user-privilege",
{
identityId,
projectSlug,
privilegeSlug
}
] as const,
list: ({ projectSlug, identityId }: TListIdentityProjectPrivileges) =>
["identity-user-privileges", { identityId, projectSlug }] as const
};
export const useGetIdentityProjectPrivilegeDetails = ({
projectSlug,
identityId,
privilegeSlug
}: TGetIdentityProjectPrivilegeDetails) => {
return useQuery({
enabled: Boolean(projectSlug && identityId && privilegeSlug),
queryKey: identitiyProjectPrivilegeKeys.details({ projectSlug, privilegeSlug, identityId }),
queryFn: async () => {
const {
data: { privilege }
} = await apiRequest.get<{
privilege: Omit<TIdentityProjectPrivilege, "permissions"> & { permissions: unknown };
}>(`/api/v1/additional-privilege/identity/${privilegeSlug}`, {
params: {
identityId,
projectSlug
}
});
return {
...privilege,
permissions: unpackRules(privilege.permissions as PackRule<TProjectPermission>[])
};
}
});
};
export const useListIdentityProjectPrivileges = ({
projectSlug,
identityId
}: TListIdentityProjectPrivileges) => {
return useQuery({
enabled: Boolean(projectSlug && identityId),
queryKey: identitiyProjectPrivilegeKeys.list({ projectSlug, identityId }),
queryFn: async () => {
const {
data: { privileges }
} = await apiRequest.get<{
privileges: Array<
Omit<TIdentityProjectPrivilege, "permissions"> & { permissions: unknown }
>;
}>("/api/v1/additional-privilege/identity", {
params: { identityId, projectSlug, unpacked: false }
});
return privileges.map((el) => ({
...el,
permissions: unpackRules(el.permissions as PackRule<TProjectPermission>[])
}));
}
});
};

View File

@ -0,0 +1,64 @@
import { TProjectPermission } from "../roles/types";
export enum IdentityProjectAdditionalPrivilegeTemporaryMode {
Relative = "relative"
}
export type TIdentityProjectPrivilege = {
projectMembershipId: string;
slug: string;
id: string;
createdAt: Date;
updatedAt: Date;
permissions?: TProjectPermission[];
} & (
| {
isTemporary: true;
temporaryMode: string;
temporaryRange: string;
temporaryAccessStartTime: string;
temporaryAccessEndTime?: string;
}
| {
isTemporary: false;
temporaryMode?: null;
temporaryRange?: null;
temporaryAccessStartTime?: null;
temporaryAccessEndTime?: null;
}
);
export type TCreateIdentityProjectPrivilegeDTO = {
identityId: string;
projectSlug: string;
slug?: string;
isTemporary?: boolean;
temporaryMode?: IdentityProjectAdditionalPrivilegeTemporaryMode;
temporaryRange?: string;
temporaryAccessStartTime?: string;
permissions: TProjectPermission[];
};
export type TUpdateIdentityProjectPrivlegeDTO = {
projectSlug: string;
identityId: string;
privilegeSlug: string;
privilegeDetails: Partial<Omit<TCreateIdentityProjectPrivilegeDTO, "projectMembershipId" | "projectId">>;
};
export type TDeleteIdentityProjectPrivilegeDTO = {
projectSlug: string;
identityId: string;
privilegeSlug: string;
};
export type TListIdentityUserPrivileges = {
projectSlug: string;
identityId: string;
};
export type TGetIdentityProejctPrivilegeDetails = {
projectSlug: string;
identityId: string;
privilegeSlug: string;
};

View File

@ -6,12 +6,14 @@ export * from "./bots";
export * from "./dynamicSecret";
export * from "./dynamicSecretLease";
export * from "./identities";
export * from "./identityProjectAdditionalPrivilege";
export * from "./incidentContacts";
export * from "./integrationAuth";
export * from "./integrations";
export * from "./keys";
export * from "./ldapConfig";
export * from "./organization";
export * from "./projectUserAdditionalPrivilege";
export * from "./roles";
export * from "./scim";
export * from "./secretApproval";

View File

@ -0,0 +1,7 @@
export {
useCreateProjectUserAdditionalPrivilege,
useDeleteProjectUserAdditionalPrivilege,
useUpdateProjectUserAdditionalPrivilege
} from "./mutation";
export { useGetProjectUserPrivilegeDetails, useListProjectUserPrivileges } from "./queries";
export type { TProjectUserPrivilege } from "./types";

View File

@ -0,0 +1,62 @@
import { packRules } from "@casl/ability/extra";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { projectUserPrivilegeKeys } from "./queries";
import {
TCreateProjectUserPrivilegeDTO,
TDeleteProjectUserPrivilegeDTO,
TProjectUserPrivilege,
TUpdateProjectUserPrivlegeDTO
} from "./types";
export const useCreateProjectUserAdditionalPrivilege = () => {
const queryClient = useQueryClient();
return useMutation<{ privilege: TProjectUserPrivilege }, {}, TCreateProjectUserPrivilegeDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.post("/api/v1/additional-privilege/users/permanent", {
...dto,
permissions: packRules(dto.permissions)
});
return data.privilege;
},
onSuccess: (_, { projectMembershipId }) => {
queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId));
}
});
};
export const useUpdateProjectUserAdditionalPrivilege = () => {
const queryClient = useQueryClient();
return useMutation<{ privilege: TProjectUserPrivilege }, {}, TUpdateProjectUserPrivlegeDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.patch(
`/api/v1/additional-privilege/users/${dto.privilegeId}`,
{ ...dto, permissions: dto.permissions ? packRules(dto.permissions) : undefined }
);
return data.privilege;
},
onSuccess: (_, { projectMembershipId }) => {
queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId));
}
});
};
export const useDeleteProjectUserAdditionalPrivilege = () => {
const queryClient = useQueryClient();
return useMutation<{ privilege: TProjectUserPrivilege }, {}, TDeleteProjectUserPrivilegeDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.delete(
`/api/v1/additional-privilege/users/${dto.privilegeId}`
);
return data.privilege;
},
onSuccess: (_, { projectMembershipId }) => {
queryClient.invalidateQueries(projectUserPrivilegeKeys.list(projectMembershipId));
}
});
};

View File

@ -0,0 +1,51 @@
import { PackRule, unpackRules } from "@casl/ability/extra";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TProjectPermission } from "../roles/types";
import { TProjectUserPrivilege } from "./types";
export const projectUserPrivilegeKeys = {
details: (privilegeId: string) => ["project-user-privilege", { privilegeId }] as const,
list: (projectMembershipId: string) =>
["project-user-privileges", { projectMembershipId }] as const
};
const fetchProjectUserPrivilegeDetails = async (privilegeId: string) => {
const {
data: { privilege }
} = await apiRequest.get<{
privilege: Omit<TProjectUserPrivilege, "permissions"> & { permissions: unknown };
}>(`/api/v1/additional-privilege/users/${privilegeId}`);
return {
...privilege,
permissions: unpackRules(privilege.permissions as PackRule<TProjectPermission>[])
};
};
export const useGetProjectUserPrivilegeDetails = (privilegeId: string) => {
return useQuery({
enabled: Boolean(privilegeId),
queryKey: projectUserPrivilegeKeys.details(privilegeId),
queryFn: () => fetchProjectUserPrivilegeDetails(privilegeId)
});
};
export const useListProjectUserPrivileges = (projectMembershipId: string) => {
return useQuery({
enabled: Boolean(projectMembershipId),
queryKey: projectUserPrivilegeKeys.list(projectMembershipId),
queryFn: async () => {
const {
data: { privileges }
} = await apiRequest.get<{
privileges: Array<Omit<TProjectUserPrivilege, "permissions"> & { permissions: unknown }>;
}>("/api/v1/additional-privilege/users", { params: { projectMembershipId } });
return privileges.map((el) => ({
...el,
permissions: unpackRules(el.permissions as PackRule<TProjectPermission>[])
}));
}
});
};

View File

@ -0,0 +1,57 @@
import { TProjectPermission } from "../roles/types";
export enum ProjectUserAdditionalPrivilegeTemporaryMode {
Relative = "relative"
}
export type TProjectUserPrivilege = {
projectMembershipId: string;
slug: string;
id: string;
createdAt: Date;
updatedAt: Date;
permissions?: TProjectPermission[];
} & (
| {
isTemporary: true;
temporaryMode: string;
temporaryRange: string;
temporaryAccessStartTime: string;
temporaryAccessEndTime?: string;
}
| {
isTemporary: false;
temporaryMode?: null;
temporaryRange?: null;
temporaryAccessStartTime?: null;
temporaryAccessEndTime?: null;
}
);
export type TCreateProjectUserPrivilegeDTO = {
projectMembershipId: string;
slug?: string;
isTemporary?: boolean;
temporaryMode?: ProjectUserAdditionalPrivilegeTemporaryMode;
temporaryRange?: string;
temporaryAccessStartTime?: string;
permissions: TProjectPermission[];
};
export type TUpdateProjectUserPrivlegeDTO = {
privilegeId: string;
projectMembershipId: string;
} & Partial<Omit<TCreateProjectUserPrivilegeDTO, "projectMembershipId">>;
export type TDeleteProjectUserPrivilegeDTO = {
privilegeId: string;
projectMembershipId: string;
};
export type TGetProjectUserPrivilegeDetails = {
privilegeId: string;
};
export type TListProjectUserPrivileges = {
projectMembershipId: string;
};

View File

@ -76,18 +76,32 @@ export type TWorkspaceUser = {
};
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;
}[];
roles: (
| {
id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
isTemporary: false;
temporaryRange: null;
temporaryMode: null;
temporaryAccessEndTime: null;
temporaryAccessStartTime: null;
}
| {
id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
isTemporary: true;
temporaryRange: string;
temporaryMode: string;
temporaryAccessEndTime: string;
temporaryAccessStartTime: string;
}
)[];
status: "invited" | "accepted" | "verified" | "completed";
deniedPermissions: any[];
};

View File

@ -32,15 +32,7 @@ export const MembersPage = withProjectPermission(
<Tab value={TabSections.Roles}>Project Roles</Tab>
</TabList>
<TabPanel value={TabSections.Member}>
<motion.div
key="panel-1"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<MemberListTab />
</motion.div>
<MemberListTab />
</TabPanel>
<TabPanel value={TabSections.Identities}>
<IdentityTab />

View File

@ -1,17 +1,351 @@
import Link from "next/link";
import {
faArrowUpRightFromSquare,
faClock,
faEdit,
faPlus,
faServer,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { IdentitySection } from "./components";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
DeleteActionModal,
EmptyState,
HoverCard,
HoverCardContent,
HoverCardTrigger,
IconButton,
Modal,
ModalContent,
Table,
TableContainer,
TableSkeleton,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
import { IdentityMembership } from "@app/hooks/api/identities/types";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { usePopUp } from "@app/hooks/usePopUp";
export const IdentityTab = () => {
return (
<motion.div
key="panel-identity"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<IdentitySection />
</motion.div>
);
import { IdentityModal } from "./components/IdentityModal";
import { IdentityRoleForm } from "./components/IdentityRoleForm";
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Member) return "Developer";
if (role === ProjectMembershipRole.NoAccess) return "No access";
return role;
};
export const IdentityTab = withProjectPermission(
() => {
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id ?? "";
const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || "");
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"identity",
"deleteIdentity",
"upgradePlan",
"updateRole"
] as const);
const onRemoveIdentitySubmit = async (identityId: string) => {
try {
await deleteMutateAsync({
identityId,
workspaceId
});
createNotification({
text: "Successfully removed identity from project",
type: "success"
});
handlePopUpClose("deleteIdentity");
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to remove identity from project";
createNotification({
text,
type: "error"
});
}
};
return (
<motion.div
key="identity-role-panel"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
<div className="flex w-full justify-end pr-4">
<Link href="https://infisical.com/docs/documentation/platform/identities/overview">
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</Link>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("identity")}
isDisabled={!isAllowed}
>
Add identity
</Button>
)}
</ProjectPermissionCan>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Role</Th>
<Th>Added on</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={7} innerKey="project-identities" />}
{!isLoading &&
data &&
data.length > 0 &&
data.map((identityMember, index) => {
const {
identity: { id, name },
roles,
createdAt
} = identityMember;
return (
<Tr className="h-10" key={`st-v3-${id}`}>
<Td>{name}</Td>
<Td>
<div className="flex items-center space-x-2">
{roles
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(
({
role,
customRoleName,
id: roleId,
isTemporary,
temporaryAccessEndTime
}) => {
const isExpired =
new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={roleId}>
<div className="flex items-center space-x-2">
<div className="capitalize">
{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: roleId,
isTemporary,
temporaryAccessEndTime
}) => {
const isExpired =
new Date() >
new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={roleId} 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>
)}
<Tooltip content="Edit permission">
<IconButton
size="sm"
variant="plain"
ariaLabel="update-role"
onClick={() =>
handlePopUpOpen("updateRole", { ...identityMember, index })
}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</Tooltip>
</div>
</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<IconButton
onClick={() => {
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={7}>
<EmptyState
title="No identities have been added to this project"
icon={faServer}
/>
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
<Modal
isOpen={popUp.updateRole.isOpen}
onOpenChange={(state) => handlePopUpToggle("updateRole", state)}
>
<ModalContent
className="max-w-3xl"
title={`Manage Access for ${(popUp.updateRole.data as IdentityMembership)?.identity?.name
}`}
subTitle={`
Configure role-based access control by assigning machine identities a mix of roles and specific privileges. An identity will gain access to all actions within the roles assigned to it, not just the actions those roles share in common. You must choose at least one permanent role.
`}
>
<IdentityRoleForm
onOpenUpgradeModal={(description) =>
handlePopUpOpen("upgradePlan", { description })
}
identityProjectMember={
data?.[
(popUp.updateRole?.data as IdentityMembership & { index: number })?.index
] as IdentityMembership
}
/>
</ModalContent>
</Modal>
<IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen}
title={`Are you sure want to remove ${(popUp?.deleteIdentity?.data as { name: string })?.name || ""
} from the project?`}
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveIdentitySubmit(
(popUp?.deleteIdentity?.data as { identityId: string })?.identityId
)
}
/>
</div>
</motion.div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity }
);

View File

@ -0,0 +1,353 @@
/* eslint-disable no-nested-ternary */
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { format, formatDistance } from "date-fns";
import ms from "ms";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
FormControl,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectItem,
Spinner,
Tag,
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription,
useWorkspace
} from "@app/context";
import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } from "@app/hooks/api";
import { IdentityMembership } from "@app/hooks/api/identities/types";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
const roleFormSchema = z.object({
roles: z
.object({
slug: z.string(),
temporaryAccess: z.discriminatedUnion("isTemporary", [
z.object({
isTemporary: z.literal(true),
temporaryRange: z.string().min(1),
temporaryAccessStartTime: z.string().datetime(),
temporaryAccessEndTime: z.string().datetime().nullable().optional()
}),
z.object({
isTemporary: z.literal(false)
})
])
})
.array()
});
type TRoleForm = z.infer<typeof roleFormSchema>;
type Props = {
identityProjectMember: IdentityMembership;
onOpenUpgradeModal: (title: string) => void;
};
export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal }: Props) => {
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
const { permission } = useProjectPermission();
const isMemberEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionSub.Identity
);
const roleForm = useForm<TRoleForm>({
resolver: zodResolver(roleFormSchema),
values: {
roles: identityProjectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({
slug: customRoleSlug || role,
temporaryAccess: dto.isTemporary
? {
isTemporary: true,
temporaryRange: dto.temporaryRange,
temporaryAccessEndTime: dto.temporaryAccessEndTime,
temporaryAccessStartTime: dto.temporaryAccessStartTime
}
: {
isTemporary: dto.isTemporary
}
}))
}
});
const selectedRoleList = useFieldArray({
name: "roles",
control: roleForm.control
});
const formRoleField = roleForm.watch("roles");
const updateMembershipRole = useUpdateIdentityWorkspaceRole();
const handleRoleUpdate = async (data: TRoleForm) => {
if (updateMembershipRole.isLoading) return;
const sanitizedRoles = data.roles.map((el) => {
const { isTemporary } = el.temporaryAccess;
if (!isTemporary) {
return { role: el.slug, isTemporary: false as const };
}
return {
role: el.slug,
isTemporary: true as const,
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
temporaryRange: el.temporaryAccess.temporaryRange,
temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime
};
});
const hasCustomRoleSelected = sanitizedRoles.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,
identityId: identityProjectMember.identity.id,
roles: sanitizedRoles
});
createNotification({ text: "Successfully updated roles", type: "success" });
roleForm.reset(undefined, { keepValues: true });
} catch (err) {
createNotification({ text: "Failed to update role", type: "error" });
}
};
if (isRolesLoading)
return (
<div className="flex w-full items-center justify-center p-8">
<Spinner />
</div>
);
return (
<div>
<div className="text-lg font-medium">Roles</div>
<p className="text-sm text-mineshaft-400">Select one of the pre-defined or custom roles.</p>
<div>
<form onSubmit={roleForm.handleSubmit(handleRoleUpdate)}>
<div className="mt-2 flex flex-col space-y-2">
{selectedRoleList.fields.map(({ id }, index) => {
const { temporaryAccess } = formRoleField[index];
const isTemporary = temporaryAccess?.isTemporary;
const isExpired =
temporaryAccess.isTemporary &&
new Date() > new Date(temporaryAccess.temporaryAccessEndTime || "");
return (
<div key={id} className="flex items-center space-x-2">
<Controller
control={roleForm.control}
name={`roles.${index}.slug`}
render={({ field: { onChange, ...field } }) => (
<Select
defaultValue={field.value}
{...field}
isDisabled={isMemberEditDisabled}
onValueChange={(e) => onChange(e)}
className="w-full bg-mineshaft-600"
>
{projectRoles?.map(({ name, slug, id: projectRoleId }) => (
<SelectItem value={slug} key={projectRoleId}>
{name}
</SelectItem>
))}
</Select>
)}
/>
<Popover>
<PopoverTrigger disabled={isMemberEditDisabled}>
<div>
<Tooltip
content={
temporaryAccess?.isTemporary
? isExpired
? "Timed Access Expired"
: `Until ${format(
new Date(temporaryAccess.temporaryAccessEndTime || ""),
"yyyy-MM-dd HH:mm:ss"
)}`
: "Non expiry access"
}
>
<Button
variant="outline_bg"
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
isDisabled={isMemberEditDisabled}
className={twMerge(
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{temporaryAccess?.isTemporary
? isExpired
? "Access Expired"
: formatDistance(
new Date(temporaryAccess.temporaryAccessEndTime || ""),
new Date()
)
: "Permanent"}
</Button>
</Tooltip>
</div>
</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={roleForm.control}
defaultValue="1h"
name={`roles.${index}.temporaryAccess.temporaryRange`}
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Validity" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Button
size="xs"
onClick={() => {
const temporaryRange = roleForm.getValues(
`roles.${index}.temporaryAccess.temporaryRange`
);
if (!temporaryRange) {
roleForm.setError(
`roles.${index}.temporaryAccess.temporaryRange`,
{ type: "required", message: "Required" },
{ shouldFocus: true }
);
return;
}
roleForm.clearErrors(`roles.${index}.temporaryAccess.temporaryRange`);
roleForm.setValue(
`roles.${index}.temporaryAccess`,
{
isTemporary: true,
temporaryAccessStartTime: new Date().toISOString(),
temporaryRange,
temporaryAccessEndTime: new Date(
new Date().getTime() + ms(temporaryRange)
).toISOString()
},
{ shouldDirty: true }
);
}}
>
{temporaryAccess.isTemporary ? "Restart" : "Grant"}
</Button>
{temporaryAccess.isTemporary && (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
roleForm.setValue(`roles.${index}.temporaryAccess`, {
isTemporary: false
});
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
<IconButton
variant="outline_bg"
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
ariaLabel="delete-role"
isDisabled={isMemberEditDisabled}
onClick={() => {
if (selectedRoleList.fields.length > 1) {
selectedRoleList.remove(index);
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
);
})}
</div>
<div className="mt-4 flex justify-between space-x-2">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<Button
variant="outline_bg"
isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() =>
selectedRoleList.append({
slug: ProjectMembershipRole.Member,
temporaryAccess: { isTemporary: false }
})
}
>
Add Role
</Button>
)}
</ProjectPermissionCan>
<Button
type="submit"
className={twMerge(
"transition-all",
"opacity-0",
roleForm.formState.isDirty && "opacity-100"
)}
isLoading={roleForm.formState.isSubmitting}
>
Save Roles
</Button>
</div>
</form>
</div>
</div>
);
};

View File

@ -0,0 +1,20 @@
import { IdentityMembership } from "@app/hooks/api/identities/types";
import { IdentityRbacSection } from "./IdentityRbacSection";
import { SpecificPrivilegeSection } from "./SpecificPrivilegeSection";
type Props = {
identityProjectMember: IdentityMembership;
onOpenUpgradeModal: (title: string) => void;
};
export const IdentityRoleForm = ({ identityProjectMember, onOpenUpgradeModal }: Props) => {
return (
<div>
<IdentityRbacSection
identityProjectMember={identityProjectMember}
onOpenUpgradeModal={onOpenUpgradeModal}
/>
<SpecificPrivilegeSection identityId={identityProjectMember?.identity?.id} />
</div>
);
};

View File

@ -0,0 +1,532 @@
import { Controller, useForm } from "react-hook-form";
import {
faArrowRotateLeft,
faCaretDown,
faCheck,
faClock,
faPlus,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { format, formatDistance } from "date-fns";
import ms from "ms";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
Checkbox,
DeleteActionModal,
FormControl,
FormLabel,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectItem,
Spinner,
Tag,
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
TProjectUserPrivilege,
useCreateIdentityProjectAdditionalPrivilege,
useDeleteIdentityProjectAdditionalPrivilege,
useUpdateIdentityProjectAdditionalPrivilege
} from "@app/hooks/api";
import { useListIdentityProjectPrivileges } from "@app/hooks/api/identityProjectAdditionalPrivilege/queries";
const secretPermissionSchema = z.object({
secretPath: z.string().optional(),
environmentSlug: z.string(),
[ProjectPermissionActions.Edit]: z.boolean().optional(),
[ProjectPermissionActions.Read]: z.boolean().optional(),
[ProjectPermissionActions.Create]: z.boolean().optional(),
[ProjectPermissionActions.Delete]: z.boolean().optional(),
temporaryAccess: z.discriminatedUnion("isTemporary", [
z.object({
isTemporary: z.literal(true),
temporaryRange: z.string().min(1),
temporaryAccessStartTime: z.string().datetime(),
temporaryAccessEndTime: z.string().datetime().nullable().optional()
}),
z.object({
isTemporary: z.literal(false)
})
])
});
type TSecretPermissionForm = z.infer<typeof secretPermissionSchema>;
const SpecificPrivilegeSecretForm = ({
privilege,
identityId
}: {
privilege: TProjectUserPrivilege;
identityId: string;
}) => {
const { currentWorkspace } = useWorkspace();
const projectSlug = currentWorkspace?.slug || "";
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"deletePrivilege"
] as const);
const { permission } = useProjectPermission();
const isMemberEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionSub.Identity
);
const updateIdentityPrivilege = useUpdateIdentityProjectAdditionalPrivilege();
const deleteIdentityPrivilege = useDeleteIdentityProjectAdditionalPrivilege();
const privilegeForm = useForm<TSecretPermissionForm>({
resolver: zodResolver(secretPermissionSchema),
values: {
environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
// secret path will be inside $glob operator
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "",
read: privilege.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Read)
),
edit: privilege.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Edit)
),
create: privilege.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Create)
),
delete: privilege.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Delete)
),
// zod will pick it
temporaryAccess: privilege
}
});
const temporaryAccessField = privilegeForm.watch("temporaryAccess");
const isTemporary = temporaryAccessField?.isTemporary;
const isExpired =
temporaryAccessField.isTemporary &&
new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || "");
const handleUpdatePrivilege = async (data: TSecretPermissionForm) => {
if (updateIdentityPrivilege.isLoading) return;
try {
const actions = [
{ action: ProjectPermissionActions.Read, allowed: data.read },
{ action: ProjectPermissionActions.Create, allowed: data.create },
{ action: ProjectPermissionActions.Delete, allowed: data.delete },
{ action: ProjectPermissionActions.Edit, allowed: data.edit }
];
const conditions: Record<string, any> = { environment: data.environmentSlug };
if (data.secretPath) {
conditions.secretPath = { $glob: data.secretPath };
}
await updateIdentityPrivilege.mutateAsync({
privilegeDetails: {
...data.temporaryAccess,
permissions: actions
.filter(({ allowed }) => allowed)
.map(({ action }) => ({
action,
subject: [ProjectPermissionSub.Secrets],
conditions
}))
},
privilegeSlug: privilege.slug,
identityId,
projectSlug
});
createNotification({
type: "success",
text: "Successfully updated privilege"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to update privilege"
});
}
};
const handleDeletePrivilege = async () => {
if (deleteIdentityPrivilege.isLoading) return;
try {
await deleteIdentityPrivilege.mutateAsync({
identityId,
privilegeSlug: privilege.slug,
projectSlug
});
createNotification({
type: "success",
text: "Successfully deleted privilege"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to delete privilege"
});
}
};
const getAccessLabel = (exactTime = false) => {
if (isExpired) return "Access expired";
if (!temporaryAccessField?.isTemporary) return "Permanent";
if (exactTime)
return `Until ${format(
new Date(temporaryAccessField.temporaryAccessEndTime || ""),
"yyyy-MM-dd HH:mm:ss"
)}`;
return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date());
};
return (
<div className="mt-4">
<form onSubmit={privilegeForm.handleSubmit(handleUpdatePrivilege)}>
<div className="flex items-start space-x-4">
<Controller
control={privilegeForm.control}
name="environmentSlug"
render={({ field: { onChange, ...field } }) => (
<FormControl label="Env">
<Select
{...field}
isDisabled={isMemberEditDisabled}
className="bg-mineshaft-600 hover:bg-mineshaft-500"
onValueChange={(e) => onChange(e)}
>
{currentWorkspace?.environments?.map(({ slug, id }) => (
<SelectItem value={slug} key={id}>
{slug}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={privilegeForm.control}
name="secretPath"
render={({ field }) => (
<FormControl label="Secret Path">
<Input {...field} isDisabled={isMemberEditDisabled} className="w-48" />
</FormControl>
)}
/>
<div className="flex flex-grow justify-between">
<Controller
control={privilegeForm.control}
name="read"
render={({ field }) => (
<div className="flex flex-col items-center">
<FormLabel label="View" className="mb-4" />
<Checkbox
isDisabled={isMemberEditDisabled}
id="secret-read"
className="h-5 w-5"
isChecked={field.value}
onCheckedChange={(isChecked) => field.onChange(isChecked)}
/>
</div>
)}
/>
<Controller
control={privilegeForm.control}
name="create"
render={({ field }) => (
<div className="flex flex-col items-center">
<FormLabel label="Create" className="mb-4" />
<Checkbox
isDisabled={isMemberEditDisabled}
id="secret-create"
className="h-5 w-5"
isChecked={field.value}
onCheckedChange={(isChecked) => field.onChange(isChecked)}
/>
</div>
)}
/>
<Controller
control={privilegeForm.control}
name="edit"
render={({ field }) => (
<div className="flex flex-col items-center">
<FormLabel label="Modify" className="mb-4" />
<Checkbox
isDisabled={isMemberEditDisabled}
id="secret-modify"
className="h-5 w-5"
isChecked={field.value}
onCheckedChange={(isChecked) => field.onChange(isChecked)}
/>
</div>
)}
/>
<Controller
control={privilegeForm.control}
name="delete"
render={({ field }) => (
<div className="flex flex-col items-center">
<FormLabel label="Delete" className="mb-4" />
<Checkbox
isDisabled={isMemberEditDisabled}
id="secret-delete"
className="h-5 w-5"
isChecked={field.value}
onCheckedChange={(isChecked) => field.onChange(isChecked)}
/>
</div>
)}
/>
</div>
<div className="mt-7 flex items-center space-x-2">
<Popover>
<PopoverTrigger disabled={isMemberEditDisabled}>
<div>
<Tooltip content={getAccessLabel(true)}>
<Button
variant="outline_bg"
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
isDisabled={isMemberEditDisabled}
className={twMerge(
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{getAccessLabel()}
</Button>
</Tooltip>
</div>
</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={privilegeForm.control}
defaultValue="1h"
name="temporaryAccess.temporaryRange"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Validity" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Button
size="xs"
onClick={() => {
const temporaryRange = privilegeForm.getValues(
"temporaryAccess.temporaryRange"
);
if (!temporaryRange) {
privilegeForm.setError(
"temporaryAccess.temporaryRange",
{ type: "required", message: "Required" },
{ shouldFocus: true }
);
return;
}
privilegeForm.clearErrors("temporaryAccess.temporaryRange");
privilegeForm.setValue(
"temporaryAccess",
{
isTemporary: true,
temporaryAccessStartTime: new Date().toISOString(),
temporaryRange,
temporaryAccessEndTime: new Date(
new Date().getTime() + ms(temporaryRange)
).toISOString()
},
{ shouldDirty: true }
);
}}
>
{temporaryAccessField.isTemporary ? "Restart" : "Grant"}
</Button>
{temporaryAccessField.isTemporary && (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
privilegeForm.setValue("temporaryAccess", {
isTemporary: false
});
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
{privilegeForm.formState.isDirty ? (
<>
<Tooltip content="Cancel" className="mr-4">
<IconButton
variant="outline_bg"
className="border border-mineshaft-500 bg-mineshaft-600 py-2.5 hover:border-red/70 hover:bg-red/20"
ariaLabel="delete-privilege"
isDisabled={privilegeForm.formState.isSubmitting}
onClick={() => privilegeForm.reset()}
>
<FontAwesomeIcon icon={faArrowRotateLeft} className="py-0.5" />
</IconButton>
</Tooltip>
<Tooltip
content={isMemberEditDisabled ? "Access restricted" : "Save"}
className="mr-4"
>
<IconButton
isDisabled={isMemberEditDisabled}
className="border-none py-3"
ariaLabel="save-privilege"
type="submit"
>
{privilegeForm.formState.isSubmitting ? (
<Spinner size="xs" className="m-0 h-3 w-3 text-slate-500" />
) : (
<FontAwesomeIcon icon={faCheck} className="px-0.5" />
)}
</IconButton>
</Tooltip>
</>
) : (
<Tooltip
content={isMemberEditDisabled ? "Access restricted" : "Delete"}
className="mr-4"
>
<IconButton
isDisabled={isMemberEditDisabled}
variant="outline_bg"
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
ariaLabel="delete-privilege"
onClick={() => handlePopUpOpen("deletePrivilege")}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
)}
</div>
</div>
</form>
<DeleteActionModal
isOpen={popUp.deletePrivilege.isOpen}
title="Remove user additional privilege"
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
deleteKey="delete"
onClose={() => handlePopUpClose("deletePrivilege")}
onDeleteApproved={handleDeletePrivilege}
/>
</div>
);
};
type Props = {
identityId: string;
};
export const SpecificPrivilegeSection = ({ identityId }: Props) => {
const { currentWorkspace } = useWorkspace();
const projectSlug = currentWorkspace?.slug || "";
const { data: identityPrivileges, isLoading } = useListIdentityProjectPrivileges({
identityId,
projectSlug
});
const createIdentityPrivilege = useCreateIdentityProjectAdditionalPrivilege();
const handleCreatePrivilege = async () => {
if (createIdentityPrivilege.isLoading) return;
try {
await createIdentityPrivilege.mutateAsync({
permissions: [
{
action: ProjectPermissionActions.Read,
subject: [ProjectPermissionSub.Secrets],
conditions: {
environment: currentWorkspace?.environments?.[0].slug
}
}
],
identityId,
projectSlug
});
createNotification({
type: "success",
text: "Successfully created privilege"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to create privilege"
});
}
};
return (
<div className="mt-6 border-t border-t-mineshaft-600 pt-6">
<div className="flex items-center space-x-2 text-lg font-medium">
Additional Privileges
{isLoading && <Spinner size="xs" />}
</div>
<p className="mt-0.5 text-sm text-mineshaft-400">
Select individual privileges to associate with the identity.
</p>
<div>
{identityPrivileges
?.filter(({ permissions }) =>
permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets)
)
?.map((privilege) => (
<SpecificPrivilegeSecretForm
privilege={privilege as TProjectUserPrivilege}
identityId={identityId}
key={privilege?.id}
/>
))}
</div>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Identity}>
{(isAllowed) => (
<Button
variant="outline_bg"
className="mt-4"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={handleCreatePrivilege}
isLoading={createIdentityPrivilege.isLoading}
isDisabled={!isAllowed}
>
Add additional privilege
</Button>
)}
</ProjectPermissionCan>
</div>
);
};

View File

@ -0,0 +1 @@
export { IdentityRoleForm } from "./IdentityRoleForm";

View File

@ -1,459 +0,0 @@
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 { createNotification } from "@app/components/notifications";
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 { 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>
);
};

View File

@ -1,107 +0,0 @@
import Link from "next/link";
import { faArrowUpRightFromSquare, faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { useDeleteIdentityFromWorkspace } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { IdentityModal } from "./IdentityModal";
import { IdentityTable } from "./IdentityTable";
export const IdentitySection = withProjectPermission(
() => {
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id ?? "";
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"identity",
"deleteIdentity",
"upgradePlan"
] as const);
const onRemoveIdentitySubmit = async (identityId: string) => {
try {
await deleteMutateAsync({
identityId,
workspaceId
});
createNotification({
text: "Successfully removed identity from project",
type: "success"
});
handlePopUpClose("deleteIdentity");
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to remove identity from project";
createNotification({
text,
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Identities</p>
<div className="flex w-full justify-end pr-4">
<Link href="https://infisical.com/docs/documentation/platform/identities/overview">
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</Link>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("identity")}
isDisabled={!isAllowed}
>
Add identity
</Button>
)}
</ProjectPermissionCan>
</div>
<IdentityTable handlePopUpOpen={handlePopUpOpen} />
<IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen}
title={`Are you sure want to remove ${
(popUp?.deleteIdentity?.data as { name: string })?.name || ""
} from the project?`}
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveIdentitySubmit(
(popUp?.deleteIdentity?.data as { identityId: string })?.identityId
)
}
/>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity }
);

View File

@ -1,108 +0,0 @@
import { faServer, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
EmptyState,
IconButton,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { IdentityRoles } from "./IdentityRoles";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteIdentity", "identity"]>,
data?: {
identityId?: string;
name?: string;
}
) => void;
};
export const IdentityTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || "");
return (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Role</Th>
<Th>Added on</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={7} innerKey="project-identities" />}
{!isLoading &&
data &&
data.length > 0 &&
data.map(({ identity: { id, name }, roles, createdAt }) => {
return (
<Tr className="h-10" key={`st-v3-${id}`}>
<Td>{name}</Td>
<Td>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<IdentityRoles roles={roles} disableEdit={!isAllowed} identityId={id} />
)}
</ProjectPermissionCan>
</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td className="flex justify-end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<IconButton
onClick={() => {
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
{!isLoading && data && data?.length === 0 && (
<Tr>
<Td colSpan={7}>
<EmptyState title="No identities have been added to this project" icon={faServer} />
</Td>
</Tr>
)}
</TBody>
</Table>
</TableContainer>
);
};

View File

@ -1 +0,0 @@
export { IdentitySection } from "./IdentitySection";

View File

@ -1 +0,0 @@
export { IdentitySection } from "./IdentitySection";

View File

@ -2,9 +2,18 @@ import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import Link from "next/link";
import { faMagnifyingGlass, faPlus, faUsers, faXmark } from "@fortawesome/free-solid-svg-icons";
import {
faClock,
faEdit,
faMagnifyingGlass,
faPlus,
faUsers,
faXmark
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
@ -14,6 +23,9 @@ import {
DeleteActionModal,
EmptyState,
FormControl,
HoverCard,
HoverCardContent,
HoverCardTrigger,
IconButton,
Input,
Modal,
@ -23,10 +35,12 @@ import {
Table,
TableContainer,
TableSkeleton,
Tag,
TBody,
Td,
Th,
THead,
Tooltip,
Tr,
UpgradePlanModal
} from "@app/components/v2";
@ -46,9 +60,11 @@ import {
useGetUserWsKey,
useGetWorkspaceUsers
} from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { TWorkspaceUser } from "@app/hooks/api/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { MemberRoles } from "./MemberRoles";
import { MemberRoleForm } from "./MemberRoleForm";
const addMemberFormSchema = z.object({
orgMembershipId: z.string().trim()
@ -56,8 +72,15 @@ const addMemberFormSchema = z.object({
type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Member) return "Developer";
if (role === ProjectMembershipRole.NoAccess) return "No access";
return role;
};
export const MemberListTab = () => {
const { t } = useTranslation();
const { currentOrg } = useOrganization();
@ -77,7 +100,8 @@ export const MemberListTab = () => {
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addMember",
"removeMember",
"upgradePlan"
"upgradePlan",
"updateRole"
] as const);
const {
@ -186,7 +210,14 @@ export const MemberListTab = () => {
}, [orgUsers, members]);
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<motion.div
key="user-role-1"
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<div className="mb-4 flex items-center justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Members</p>
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Member}>
@ -223,7 +254,8 @@ export const MemberListTab = () => {
<TBody>
{isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />}
{!isMembersLoading &&
filterdUsers?.map(({ user: u, inviteEmail, id: membershipId, roles }) => {
filterdUsers?.map((projectMember, index) => {
const { user: u, inviteEmail, id: membershipId, roles } = projectMember;
const name = u ? `${u.firstName} ${u.lastName}` : "-";
const email = u?.email || inviteEmail;
@ -232,44 +264,136 @@ export const MemberListTab = () => {
<Td>{name}</Td>
<Td>{email}</Td>
<Td>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<MemberRoles
roles={roles}
disableEdit={u.id === user?.id || !isAllowed}
onOpenUpgradeModal={(description) =>
handlePopUpOpen("upgradePlan", { description })
<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 className="capitalize">
{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>
);
}
membershipId={membershipId}
/>
)}
{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>
)}
</ProjectPermissionCan>
{userId !== u?.id && (
<Tooltip content="Edit permission">
<IconButton
size="sm"
variant="plain"
ariaLabel="update-role"
onClick={() =>
handlePopUpOpen("updateRole", { ...projectMember, index })
}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</Tooltip>
)}
</div>
</Td>
<Td>
{userId !== u?.id && (
<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>
<div className="flex items-center space-x-2">
<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>
</div>
)}
</Td>
</Tr>
@ -343,6 +467,27 @@ export const MemberListTab = () => {
)}
</ModalContent>
</Modal>
<Modal
isOpen={popUp.updateRole.isOpen}
onOpenChange={(state) => handlePopUpToggle("updateRole", state)}
>
<ModalContent
className="max-w-4xl"
title={`Manage Access for ${(popUp.updateRole.data as TWorkspaceUser)?.user?.email}`}
subTitle={`
Configure role-based access control by assigning Infisical users a mix of roles and specific privileges. A user will gain access to all actions within the roles assigned to them, not just the actions those roles share in common. You must choose at least one permanent role.
`}
>
<MemberRoleForm
onOpenUpgradeModal={(description) => handlePopUpOpen("upgradePlan", { description })}
projectMember={
filterdUsers?.[
(popUp.updateRole?.data as TWorkspaceUser & { index: number })?.index
] as TWorkspaceUser
}
/>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.removeMember.isOpen}
deleteKey="remove"
@ -355,6 +500,6 @@ export const MemberListTab = () => {
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
</motion.div>
);
};

View File

@ -0,0 +1,350 @@
/* eslint-disable no-nested-ternary */
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faCaretDown, faClock, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { format, formatDistance } from "date-fns";
import ms from "ms";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
FormControl,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectItem,
Spinner,
Tag,
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription,
useWorkspace
} from "@app/context";
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";
const roleFormSchema = z.object({
roles: z
.object({
slug: z.string(),
temporaryAccess: z.discriminatedUnion("isTemporary", [
z.object({
isTemporary: z.literal(true),
temporaryRange: z.string().min(1),
temporaryAccessStartTime: z.string().datetime(),
temporaryAccessEndTime: z.string().datetime().nullable().optional()
}),
z.object({
isTemporary: z.literal(false)
})
])
})
.array()
});
type TRoleForm = z.infer<typeof roleFormSchema>;
type Props = {
projectMember: TWorkspaceUser;
onOpenUpgradeModal: (title: string) => void;
};
export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) => {
const { subscription } = useSubscription();
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
const { permission } = useProjectPermission();
const isMemberEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionSub.Member
);
const roleForm = useForm<TRoleForm>({
resolver: zodResolver(roleFormSchema),
values: {
roles: projectMember?.roles?.map(({ customRoleSlug, role, ...dto }) => ({
slug: customRoleSlug || role,
temporaryAccess: dto.isTemporary
? {
isTemporary: true,
temporaryRange: dto.temporaryRange,
temporaryAccessEndTime: dto.temporaryAccessEndTime,
temporaryAccessStartTime: dto.temporaryAccessStartTime
}
: {
isTemporary: dto.isTemporary
}
}))
}
});
const selectedRoleList = useFieldArray({
name: "roles",
control: roleForm.control
});
const formRoleField = roleForm.watch("roles");
const updateMembershipRole = useUpdateUserWorkspaceRole();
const handleRoleUpdate = async (data: TRoleForm) => {
if (updateMembershipRole.isLoading) return;
const sanitizedRoles = data.roles.map((el) => {
const { isTemporary } = el.temporaryAccess;
if (!isTemporary) {
return { role: el.slug, isTemporary: false as const };
}
return {
role: el.slug,
isTemporary: true as const,
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
temporaryRange: el.temporaryAccess.temporaryRange,
temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime
};
});
const hasCustomRoleSelected = sanitizedRoles.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: projectMember.id,
roles: sanitizedRoles
});
createNotification({ text: "Successfully updated roles", type: "success" });
roleForm.reset(undefined, { keepValues: true });
} catch (err) {
createNotification({ text: "Failed to update role", type: "error" });
}
};
if (isRolesLoading)
return (
<div className="flex w-full items-center justify-center p-8">
<Spinner />
</div>
);
return (
<div>
<div className="text-lg font-medium">Roles</div>
<p className="text-sm text-mineshaft-400">Select one of the pre-defined or custom roles.</p>
<div>
<form onSubmit={roleForm.handleSubmit(handleRoleUpdate)}>
<div className="mt-2 flex flex-col space-y-2">
{selectedRoleList.fields.map(({ id }, index) => {
const { temporaryAccess } = formRoleField[index];
const isTemporary = temporaryAccess?.isTemporary;
const isExpired =
temporaryAccess.isTemporary &&
new Date() > new Date(temporaryAccess.temporaryAccessEndTime || "");
return (
<div key={id} className="flex items-center space-x-2">
<Controller
control={roleForm.control}
name={`roles.${index}.slug`}
render={({ field: { onChange, ...field } }) => (
<Select
defaultValue={field.value}
{...field}
isDisabled={isMemberEditDisabled}
onValueChange={(e) => onChange(e)}
className="w-full bg-mineshaft-600 duration-200 hover:bg-mineshaft-500"
>
{projectRoles?.map(({ name, slug, id: projectRoleId }) => (
<SelectItem value={slug} key={projectRoleId}>
{name}
</SelectItem>
))}
</Select>
)}
/>
<Popover>
<PopoverTrigger disabled={isMemberEditDisabled} asChild>
<div>
<Tooltip
content={
temporaryAccess?.isTemporary
? isExpired
? "Timed Access Expired"
: `Until ${format(
new Date(temporaryAccess.temporaryAccessEndTime || ""),
"yyyy-MM-dd HH:mm:ss"
)}`
: "Non expiry access"
}
>
<Button
variant="outline_bg"
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
isDisabled={isMemberEditDisabled}
className={twMerge(
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{temporaryAccess?.isTemporary
? isExpired
? "Access Expired"
: formatDistance(
new Date(temporaryAccess.temporaryAccessEndTime || ""),
new Date()
)
: "Permanent"}
</Button>
</Tooltip>
</div>
</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={roleForm.control}
defaultValue="1h"
name={`roles.${index}.temporaryAccess.temporaryRange`}
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Validity" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Button
size="xs"
onClick={() => {
const temporaryRange = roleForm.getValues(
`roles.${index}.temporaryAccess.temporaryRange`
);
if (!temporaryRange) {
roleForm.setError(
`roles.${index}.temporaryAccess.temporaryRange`,
{ type: "required", message: "Required" },
{ shouldFocus: true }
);
return;
}
roleForm.clearErrors(`roles.${index}.temporaryAccess.temporaryRange`);
roleForm.setValue(
`roles.${index}.temporaryAccess`,
{
isTemporary: true,
temporaryAccessStartTime: new Date().toISOString(),
temporaryRange,
temporaryAccessEndTime: new Date(
new Date().getTime() + ms(temporaryRange)
).toISOString()
},
{ shouldDirty: true }
);
}}
>
{temporaryAccess.isTemporary ? "Restart" : "Grant"}
</Button>
{temporaryAccess.isTemporary && (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
roleForm.setValue(`roles.${index}.temporaryAccess`, {
isTemporary: false
});
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
<IconButton
variant="outline_bg"
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
ariaLabel="delete-role"
isDisabled={isMemberEditDisabled}
onClick={() => {
if (selectedRoleList.fields.length > 1) {
selectedRoleList.remove(index);
}
}}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</div>
);
})}
</div>
<div className="mt-4 flex justify-between space-x-2">
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Member}>
{(isAllowed) => (
<Button
variant="outline_bg"
isDisabled={!isAllowed}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() =>
selectedRoleList.append({
slug: ProjectMembershipRole.Member,
temporaryAccess: { isTemporary: false }
})
}
>
Add Role
</Button>
)}
</ProjectPermissionCan>
<Button
type="submit"
className={twMerge(
"transition-all",
"opacity-0",
roleForm.formState.isDirty && "opacity-100"
)}
isLoading={roleForm.formState.isSubmitting}
>
Save Roles
</Button>
</div>
</form>
</div>
</div>
);
};

View File

@ -0,0 +1,20 @@
import { TWorkspaceUser } from "@app/hooks/api/types";
import { MemberRbacSection } from "./MemberRbacSection";
import { SpecificPrivilegeSection } from "./SpecificPrivilegeSection";
type Props = {
projectMember: TWorkspaceUser;
onOpenUpgradeModal: (title: string) => void;
};
export const MemberRoleForm = ({ projectMember, onOpenUpgradeModal }: Props) => {
return (
<div>
<MemberRbacSection
projectMember={projectMember}
onOpenUpgradeModal={onOpenUpgradeModal}
/>
<SpecificPrivilegeSection membershipId={projectMember?.id} />
</div>
);
};

View File

@ -0,0 +1,514 @@
import { Controller, useForm } from "react-hook-form";
import {
faArrowRotateLeft,
faCaretDown,
faCheck,
faClock,
faPlus,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { format, formatDistance } from "date-fns";
import ms from "ms";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
Checkbox,
DeleteActionModal,
FormControl,
FormLabel,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectItem,
Spinner,
Tag,
Tooltip
} from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import {
TProjectUserPrivilege,
useCreateProjectUserAdditionalPrivilege,
useDeleteProjectUserAdditionalPrivilege,
useListProjectUserPrivileges,
useUpdateProjectUserAdditionalPrivilege
} from "@app/hooks/api";
const secretPermissionSchema = z.object({
secretPath: z.string().optional(),
environmentSlug: z.string(),
[ProjectPermissionActions.Edit]: z.boolean().optional(),
[ProjectPermissionActions.Read]: z.boolean().optional(),
[ProjectPermissionActions.Create]: z.boolean().optional(),
[ProjectPermissionActions.Delete]: z.boolean().optional(),
temporaryAccess: z.discriminatedUnion("isTemporary", [
z.object({
isTemporary: z.literal(true),
temporaryRange: z.string().min(1),
temporaryAccessStartTime: z.string().datetime(),
temporaryAccessEndTime: z.string().datetime().nullable().optional()
}),
z.object({
isTemporary: z.literal(false)
})
])
});
type TSecretPermissionForm = z.infer<typeof secretPermissionSchema>;
const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPrivilege }) => {
const { currentWorkspace } = useWorkspace();
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
"deletePrivilege"
] as const);
const { permission } = useProjectPermission();
const isMemberEditDisabled = permission.cannot(
ProjectPermissionActions.Edit,
ProjectPermissionSub.Member
);
const updateUserPrivilege = useUpdateProjectUserAdditionalPrivilege();
const deleteUserPrivilege = useDeleteProjectUserAdditionalPrivilege();
const privilegeForm = useForm<TSecretPermissionForm>({
resolver: zodResolver(secretPermissionSchema),
values: {
environmentSlug: privilege.permissions?.[0]?.conditions?.environment,
// secret path will be inside $glob operator
secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "",
read: privilege.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Read)
),
edit: privilege.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Edit)
),
create: privilege.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Create)
),
delete: privilege.permissions?.some(({ action }) =>
action.includes(ProjectPermissionActions.Delete)
),
// zod will pick it
temporaryAccess: privilege
}
});
const temporaryAccessField = privilegeForm.watch("temporaryAccess");
const isTemporary = temporaryAccessField?.isTemporary;
const isExpired =
temporaryAccessField.isTemporary &&
new Date() > new Date(temporaryAccessField.temporaryAccessEndTime || "");
const handleUpdatePrivilege = async (data: TSecretPermissionForm) => {
if (updateUserPrivilege.isLoading) return;
try {
const actions = [
{ action: ProjectPermissionActions.Read, allowed: data.read },
{ action: ProjectPermissionActions.Create, allowed: data.create },
{ action: ProjectPermissionActions.Delete, allowed: data.delete },
{ action: ProjectPermissionActions.Edit, allowed: data.edit }
];
const conditions: Record<string, any> = { environment: data.environmentSlug };
if (data.secretPath) {
conditions.secretPath = { $glob: data.secretPath };
}
await updateUserPrivilege.mutateAsync({
privilegeId: privilege.id,
...data.temporaryAccess,
permissions: actions
.filter(({ allowed }) => allowed)
.map(({ action }) => ({
action,
subject: [ProjectPermissionSub.Secrets],
conditions
})),
projectMembershipId: privilege.projectMembershipId
});
createNotification({
type: "success",
text: "Successfully updated privilege"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to update privilege"
});
}
};
const handleDeletePrivilege = async () => {
if (deleteUserPrivilege.isLoading) return;
try {
await deleteUserPrivilege.mutateAsync({
privilegeId: privilege.id,
projectMembershipId: privilege.projectMembershipId
});
createNotification({
type: "success",
text: "Successfully deleted privilege"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to delete privilege"
});
}
};
const getAccessLabel = (exactTime = false) => {
if (isExpired) return "Access expired";
if (!temporaryAccessField?.isTemporary) return "Permanent";
if (exactTime)
return `Until ${format(
new Date(temporaryAccessField.temporaryAccessEndTime || ""),
"yyyy-MM-dd HH:mm:ss"
)}`;
return formatDistance(new Date(temporaryAccessField.temporaryAccessEndTime || ""), new Date());
};
return (
<div className="mt-4">
<form onSubmit={privilegeForm.handleSubmit(handleUpdatePrivilege)}>
<div className="flex items-start space-x-4">
<Controller
control={privilegeForm.control}
name="environmentSlug"
render={({ field: { onChange, ...field } }) => (
<FormControl label="Env">
<Select
{...field}
isDisabled={isMemberEditDisabled}
className="bg-mineshaft-600 hover:bg-mineshaft-500"
onValueChange={(e) => onChange(e)}
>
{currentWorkspace?.environments?.map(({ slug, id }) => (
<SelectItem value={slug} key={id}>
{slug}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={privilegeForm.control}
name="secretPath"
render={({ field }) => (
<FormControl label="Secret Path">
<Input {...field} isDisabled={isMemberEditDisabled} className="w-48" />
</FormControl>
)}
/>
<div className="flex flex-grow justify-between">
<Controller
control={privilegeForm.control}
name="read"
render={({ field }) => (
<div className="flex flex-col items-center">
<FormLabel label="View" className="mb-4" />
<Checkbox
isDisabled={isMemberEditDisabled}
id="secret-read"
className="h-5 w-5"
isChecked={field.value}
onCheckedChange={(isChecked) => field.onChange(isChecked)}
/>
</div>
)}
/>
<Controller
control={privilegeForm.control}
name="create"
render={({ field }) => (
<div className="flex flex-col items-center">
<FormLabel label="Create" className="mb-4" />
<Checkbox
isDisabled={isMemberEditDisabled}
id="secret-create"
className="h-5 w-5"
isChecked={field.value}
onCheckedChange={(isChecked) => field.onChange(isChecked)}
/>
</div>
)}
/>
<Controller
control={privilegeForm.control}
name="edit"
render={({ field }) => (
<div className="flex flex-col items-center">
<FormLabel label="Modify" className="mb-4" />
<Checkbox
isDisabled={isMemberEditDisabled}
id="secret-modify"
className="h-5 w-5"
isChecked={field.value}
onCheckedChange={(isChecked) => field.onChange(isChecked)}
/>
</div>
)}
/>
<Controller
control={privilegeForm.control}
name="delete"
render={({ field }) => (
<div className="flex flex-col items-center">
<FormLabel label="Delete" className="mb-4" />
<Checkbox
isDisabled={isMemberEditDisabled}
id="secret-delete"
className="h-5 w-5"
isChecked={field.value}
onCheckedChange={(isChecked) => field.onChange(isChecked)}
/>
</div>
)}
/>
</div>
<div className="mt-7 flex items-center space-x-2">
<Popover>
<PopoverTrigger disabled={isMemberEditDisabled}>
<div>
<Tooltip content={getAccessLabel(true)}>
<Button
variant="outline_bg"
leftIcon={isTemporary ? <FontAwesomeIcon icon={faClock} /> : undefined}
rightIcon={<FontAwesomeIcon icon={faCaretDown} className="ml-2" />}
isDisabled={isMemberEditDisabled}
className={twMerge(
"border-none bg-mineshaft-600 py-2.5 text-xs capitalize hover:bg-mineshaft-500",
isTemporary && "text-primary",
isExpired && "text-red-600"
)}
>
{getAccessLabel()}
</Button>
</Tooltip>
</div>
</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={privilegeForm.control}
defaultValue="1h"
name="temporaryAccess.temporaryRange"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Validity" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
<Button
size="xs"
onClick={() => {
const temporaryRange = privilegeForm.getValues(
"temporaryAccess.temporaryRange"
);
if (!temporaryRange) {
privilegeForm.setError(
"temporaryAccess.temporaryRange",
{ type: "required", message: "Required" },
{ shouldFocus: true }
);
return;
}
privilegeForm.clearErrors("temporaryAccess.temporaryRange");
privilegeForm.setValue(
"temporaryAccess",
{
isTemporary: true,
temporaryAccessStartTime: new Date().toISOString(),
temporaryRange,
temporaryAccessEndTime: new Date(
new Date().getTime() + ms(temporaryRange)
).toISOString()
},
{ shouldDirty: true }
);
}}
>
{temporaryAccessField.isTemporary ? "Restart" : "Grant"}
</Button>
{temporaryAccessField.isTemporary && (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
privilegeForm.setValue("temporaryAccess", {
isTemporary: false
});
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
{privilegeForm.formState.isDirty ? (
<>
<Tooltip content="Cancel" className="mr-4">
<IconButton
variant="outline_bg"
className="border border-mineshaft-500 bg-mineshaft-600 py-2.5 hover:border-red/70 hover:bg-red/20"
ariaLabel="delete-privilege"
isDisabled={privilegeForm.formState.isSubmitting}
onClick={() => privilegeForm.reset()}
>
<FontAwesomeIcon icon={faArrowRotateLeft} className="py-0.5" />
</IconButton>
</Tooltip>
<Tooltip
content={isMemberEditDisabled ? "Access restricted" : "Save"}
className="mr-4"
>
<IconButton
isDisabled={isMemberEditDisabled}
className="border-none py-3"
ariaLabel="save-privilege"
type="submit"
>
{privilegeForm.formState.isSubmitting ? (
<Spinner size="xs" className="m-0 h-3 w-3 text-slate-500" />
) : (
<FontAwesomeIcon icon={faCheck} className="px-0.5" />
)}
</IconButton>
</Tooltip>
</>
) : (
<Tooltip
content={isMemberEditDisabled ? "Access restricted" : "Delete"}
className="mr-4"
>
<IconButton
isDisabled={isMemberEditDisabled}
variant="outline_bg"
className="border border-mineshaft-500 bg-mineshaft-600 py-3 hover:border-red/70 hover:bg-red/20"
ariaLabel="delete-privilege"
onClick={() => handlePopUpOpen("deletePrivilege")}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
)}
</div>
</div>
</form>
<DeleteActionModal
isOpen={popUp.deletePrivilege.isOpen}
title="Remove user additional privilege"
onChange={(isOpen) => handlePopUpToggle("deletePrivilege", isOpen)}
deleteKey="delete"
onClose={() => handlePopUpClose("deletePrivilege")}
onDeleteApproved={handleDeletePrivilege}
/>
</div>
);
};
type Props = {
membershipId: string;
};
export const SpecificPrivilegeSection = ({ membershipId }: Props) => {
const { data: userPrivileges, isLoading } = useListProjectUserPrivileges(membershipId);
const { currentWorkspace } = useWorkspace();
const createUserPrivilege = useCreateProjectUserAdditionalPrivilege();
const handleCreatePrivilege = async () => {
if (createUserPrivilege.isLoading) return;
try {
await createUserPrivilege.mutateAsync({
permissions: [
{
action: ProjectPermissionActions.Read,
subject: [ProjectPermissionSub.Secrets],
conditions: {
environment: currentWorkspace?.environments?.[0].slug
}
}
],
projectMembershipId: membershipId
});
createNotification({
type: "success",
text: "Successfully created privilege"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to create privilege"
});
}
};
return (
<div className="mt-6 border-t border-t-mineshaft-600 pt-6">
<div className="flex items-center space-x-2 text-lg font-medium">
Additional Privileges
{isLoading && <Spinner size="xs" />}
</div>
<p className="mt-0.5 text-sm text-mineshaft-400">
Select individual privileges to associate with the user.
</p>
<div>
{userPrivileges
?.filter(({ permissions }) =>
permissions?.[0]?.subject?.includes(ProjectPermissionSub.Secrets)
)
?.map((privilege) => (
<SpecificPrivilegeSecretForm
privilege={privilege as TProjectUserPrivilege}
key={privilege?.id}
/>
))}
</div>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Member}>
{(isAllowed) => (
<Button
variant="outline_bg"
className="mt-4"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={handleCreatePrivilege}
isLoading={createUserPrivilege.isLoading}
isDisabled={!isAllowed}
>
Add additional privilege
</Button>
)}
</ProjectPermissionCan>
</div>
);
};

View File

@ -0,0 +1 @@
export { MemberRoleForm } from "./MemberRoleForm";

View File

@ -1,471 +0,0 @@
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 { createNotification } from "@app/components/notifications";
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 { 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>
);
};

View File

@ -1,5 +1,5 @@
import { useMemo } from "react";
import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form";
import { Control, Controller, UseFormGetValues, UseFormSetValue, useWatch } from "react-hook-form";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { motion } from "framer-motion";
@ -28,6 +28,7 @@ type Props = {
formName: "secrets";
isNonEditable?: boolean;
setValue: UseFormSetValue<TFormSchema>;
getValue: UseFormGetValues<TFormSchema>;
control: Control<TFormSchema>;
title: string;
subtitle: string;
@ -44,6 +45,7 @@ enum Permission {
export const MultiEnvProjectPermission = ({
isNonEditable,
setValue,
getValue,
control,
formName,
title,
@ -69,9 +71,12 @@ export const MultiEnvProjectPermission = ({
const handlePermissionChange = (val: Permission) => {
switch (val) {
case Permission.NoAccess:
setValue(`permissions.${formName}`, undefined, { shouldDirty: true });
case Permission.NoAccess: {
const permissions = getValue("permissions");
if (permissions) delete permissions[formName];
setValue("permissions", permissions, { shouldDirty: true });
break;
}
case Permission.FullAccess:
setValue(
`permissions.${formName}`,
@ -101,7 +106,7 @@ export const MultiEnvProjectPermission = ({
className={twMerge(
"rounded-md bg-mineshaft-800 px-10 py-6",
(selectedPermissionCategory !== Permission.NoAccess || isCustom) &&
"border-l-2 border-primary-600"
"border-l-2 border-primary-600"
)}
>
<div className="flex items-center space-x-4">

View File

@ -128,6 +128,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => {
register,
formState: { isSubmitting, isDirty, errors },
setValue,
getValues,
control
} = useForm<TFormSchema>({
defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {},
@ -226,6 +227,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => {
</div>
<div>
<MultiEnvProjectPermission
getValue={getValues}
isNonEditable={isNonEditable}
control={control}
setValue={setValue}

View File

@ -31,6 +31,7 @@ export const formSchema = z.object({
slug: z
.string()
.trim()
.toLowerCase()
.refine((val) => val !== "custom", { message: "Cannot use custom as its a keyword" }),
permissions: z
.object({

View File

@ -416,7 +416,7 @@ export const ActionBar = ({
{Object.keys(selectedSecrets).length} Selected
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
renderTooltip
allowedLabel="Delete"

View File

@ -119,9 +119,12 @@ export const AdminDashboardPage = () => {
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
onSubmit={handleSubmit(onFormSubmit)}
>
<div className="flex justify-between">
<div className="mb-4 text-xl font-semibold text-mineshaft-100">
Allow user sign up
<div className="flex flex-col justify-start">
<div className="mb-2 text-xl font-semibold text-mineshaft-100">
Allow user signups
</div>
<div className="mb-4 text-sm max-w-sm text-mineshaft-400">
Select if you want users to be able to signup freely into your Infisical instance.
</div>
<Controller
control={control}
@ -147,9 +150,9 @@ export const AdminDashboardPage = () => {
/>
</div>
{signupMode === "anyone" && (
<div className="mt-4 flex items-center justify-between">
<div className="mb-4 flex text-mineshaft-100">
Restrict sign up by email domain(s)
<div className="mt-8 mb-8 flex flex-col justify-start">
<div className="mb-4 text-xl font-semibold text-mineshaft-100">
Restrict signup by email domain(s)
</div>
<Controller
control={control}