Fix merge conflicts

This commit is contained in:
Tuan Dang
2024-03-12 16:20:17 -07:00
81 changed files with 3330 additions and 915 deletions
+696 -171
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -70,6 +70,7 @@
"vitest": "^1.2.2" "vitest": "^1.2.2"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-iam": "^3.525.0",
"@aws-sdk/client-secrets-manager": "^3.504.0", "@aws-sdk/client-secrets-manager": "^3.504.0",
"@casl/ability": "^6.5.0", "@casl/ability": "^6.5.0",
"@fastify/cookie": "^9.3.1", "@fastify/cookie": "^9.3.1",
@@ -106,6 +107,7 @@
"knex": "^3.0.1", "knex": "^3.0.1",
"libsodium-wrappers": "^0.7.13", "libsodium-wrappers": "^0.7.13",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"ms": "^2.1.3",
"mysql2": "^3.9.1", "mysql2": "^3.9.1",
"nanoid": "^5.0.4", "nanoid": "^5.0.4",
"nodemailer": "^6.9.9", "nodemailer": "^6.9.9",
@@ -115,6 +117,7 @@
"passport-google-oauth20": "^2.0.0", "passport-google-oauth20": "^2.0.0",
"passport-ldapauth": "^3.0.1", "passport-ldapauth": "^3.0.1",
"pg": "^8.11.3", "pg": "^8.11.3",
"pg-query-stream": "^4.5.3",
"picomatch": "^3.0.1", "picomatch": "^3.0.1",
"pino": "^8.16.2", "pino": "^8.16.2",
"posthog-node": "^3.6.2", "posthog-node": "^3.6.2",
+16
View File
@@ -32,6 +32,9 @@ import {
TIdentityOrgMemberships, TIdentityOrgMemberships,
TIdentityOrgMembershipsInsert, TIdentityOrgMembershipsInsert,
TIdentityOrgMembershipsUpdate, TIdentityOrgMembershipsUpdate,
TIdentityProjectMembershipRole,
TIdentityProjectMembershipRoleInsert,
TIdentityProjectMembershipRoleUpdate,
TIdentityProjectMemberships, TIdentityProjectMemberships,
TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsInsert,
TIdentityProjectMembershipsUpdate, TIdentityProjectMembershipsUpdate,
@@ -83,6 +86,9 @@ import {
TProjects, TProjects,
TProjectsInsert, TProjectsInsert,
TProjectsUpdate, TProjectsUpdate,
TProjectUserMembershipRoles,
TProjectUserMembershipRolesInsert,
TProjectUserMembershipRolesUpdate,
TSamlConfigs, TSamlConfigs,
TSamlConfigsInsert, TSamlConfigsInsert,
TSamlConfigsUpdate, TSamlConfigsUpdate,
@@ -221,6 +227,11 @@ declare module "knex/types/tables" {
TProjectEnvironmentsUpdate TProjectEnvironmentsUpdate
>; >;
[TableName.ProjectBot]: Knex.CompositeTableType<TProjectBots, TProjectBotsInsert, TProjectBotsUpdate>; [TableName.ProjectBot]: Knex.CompositeTableType<TProjectBots, TProjectBotsInsert, TProjectBotsUpdate>;
[TableName.ProjectUserMembershipRole]: Knex.CompositeTableType<
TProjectUserMembershipRoles,
TProjectUserMembershipRolesInsert,
TProjectUserMembershipRolesUpdate
>;
[TableName.ProjectRoles]: Knex.CompositeTableType<TProjectRoles, TProjectRolesInsert, TProjectRolesUpdate>; [TableName.ProjectRoles]: Knex.CompositeTableType<TProjectRoles, TProjectRolesInsert, TProjectRolesUpdate>;
[TableName.ProjectKeys]: Knex.CompositeTableType<TProjectKeys, TProjectKeysInsert, TProjectKeysUpdate>; [TableName.ProjectKeys]: Knex.CompositeTableType<TProjectKeys, TProjectKeysInsert, TProjectKeysUpdate>;
[TableName.Secret]: Knex.CompositeTableType<TSecrets, TSecretsInsert, TSecretsUpdate>; [TableName.Secret]: Knex.CompositeTableType<TSecrets, TSecretsInsert, TSecretsUpdate>;
@@ -272,6 +283,11 @@ declare module "knex/types/tables" {
TIdentityProjectMembershipsInsert, TIdentityProjectMembershipsInsert,
TIdentityProjectMembershipsUpdate TIdentityProjectMembershipsUpdate
>; >;
[TableName.IdentityProjectMembershipRole]: Knex.CompositeTableType<
TIdentityProjectMembershipRole,
TIdentityProjectMembershipRoleInsert,
TIdentityProjectMembershipRoleUpdate
>;
[TableName.ScimToken]: Knex.CompositeTableType<TScimTokens, TScimTokensInsert, TScimTokensUpdate>; [TableName.ScimToken]: Knex.CompositeTableType<TScimTokens, TScimTokensInsert, TScimTokensUpdate>;
[TableName.SecretApprovalPolicy]: Knex.CompositeTableType< [TableName.SecretApprovalPolicy]: Knex.CompositeTableType<
TSecretApprovalPolicies, TSecretApprovalPolicies,
@@ -0,0 +1,50 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
const doesTableExist = await knex.schema.hasTable(TableName.ProjectUserMembershipRole);
if (!doesTableExist) {
await knex.schema.createTable(TableName.ProjectUserMembershipRole, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("role").notNullable();
t.uuid("projectMembershipId").notNullable();
t.foreign("projectMembershipId").references("id").inTable(TableName.ProjectMembership).onDelete("CASCADE");
// until role is changed/removed the role should not deleted
t.uuid("customRoleId");
t.foreign("customRoleId").references("id").inTable(TableName.ProjectRoles);
t.boolean("isTemporary").notNullable().defaultTo(false);
t.string("temporaryMode");
t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc
t.datetime("temporaryAccessStartTime");
t.datetime("temporaryAccessEndTime");
t.timestamps(true, true, true);
});
}
await createOnUpdateTrigger(knex, TableName.ProjectUserMembershipRole);
const projectMemberships = await knex(TableName.ProjectMembership).select(
"id",
"role",
"createdAt",
"updatedAt",
knex.ref("roleId").withSchema(TableName.ProjectMembership).as("customRoleId")
);
if (projectMemberships.length)
await knex.batchInsert(
TableName.ProjectUserMembershipRole,
projectMemberships.map((data) => ({ ...data, projectMembershipId: data.id }))
);
// will be dropped later
// await knex.schema.alterTable(TableName.ProjectMembership, (t) => {
// t.dropColumn("roleId");
// t.dropColumn("role");
// });
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.ProjectUserMembershipRole);
await dropOnUpdateTrigger(knex, TableName.ProjectUserMembershipRole);
}
@@ -0,0 +1,52 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
const doesTableExist = await knex.schema.hasTable(TableName.IdentityProjectMembershipRole);
if (!doesTableExist) {
await knex.schema.createTable(TableName.IdentityProjectMembershipRole, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("role").notNullable();
t.uuid("projectMembershipId").notNullable();
t.foreign("projectMembershipId")
.references("id")
.inTable(TableName.IdentityProjectMembership)
.onDelete("CASCADE");
// until role is changed/removed the role should not deleted
t.uuid("customRoleId");
t.foreign("customRoleId").references("id").inTable(TableName.ProjectRoles);
t.boolean("isTemporary").notNullable().defaultTo(false);
t.string("temporaryMode");
t.string("temporaryRange"); // could be cron or relative time like 1H or 1minute etc
t.datetime("temporaryAccessStartTime");
t.datetime("temporaryAccessEndTime");
t.timestamps(true, true, true);
});
}
await createOnUpdateTrigger(knex, TableName.IdentityProjectMembershipRole);
const identityMemberships = await knex(TableName.IdentityProjectMembership).select(
"id",
"role",
"createdAt",
"updatedAt",
knex.ref("roleId").withSchema(TableName.IdentityProjectMembership).as("customRoleId")
);
if (identityMemberships.length)
await knex.batchInsert(
TableName.IdentityProjectMembershipRole,
identityMemberships.map((data) => ({ ...data, projectMembershipId: data.id }))
);
// await knex.schema.alterTable(TableName.IdentityProjectMembership, (t) => {
// t.dropColumn("roleId");
// t.dropColumn("role");
// });
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.IdentityProjectMembershipRole);
await dropOnUpdateTrigger(knex, TableName.IdentityProjectMembershipRole);
}
@@ -0,0 +1,31 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const IdentityProjectMembershipRoleSchema = z.object({
id: z.string().uuid(),
role: z.string(),
projectMembershipId: z.string().uuid(),
customRoleId: z.string().uuid().nullable().optional(),
isTemporary: z.boolean().default(false),
temporaryMode: z.string().nullable().optional(),
temporaryRange: z.string().nullable().optional(),
temporaryAccessStartTime: z.date().nullable().optional(),
temporaryAccessEndTime: z.date().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TIdentityProjectMembershipRole = z.infer<typeof IdentityProjectMembershipRoleSchema>;
export type TIdentityProjectMembershipRoleInsert = Omit<
z.input<typeof IdentityProjectMembershipRoleSchema>,
TImmutableDBKeys
>;
export type TIdentityProjectMembershipRoleUpdate = Partial<
Omit<z.input<typeof IdentityProjectMembershipRoleSchema>, TImmutableDBKeys>
>;
+2
View File
@@ -8,6 +8,7 @@ export * from "./git-app-org";
export * from "./identities"; export * from "./identities";
export * from "./identity-access-tokens"; export * from "./identity-access-tokens";
export * from "./identity-org-memberships"; export * from "./identity-org-memberships";
export * from "./identity-project-membership-role";
export * from "./identity-project-memberships"; export * from "./identity-project-memberships";
export * from "./identity-ua-client-secrets"; export * from "./identity-ua-client-secrets";
export * from "./identity-universal-auths"; export * from "./identity-universal-auths";
@@ -25,6 +26,7 @@ export * from "./project-environments";
export * from "./project-keys"; export * from "./project-keys";
export * from "./project-memberships"; export * from "./project-memberships";
export * from "./project-roles"; export * from "./project-roles";
export * from "./project-user-membership-roles";
export * from "./projects"; export * from "./projects";
export * from "./saml-configs"; export * from "./saml-configs";
export * from "./scim-tokens"; export * from "./scim-tokens";
+2
View File
@@ -20,6 +20,7 @@ export enum TableName {
Environment = "project_environments", Environment = "project_environments",
ProjectMembership = "project_memberships", ProjectMembership = "project_memberships",
ProjectRoles = "project_roles", ProjectRoles = "project_roles",
ProjectUserMembershipRole = "project_user_membership_roles",
ProjectKeys = "project_keys", ProjectKeys = "project_keys",
Secret = "secrets", Secret = "secrets",
SecretBlindIndex = "secret_blind_indexes", SecretBlindIndex = "secret_blind_indexes",
@@ -41,6 +42,7 @@ export enum TableName {
IdentityUaClientSecret = "identity_ua_client_secrets", IdentityUaClientSecret = "identity_ua_client_secrets",
IdentityOrgMembership = "identity_org_memberships", IdentityOrgMembership = "identity_org_memberships",
IdentityProjectMembership = "identity_project_memberships", IdentityProjectMembership = "identity_project_memberships",
IdentityProjectMembershipRole = "identity_project_membership_role",
ScimToken = "scim_tokens", ScimToken = "scim_tokens",
SecretApprovalPolicy = "secret_approval_policies", SecretApprovalPolicy = "secret_approval_policies",
SecretApprovalPolicyApprover = "secret_approval_policies_approvers", SecretApprovalPolicyApprover = "secret_approval_policies_approvers",
@@ -0,0 +1,31 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const ProjectUserMembershipRolesSchema = z.object({
id: z.string().uuid(),
role: z.string(),
projectMembershipId: z.string().uuid(),
customRoleId: z.string().uuid().nullable().optional(),
isTemporary: z.boolean().default(false),
temporaryMode: z.string().nullable().optional(),
temporaryRange: z.string().nullable().optional(),
temporaryAccessStartTime: z.date().nullable().optional(),
temporaryAccessEndTime: z.date().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TProjectUserMembershipRoles = z.infer<typeof ProjectUserMembershipRolesSchema>;
export type TProjectUserMembershipRolesInsert = Omit<
z.input<typeof ProjectUserMembershipRolesSchema>,
TImmutableDBKeys
>;
export type TProjectUserMembershipRolesUpdate = Partial<
Omit<z.input<typeof ProjectUserMembershipRolesSchema>, TImmutableDBKeys>
>;
+11 -5
View File
@@ -4,7 +4,7 @@ import { Knex } from "knex";
import { encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { encryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
import { OrgMembershipRole, SecretEncryptionAlgo, SecretKeyEncoding, TableName } from "../schemas"; import { ProjectMembershipRole, SecretEncryptionAlgo, SecretKeyEncoding, TableName } from "../schemas";
import { buildUserProjectKey, getUserPrivateKey, seedData1 } from "../seed-data"; import { buildUserProjectKey, getUserPrivateKey, seedData1 } from "../seed-data";
export const DEFAULT_PROJECT_ENVS = [ export const DEFAULT_PROJECT_ENVS = [
@@ -30,10 +30,16 @@ export async function seed(knex: Knex): Promise<void> {
}) })
.returning("*"); .returning("*");
await knex(TableName.ProjectMembership).insert({ const projectMembership = await knex(TableName.ProjectMembership)
projectId: project.id, .insert({
role: OrgMembershipRole.Admin, projectId: project.id,
userId: seedData1.id userId: seedData1.id,
role: ProjectMembershipRole.Admin
})
.returning("*");
await knex(TableName.ProjectUserMembershipRole).insert({
role: ProjectMembershipRole.Admin,
projectMembershipId: projectMembership[0].id
}); });
const user = await knex(TableName.UserEncryptionKey).where({ userId: seedData1.id }).first(); const user = await knex(TableName.UserEncryptionKey).where({ userId: seedData1.id }).first();
+10 -3
View File
@@ -75,9 +75,16 @@ export async function seed(knex: Knex): Promise<void> {
} }
]); ]);
await knex(TableName.IdentityProjectMembership).insert({ const identityProjectMembership = await knex(TableName.IdentityProjectMembership)
identityId: seedData1.machineIdentity.id, .insert({
identityId: seedData1.machineIdentity.id,
projectId: seedData1.project.id,
role: ProjectMembershipRole.Admin
})
.returning("*");
await knex(TableName.IdentityProjectMembershipRole).insert({
role: ProjectMembershipRole.Admin, role: ProjectMembershipRole.Admin,
projectId: seedData1.project.id projectMembershipId: identityProjectMembership[0].id
}); });
} }
+24 -3
View File
@@ -1,6 +1,7 @@
import slugify from "@sindresorhus/slugify";
import { z } from "zod"; import { z } from "zod";
import { OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas"; import { OrgMembershipRole, OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
@@ -13,7 +14,17 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
organizationId: z.string().trim() organizationId: z.string().trim()
}), }),
body: z.object({ body: z.object({
slug: z.string().trim(), slug: z
.string()
.min(1)
.trim()
.refine(
(val) => Object.keys(OrgMembershipRole).includes(val),
"Please choose a different slug, the slug you have entered is reserved"
)
.refine((v) => slugify(v) === v, {
message: "Slug must be a valid"
}),
name: z.string().trim(), name: z.string().trim(),
description: z.string().trim().optional(), description: z.string().trim().optional(),
permissions: z.any().array() permissions: z.any().array()
@@ -45,7 +56,17 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
roleId: z.string().trim() roleId: z.string().trim()
}), }),
body: z.object({ body: z.object({
slug: z.string().trim().optional(), slug: z
.string()
.trim()
.optional()
.refine(
(val) => typeof val === "undefined" || Object.keys(OrgMembershipRole).includes(val),
"Please choose a different slug, the slug you have entered is reserved."
)
.refine((val) => typeof val === "undefined" || slugify(val) === val, {
message: "Slug must be a valid"
}),
name: z.string().trim().optional(), name: z.string().trim().optional(),
description: z.string().trim().optional(), description: z.string().trim().optional(),
permissions: z.any().array() permissions: z.any().array()
@@ -1,7 +1,9 @@
import { z } from "zod";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas"; import { IdentityProjectMembershipRoleSchema, ProjectUserMembershipRolesSchema, TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
import { selectAllTableCols } from "@app/lib/knex"; import { selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
export type TPermissionDALFactory = ReturnType<typeof permissionDALFactory>; export type TPermissionDALFactory = ReturnType<typeof permissionDALFactory>;
@@ -43,21 +45,72 @@ export const permissionDALFactory = (db: TDbClient) => {
const getProjectPermission = async (userId: string, projectId: string) => { const getProjectPermission = async (userId: string, projectId: string) => {
try { try {
const membership = await db(TableName.ProjectMembership) const docs = await db(TableName.ProjectMembership)
.leftJoin(TableName.ProjectRoles, `${TableName.ProjectMembership}.roleId`, `${TableName.ProjectRoles}.id`) .join(
TableName.ProjectUserMembershipRole,
`${TableName.ProjectUserMembershipRole}.projectMembershipId`,
`${TableName.ProjectMembership}.id`
)
.leftJoin(
TableName.ProjectRoles,
`${TableName.ProjectUserMembershipRole}.customRoleId`,
`${TableName.ProjectRoles}.id`
)
.join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`)
.join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`) .join(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`)
.where("userId", userId) .where("userId", userId)
.where(`${TableName.ProjectMembership}.projectId`, projectId) .where(`${TableName.ProjectMembership}.projectId`, projectId)
.select(selectAllTableCols(TableName.ProjectMembership)) .select(selectAllTableCols(TableName.ProjectUserMembershipRole))
.select( .select(
db.ref("id").withSchema(TableName.ProjectMembership).as("membershipId"),
// TODO(roll-forward-migration): remove this field when we drop this in next migration after a week
db.ref("role").withSchema(TableName.ProjectMembership).as("oldRoleField"),
db.ref("createdAt").withSchema(TableName.ProjectMembership).as("membershipCreatedAt"),
db.ref("updatedAt").withSchema(TableName.ProjectMembership).as("membershipUpdatedAt"),
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
db.ref("orgId").withSchema(TableName.Project) db.ref("orgId").withSchema(TableName.Project),
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug")
) )
.select("permissions") .select("permissions");
.first();
return membership; const permission = sqlNestRelationships({
data: docs,
key: "membershipId",
parentMapper: ({
orgId,
orgAuthEnforced,
membershipId,
membershipCreatedAt,
membershipUpdatedAt,
oldRoleField
}) => ({
orgId,
orgAuthEnforced,
userId,
role: oldRoleField,
id: membershipId,
projectId,
createdAt: membershipCreatedAt,
updatedAt: membershipUpdatedAt
}),
childrenMapper: [
{
key: "id",
label: "roles" as const,
mapper: (data) =>
ProjectUserMembershipRolesSchema.extend({
permissions: z.unknown(),
customRoleSlug: z.string().optional().nullable()
}).parse(data)
}
]
});
// when introducting cron mode change it here
const activeRoles = permission?.[0]?.roles.filter(
({ isTemporary, temporaryAccessEndTime }) =>
!isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime)
);
return permission?.[0] ? { ...permission[0], roles: activeRoles } : undefined;
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "GetProjectPermission" }); throw new DatabaseError({ error, name: "GetProjectPermission" });
} }
@@ -65,18 +118,62 @@ export const permissionDALFactory = (db: TDbClient) => {
const getProjectIdentityPermission = async (identityId: string, projectId: string) => { const getProjectIdentityPermission = async (identityId: string, projectId: string) => {
try { try {
const membership = await db(TableName.IdentityProjectMembership) const docs = await db(TableName.IdentityProjectMembership)
.join(
TableName.IdentityProjectMembershipRole,
`${TableName.IdentityProjectMembershipRole}.projectMembershipId`,
`${TableName.IdentityProjectMembership}.id`
)
.leftJoin( .leftJoin(
TableName.ProjectRoles, TableName.ProjectRoles,
`${TableName.IdentityProjectMembership}.roleId`, `${TableName.IdentityProjectMembershipRole}.customRoleId`,
`${TableName.ProjectRoles}.id` `${TableName.ProjectRoles}.id`
) )
.where("identityId", identityId) .where("identityId", identityId)
.where(`${TableName.IdentityProjectMembership}.projectId`, projectId) .where(`${TableName.IdentityProjectMembership}.projectId`, projectId)
.select(selectAllTableCols(TableName.IdentityProjectMembership)) .select(selectAllTableCols(TableName.IdentityProjectMembershipRole))
.select("permissions") .select(
.first(); db.ref("id").withSchema(TableName.IdentityProjectMembership).as("membershipId"),
return membership; db.ref("role").withSchema(TableName.IdentityProjectMembership).as("oldRoleField"),
db.ref("createdAt").withSchema(TableName.IdentityProjectMembership).as("membershipCreatedAt"),
db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership).as("membershipUpdatedAt"),
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug")
)
.select("permissions");
const permission = sqlNestRelationships({
data: docs,
key: "membershipId",
parentMapper: ({ membershipId, membershipCreatedAt, membershipUpdatedAt, oldRoleField }) => ({
id: membershipId,
identityId,
projectId,
role: oldRoleField,
createdAt: membershipCreatedAt,
updatedAt: membershipUpdatedAt,
// just a prefilled value
orgAuthEnforced: false,
orgId: ""
}),
childrenMapper: [
{
key: "id",
label: "roles" as const,
mapper: (data) =>
IdentityProjectMembershipRoleSchema.extend({
permissions: z.unknown(),
customRoleSlug: z.string().optional().nullable()
}).parse(data)
}
]
});
// when introducting cron mode change it here
const activeRoles = permission?.[0]?.roles.filter(
({ isTemporary, temporaryAccessEndTime }) =>
!isTemporary || (isTemporary && temporaryAccessEndTime && new Date() < temporaryAccessEndTime)
);
return permission?.[0] ? { ...permission[0], roles: activeRoles } : undefined;
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "GetProjectIdentityPermission" }); throw new DatabaseError({ error, name: "GetProjectIdentityPermission" });
} }
@@ -18,6 +18,7 @@ import { TServiceTokenDALFactory } from "@app/services/service-token/service-tok
import { orgAdminPermissions, orgMemberPermissions, orgNoAccessPermissions, OrgPermissionSet } from "./org-permission"; import { orgAdminPermissions, orgMemberPermissions, orgNoAccessPermissions, OrgPermissionSet } from "./org-permission";
import { TPermissionDALFactory } from "./permission-dal"; import { TPermissionDALFactory } from "./permission-dal";
import { TBuildProjectPermissionDTO } from "./permission-types";
import { import {
buildServiceTokenProjectPermission, buildServiceTokenProjectPermission,
projectAdminPermissions, projectAdminPermissions,
@@ -64,31 +65,35 @@ export const permissionServiceFactory = ({
} }
}; };
const buildProjectPermission = (role: string, permission?: unknown) => { const buildProjectPermission = (projectUserRoles: TBuildProjectPermissionDTO) => {
switch (role) { const rules = projectUserRoles
case ProjectMembershipRole.Admin: .map(({ role, permissions }) => {
return projectAdminPermissions; switch (role) {
case ProjectMembershipRole.Member: case ProjectMembershipRole.Admin:
return projectMemberPermissions; return projectAdminPermissions;
case ProjectMembershipRole.Viewer: case ProjectMembershipRole.Member:
return projectViewerPermission; return projectMemberPermissions;
case ProjectMembershipRole.NoAccess: case ProjectMembershipRole.Viewer:
return projectNoAccessPermissions; return projectViewerPermission;
case ProjectMembershipRole.Custom: case ProjectMembershipRole.NoAccess:
return createMongoAbility<ProjectPermissionSet>( return projectNoAccessPermissions;
unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>( case ProjectMembershipRole.Custom: {
permission as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[] return unpackRules<RawRuleOf<MongoAbility<ProjectPermissionSet>>>(
), permissions as PackRule<RawRuleOf<MongoAbility<ProjectPermissionSet>>>[]
{ );
conditionsMatcher
} }
); default:
default: throw new BadRequestError({
throw new BadRequestError({ name: "ProjectRoleInvalid",
name: "ProjectRoleInvalid", message: "Project role not found"
message: "Project role not found" });
}); }
} })
.reduce((curr, prev) => prev.concat(curr), []);
return createMongoAbility<ProjectPermissionSet>(rules, {
conditionsMatcher
});
}; };
/* /*
@@ -145,33 +150,56 @@ export const permissionServiceFactory = ({
}; };
// user permission for a project in an organization // user permission for a project in an organization
const getUserProjectPermission = async (userId: string, projectId: string, userOrgId?: string) => { const getUserProjectPermission = async (
const membership = await permissionDAL.getProjectPermission(userId, projectId); userId: string,
if (!membership) throw new UnauthorizedError({ name: "User not in project" }); projectId: string,
if (membership.role === ProjectMembershipRole.Custom && !membership.permissions) { userOrgId?: string
): Promise<TProjectPermissionRT<ActorType.USER>> => {
const userProjectPermission = await permissionDAL.getProjectPermission(userId, projectId);
if (!userProjectPermission) throw new UnauthorizedError({ name: "User not in project" });
if (
userProjectPermission.roles.some(({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions)
) {
throw new BadRequestError({ name: "Custom permission not found" }); throw new BadRequestError({ name: "Custom permission not found" });
} }
if (membership.orgAuthEnforced && membership.orgId !== userOrgId) { if (userProjectPermission.orgAuthEnforced && userProjectPermission.orgId !== userOrgId) {
throw new BadRequestError({ name: "Cannot access org-scoped resource" }); throw new BadRequestError({ name: "Cannot access org-scoped resource" });
} }
return { return {
permission: buildProjectPermission(membership.role, membership.permissions), permission: buildProjectPermission(userProjectPermission.roles),
membership membership: userProjectPermission,
hasRole: (role: string) =>
userProjectPermission.roles.findIndex(
({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug
) !== -1
}; };
}; };
const getIdentityProjectPermission = async (identityId: string, projectId: string) => { const getIdentityProjectPermission = async (
const membership = await permissionDAL.getProjectIdentityPermission(identityId, projectId); identityId: string,
if (!membership) throw new UnauthorizedError({ name: "Identity not in project" }); projectId: string
if (membership.role === ProjectMembershipRole.Custom && !membership.permissions) { ): Promise<TProjectPermissionRT<ActorType.IDENTITY>> => {
const identityProjectPermission = await permissionDAL.getProjectIdentityPermission(identityId, projectId);
if (!identityProjectPermission) throw new UnauthorizedError({ name: "Identity not in project" });
if (
identityProjectPermission.roles.some(
({ role, permissions }) => role === ProjectMembershipRole.Custom && !permissions
)
) {
throw new BadRequestError({ name: "Custom permission not found" }); throw new BadRequestError({ name: "Custom permission not found" });
} }
return { return {
permission: buildProjectPermission(membership.role, membership.permissions), permission: buildProjectPermission(identityProjectPermission.roles),
membership membership: identityProjectPermission,
hasRole: (role: string) =>
identityProjectPermission.roles.findIndex(
({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug
) !== -1
}; };
}; };
@@ -191,14 +219,19 @@ export const permissionServiceFactory = ({
}; };
type TProjectPermissionRT<T extends ActorType> = T extends ActorType.SERVICE type TProjectPermissionRT<T extends ActorType> = T extends ActorType.SERVICE
? { permission: MongoAbility<ProjectPermissionSet, MongoQuery>; membership: undefined } ? {
permission: MongoAbility<ProjectPermissionSet, MongoQuery>;
membership: undefined;
hasRole: (arg: string) => boolean;
} // service token doesn't have both membership and roles
: { : {
permission: MongoAbility<ProjectPermissionSet, MongoQuery>; permission: MongoAbility<ProjectPermissionSet, MongoQuery>;
membership: (T extends ActorType.USER ? TProjectMemberships : TIdentityProjectMemberships) & { membership: (T extends ActorType.USER ? TProjectMemberships : TIdentityProjectMemberships) & {
orgAuthEnforced: boolean; orgAuthEnforced: boolean | null | undefined;
orgId: string; orgId: string;
permissions?: unknown; roles: Array<{ role: string }>;
}; };
hasRole: (role: string) => boolean;
}; };
const getProjectPermission = async <T extends ActorType>( const getProjectPermission = async <T extends ActorType>(
@@ -228,11 +261,13 @@ export const permissionServiceFactory = ({
const projectRole = await projectRoleDAL.findOne({ slug: role, projectId }); const projectRole = await projectRoleDAL.findOne({ slug: role, projectId });
if (!projectRole) throw new BadRequestError({ message: "Role not found" }); if (!projectRole) throw new BadRequestError({ message: "Role not found" });
return { return {
permission: buildProjectPermission(ProjectMembershipRole.Custom, projectRole.permissions), permission: buildProjectPermission([
{ role: ProjectMembershipRole.Custom, permissions: projectRole.permissions }
]),
role: projectRole role: projectRole
}; };
} }
return { permission: buildProjectPermission(role, []) }; return { permission: buildProjectPermission([{ role, permissions: [] }]) };
}; };
return { return {
@@ -0,0 +1,4 @@
export type TBuildProjectPermissionDTO = {
permissions?: unknown;
role: string;
}[];
@@ -56,8 +56,8 @@ export type ProjectPermissionSet =
| [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback]
| [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback]; | [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback];
const buildAdminPermission = () => { const buildAdminPermissionRules = () => {
const { can, build } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility); const { can, rules } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets);
can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets);
@@ -135,13 +135,13 @@ const buildAdminPermission = () => {
can(ProjectPermissionActions.Edit, ProjectPermissionSub.Project); can(ProjectPermissionActions.Edit, ProjectPermissionSub.Project);
can(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Project);
return build({ conditionsMatcher }); return rules;
}; };
export const projectAdminPermissions = buildAdminPermission(); export const projectAdminPermissions = buildAdminPermissionRules();
const buildMemberPermission = () => { const buildMemberPermissionRules = () => {
const { can, build } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility); const { can, rules } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets);
can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets);
@@ -196,13 +196,13 @@ const buildMemberPermission = () => {
can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs);
can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList);
return build({ conditionsMatcher }); return rules;
}; };
export const projectMemberPermissions = buildMemberPermission(); export const projectMemberPermissions = buildMemberPermissionRules();
const buildViewerPermission = () => { const buildViewerPermissionRules = () => {
const { can, build } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility); const { can, rules } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility);
can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval);
@@ -220,14 +220,14 @@ const buildViewerPermission = () => {
can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs);
can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList);
return build({ conditionsMatcher }); return rules;
}; };
export const projectViewerPermission = buildViewerPermission(); export const projectViewerPermission = buildViewerPermissionRules();
const buildNoAccessProjectPermission = () => { const buildNoAccessProjectPermission = () => {
const { build } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility); const { rules } = new AbilityBuilder<MongoAbility<ProjectPermissionSet>>(createMongoAbility);
return build({ conditionsMatcher }); return rules;
}; };
export const buildServiceTokenProjectPermission = ( export const buildServiceTokenProjectPermission = (
@@ -129,14 +129,14 @@ export const secretApprovalRequestServiceFactory = ({
if (!secretApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" }); if (!secretApprovalRequest) throw new BadRequestError({ message: "Secret approval request not found" });
const { policy } = secretApprovalRequest; const { policy } = secretApprovalRequest;
const { membership } = await permissionService.getProjectPermission( const { membership, hasRole } = await permissionService.getProjectPermission(
actor, actor,
actorId, actorId,
secretApprovalRequest.projectId, secretApprovalRequest.projectId,
actorOrgId actorOrgId
); );
if ( if (
membership.role !== ProjectMembershipRole.Admin && !hasRole(ProjectMembershipRole.Admin) &&
secretApprovalRequest.committerId !== membership.id && secretApprovalRequest.committerId !== membership.id &&
!policy.approvers.find((approverId) => approverId === membership.id) !policy.approvers.find((approverId) => approverId === membership.id)
) { ) {
@@ -156,14 +156,14 @@ export const secretApprovalRequestServiceFactory = ({
if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" });
const { policy } = secretApprovalRequest; const { policy } = secretApprovalRequest;
const { membership } = await permissionService.getProjectPermission( const { membership, hasRole } = await permissionService.getProjectPermission(
ActorType.USER, ActorType.USER,
actorId, actorId,
secretApprovalRequest.projectId, secretApprovalRequest.projectId,
actorOrgId actorOrgId
); );
if ( if (
membership.role !== ProjectMembershipRole.Admin && !hasRole(ProjectMembershipRole.Admin) &&
secretApprovalRequest.committerId !== membership.id && secretApprovalRequest.committerId !== membership.id &&
!policy.approvers.find((approverId) => approverId === membership.id) !policy.approvers.find((approverId) => approverId === membership.id)
) { ) {
@@ -198,14 +198,14 @@ export const secretApprovalRequestServiceFactory = ({
if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" });
const { policy } = secretApprovalRequest; const { policy } = secretApprovalRequest;
const { membership } = await permissionService.getProjectPermission( const { membership, hasRole } = await permissionService.getProjectPermission(
ActorType.USER, ActorType.USER,
actorId, actorId,
secretApprovalRequest.projectId, secretApprovalRequest.projectId,
actorOrgId actorOrgId
); );
if ( if (
membership.role !== ProjectMembershipRole.Admin && !hasRole(ProjectMembershipRole.Admin) &&
secretApprovalRequest.committerId !== membership.id && secretApprovalRequest.committerId !== membership.id &&
!policy.approvers.find((approverId) => approverId === membership.id) !policy.approvers.find((approverId) => approverId === membership.id)
) { ) {
@@ -236,9 +236,14 @@ export const secretApprovalRequestServiceFactory = ({
if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" }); if (actor !== ActorType.USER) throw new BadRequestError({ message: "Must be a user" });
const { policy, folderId, projectId } = secretApprovalRequest; const { policy, folderId, projectId } = secretApprovalRequest;
const { membership } = await permissionService.getProjectPermission(ActorType.USER, actorId, projectId, actorOrgId); const { membership, hasRole } = await permissionService.getProjectPermission(
ActorType.USER,
actorId,
projectId,
actorOrgId
);
if ( if (
membership.role !== ProjectMembershipRole.Admin && !hasRole(ProjectMembershipRole.Admin) &&
secretApprovalRequest.committerId !== membership.id && secretApprovalRequest.committerId !== membership.id &&
!policy.approvers.find((approverId) => approverId === membership.id) !policy.approvers.find((approverId) => approverId === membership.id)
) { ) {
@@ -1,3 +1,10 @@
import {
CreateAccessKeyCommand,
DeleteAccessKeyCommand,
GetAccessKeyLastUsedCommand,
IAMClient
} from "@aws-sdk/client-iam";
import { SecretKeyEncoding, SecretType } from "@app/db/schemas"; import { SecretKeyEncoding, SecretType } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { import {
@@ -18,7 +25,12 @@ import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
import { TSecretRotationDALFactory } from "../secret-rotation-dal"; import { TSecretRotationDALFactory } from "../secret-rotation-dal";
import { rotationTemplates } from "../templates"; import { rotationTemplates } from "../templates";
import { TDbProviderClients, TProviderFunctionTypes, TSecretRotationProviderTemplate } from "../templates/types"; import {
TAwsProviderSystems,
TDbProviderClients,
TProviderFunctionTypes,
TSecretRotationProviderTemplate
} from "../templates/types";
import { import {
getDbSetQuery, getDbSetQuery,
secretRotationDbFn, secretRotationDbFn,
@@ -127,7 +139,10 @@ export const secretRotationQueueFactory = ({
internal: {} internal: {}
}; };
// when its a database we keep cycling the variables accordingly /* Rotation Function For Database
* A database like sql cannot have multiple password for a user
* thus we ask users to create two users with required permission and then we keep cycling between these two db users
*/
if (provider.template.type === TProviderFunctionTypes.DB) { if (provider.template.type === TProviderFunctionTypes.DB) {
const lastCred = variables.creds.at(-1); const lastCred = variables.creds.at(-1);
if (lastCred && variables.creds.length === 1) { if (lastCred && variables.creds.length === 1) {
@@ -170,6 +185,65 @@ export const secretRotationQueueFactory = ({
if (variables.creds.length === 2) variables.creds.pop(); if (variables.creds.length === 2) variables.creds.pop();
} }
/*
* Rotation Function For AWS Services
* Due to complexity in AWS Authorization hashing signature process we keep it as seperate entity instead of http template mode
* We first delete old key before creating a new one because aws iam has a quota limit of 2 keys
* */
if (provider.template.type === TProviderFunctionTypes.AWS) {
if (provider.template.client === TAwsProviderSystems.IAM) {
const client = new IAMClient({
region: newCredential.inputs.manager_user_aws_region as string,
credentials: {
accessKeyId: newCredential.inputs.manager_user_access_key as string,
secretAccessKey: newCredential.inputs.manager_user_secret_key as string
}
});
const iamUserName = newCredential.inputs.iam_username as string;
if (variables.creds.length === 2) {
const deleteCycleCredential = variables.creds.pop();
if (deleteCycleCredential) {
const deletedIamAccessKey = await client.send(
new DeleteAccessKeyCommand({
UserName: iamUserName,
AccessKeyId: deleteCycleCredential.outputs.iam_user_access_key as string
})
);
if (
!deletedIamAccessKey?.$metadata?.httpStatusCode ||
deletedIamAccessKey?.$metadata?.httpStatusCode > 300
) {
throw new DisableRotationErrors({
message: "Failed to delete aws iam access key. Check managed iam user policy"
});
}
}
}
const newIamAccessKey = await client.send(new CreateAccessKeyCommand({ UserName: iamUserName }));
if (!newIamAccessKey.AccessKey)
throw new DisableRotationErrors({ message: "Failed to create access key. Check managed iam user policy" });
// test
const testAccessKey = await client.send(
new GetAccessKeyLastUsedCommand({ AccessKeyId: newIamAccessKey.AccessKey.AccessKeyId })
);
if (testAccessKey?.UserName !== iamUserName)
throw new DisableRotationErrors({ message: "Failed to create access key. Check managed iam user policy" });
newCredential.outputs.iam_user_access_key = newIamAccessKey.AccessKey.AccessKeyId;
newCredential.outputs.iam_user_secret_key = newIamAccessKey.AccessKey.SecretAccessKey;
}
}
/* Rotation function of HTTP infisical template
* This is a generic http based template system for rotation
* we use this for sendgrid and for custom secret rotation
* This will ensure user provided rotation is easier to make
* */
if (provider.template.type === TProviderFunctionTypes.HTTP) { if (provider.template.type === TProviderFunctionTypes.HTTP) {
if (provider.template.functions.set?.pre) { if (provider.template.functions.set?.pre) {
secretRotationPreSetFn(provider.template.functions.set.pre, newCredential); secretRotationPreSetFn(provider.template.functions.set.pre, newCredential);
@@ -185,6 +259,9 @@ export const secretRotationQueueFactory = ({
} }
} }
} }
// insert the new variables to start
// encrypt the data - save it
variables.creds.unshift({ variables.creds.unshift({
outputs: newCredential.outputs, outputs: newCredential.outputs,
internal: newCredential.internal internal: newCredential.internal
@@ -200,6 +277,7 @@ export const secretRotationQueueFactory = ({
key key
) )
})); }));
// map the final values to output keys in the board
await secretRotationDAL.transaction(async (tx) => { await secretRotationDAL.transaction(async (tx) => {
await secretRotationDAL.updateById( await secretRotationDAL.updateById(
rotationId, rotationId,
@@ -0,0 +1,21 @@
import { TAwsProviderSystems, TProviderFunctionTypes } from "./types";
export const AWS_IAM_TEMPLATE = {
type: TProviderFunctionTypes.AWS as const,
client: TAwsProviderSystems.IAM,
inputs: {
type: "object" as const,
properties: {
manager_user_access_key: { type: "string" as const },
manager_user_secret_key: { type: "string" as const },
manager_user_aws_region: { type: "string" as const },
iam_username: { type: "string" as const }
},
required: ["manager_user_access_key", "manager_user_secret_key", "manager_user_aws_region", "iam_username"],
additionalProperties: false
},
outputs: {
iam_user_access_key: { type: "string" },
iam_user_secret_key: { type: "string" }
}
};
@@ -1,3 +1,4 @@
import { AWS_IAM_TEMPLATE } from "./aws-iam";
import { MYSQL_TEMPLATE } from "./mysql"; import { MYSQL_TEMPLATE } from "./mysql";
import { POSTGRES_TEMPLATE } from "./postgres"; import { POSTGRES_TEMPLATE } from "./postgres";
import { SENDGRID_TEMPLATE } from "./sendgrid"; import { SENDGRID_TEMPLATE } from "./sendgrid";
@@ -24,5 +25,12 @@ export const rotationTemplates: TSecretRotationProviderTemplate[] = [
image: "mysql.png", image: "mysql.png",
description: "Rotate MySQL@7/MariaDB user credentials", description: "Rotate MySQL@7/MariaDB user credentials",
template: MYSQL_TEMPLATE template: MYSQL_TEMPLATE
},
{
name: "aws-iam",
title: "AWS IAM",
image: "aws-iam.svg",
description: "Rotate AWS IAM User credentials",
template: AWS_IAM_TEMPLATE
} }
]; ];
@@ -1,6 +1,7 @@
export enum TProviderFunctionTypes { export enum TProviderFunctionTypes {
HTTP = "http", HTTP = "http",
DB = "database" DB = "database",
AWS = "aws"
} }
export enum TDbProviderClients { export enum TDbProviderClients {
@@ -10,6 +11,10 @@ export enum TDbProviderClients {
MySql = "mysql" MySql = "mysql"
} }
export enum TAwsProviderSystems {
IAM = "iam"
}
export enum TAssignOp { export enum TAssignOp {
Direct = "direct", Direct = "direct",
JmesPath = "jmesopath" JmesPath = "jmesopath"
@@ -42,7 +47,7 @@ export type TSecretRotationProviderTemplate = {
title: string; title: string;
image?: string; image?: string;
description?: string; description?: string;
template: THttpProviderTemplate | TDbProviderTemplate; template: THttpProviderTemplate | TDbProviderTemplate | TAwsProviderTemplate;
}; };
export type THttpProviderTemplate = { export type THttpProviderTemplate = {
@@ -70,3 +75,14 @@ export type TDbProviderTemplate = {
}; };
outputs: Record<string, unknown>; outputs: Record<string, unknown>;
}; };
export type TAwsProviderTemplate = {
type: TProviderFunctionTypes.AWS;
client: TAwsProviderSystems;
inputs: {
type: "object";
properties: Record<string, { type: string; [x: string]: unknown; desc?: string }>;
required?: string[];
};
outputs: Record<string, unknown>;
};
+14 -3
View File
@@ -53,6 +53,7 @@ import { identityServiceFactory } from "@app/services/identity/identity-service"
import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal"; import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal";
import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service";
import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; import { identityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
import { identityProjectMembershipRoleDALFactory } from "@app/services/identity-project/identity-project-membership-role-dal";
import { identityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; import { identityProjectServiceFactory } from "@app/services/identity-project/identity-project-service";
import { identityUaClientSecretDALFactory } from "@app/services/identity-ua/identity-ua-client-secret-dal"; import { identityUaClientSecretDALFactory } from "@app/services/identity-ua/identity-ua-client-secret-dal";
import { identityUaDALFactory } from "@app/services/identity-ua/identity-ua-dal"; import { identityUaDALFactory } from "@app/services/identity-ua/identity-ua-dal";
@@ -78,6 +79,7 @@ import { projectKeyDALFactory } from "@app/services/project-key/project-key-dal"
import { projectKeyServiceFactory } from "@app/services/project-key/project-key-service"; import { projectKeyServiceFactory } from "@app/services/project-key/project-key-service";
import { projectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { projectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { projectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service"; import { projectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service";
import { projectUserMembershipRoleDALFactory } from "@app/services/project-membership/project-user-membership-role-dal";
import { projectRoleDALFactory } from "@app/services/project-role/project-role-dal"; import { projectRoleDALFactory } from "@app/services/project-role/project-role-dal";
import { projectRoleServiceFactory } from "@app/services/project-role/project-role-service"; import { projectRoleServiceFactory } from "@app/services/project-role/project-role-service";
import { secretDALFactory } from "@app/services/secret/secret-dal"; import { secretDALFactory } from "@app/services/secret/secret-dal";
@@ -141,6 +143,7 @@ export const registerRoutes = async (
const projectDAL = projectDALFactory(db); const projectDAL = projectDALFactory(db);
const projectMembershipDAL = projectMembershipDALFactory(db); const projectMembershipDAL = projectMembershipDALFactory(db);
const projectUserMembershipRoleDAL = projectUserMembershipRoleDALFactory(db);
const projectRoleDAL = projectRoleDALFactory(db); const projectRoleDAL = projectRoleDALFactory(db);
const projectEnvDAL = projectEnvDALFactory(db); const projectEnvDAL = projectEnvDALFactory(db);
const projectKeyDAL = projectKeyDALFactory(db); const projectKeyDAL = projectKeyDALFactory(db);
@@ -164,6 +167,7 @@ export const registerRoutes = async (
const identityAccessTokenDAL = identityAccessTokenDALFactory(db); const identityAccessTokenDAL = identityAccessTokenDALFactory(db);
const identityOrgMembershipDAL = identityOrgDALFactory(db); const identityOrgMembershipDAL = identityOrgDALFactory(db);
const identityProjectDAL = identityProjectDALFactory(db); const identityProjectDAL = identityProjectDALFactory(db);
const identityProjectMembershipRoleDAL = identityProjectMembershipRoleDALFactory(db);
const identityUaDAL = identityUaDALFactory(db); const identityUaDAL = identityUaDALFactory(db);
const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db);
@@ -321,6 +325,7 @@ export const registerRoutes = async (
const projectMembershipService = projectMembershipServiceFactory({ const projectMembershipService = projectMembershipServiceFactory({
projectMembershipDAL, projectMembershipDAL,
projectUserMembershipRoleDAL,
projectDAL, projectDAL,
permissionService, permissionService,
projectBotDAL, projectBotDAL,
@@ -352,7 +357,8 @@ export const registerRoutes = async (
projectBotDAL, projectBotDAL,
projectMembershipDAL, projectMembershipDAL,
secretApprovalRequestDAL, secretApprovalRequestDAL,
secretApprovalSecretDAL: sarSecretDAL secretApprovalSecretDAL: sarSecretDAL,
projectUserMembershipRoleDAL
}); });
const projectService = projectServiceFactory({ const projectService = projectServiceFactory({
@@ -369,8 +375,11 @@ export const registerRoutes = async (
orgService, orgService,
projectMembershipDAL, projectMembershipDAL,
folderDAL, folderDAL,
licenseService licenseService,
projectUserMembershipRoleDAL,
identityProjectMembershipRoleDAL
}); });
const projectEnvService = projectEnvServiceFactory({ const projectEnvService = projectEnvServiceFactory({
permissionService, permissionService,
projectEnvDAL, projectEnvDAL,
@@ -521,7 +530,9 @@ export const registerRoutes = async (
permissionService, permissionService,
projectDAL, projectDAL,
identityProjectDAL, identityProjectDAL,
identityOrgMembershipDAL identityOrgMembershipDAL,
identityProjectMembershipRoleDAL,
projectRoleDAL
}); });
const identityUaService = identityUaServiceFactory({ const identityUaService = identityUaServiceFactory({
identityOrgMembershipDAL, identityOrgMembershipDAL,
@@ -1,15 +1,17 @@
import ms from "ms";
import { z } from "zod"; import { z } from "zod";
import { import {
OrgMembershipsSchema, OrgMembershipsSchema,
ProjectMembershipRole,
ProjectMembershipsSchema, ProjectMembershipsSchema,
ProjectUserMembershipRolesSchema,
UserEncryptionKeysSchema, UserEncryptionKeysSchema,
UsersSchema UsersSchema
} from "@app/db/schemas"; } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types";
export const registerProjectMembershipRouter = async (server: FastifyZodProvider) => { export const registerProjectMembershipRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
@@ -28,16 +30,31 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
}), }),
response: { response: {
200: z.object({ 200: z.object({
memberships: ProjectMembershipsSchema.merge( memberships: ProjectMembershipsSchema.omit({ role: true })
z.object({ .merge(
user: UsersSchema.pick({ z.object({
email: true, user: UsersSchema.pick({
firstName: true, email: true,
lastName: true, firstName: true,
id: true lastName: true,
}).merge(UserEncryptionKeysSchema.pick({ publicKey: true })) id: true
}) }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })),
) roles: z.array(
z.object({
id: z.string(),
role: z.string(),
customRoleId: z.string().optional().nullable(),
customRoleName: z.string().optional().nullable(),
customRoleSlug: z.string().optional().nullable(),
isTemporary: z.boolean(),
temporaryMode: z.string().optional().nullable(),
temporaryRange: z.string().nullable().optional(),
temporaryAccessStartTime: z.date().nullable().optional(),
temporaryAccessEndTime: z.date().nullable().optional()
})
)
})
)
.omit({ createdAt: true, updatedAt: true }) .omit({ createdAt: true, updatedAt: true })
.array() .array()
}) })
@@ -86,10 +103,7 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
actor: req.permission.type, actor: req.permission.type,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
projectId: req.params.workspaceId, projectId: req.params.workspaceId,
members: req.body.members.map((member) => ({ members: req.body.members
...member,
projectRole: ProjectMembershipRole.Member
}))
}); });
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
@@ -124,39 +138,56 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
membershipId: z.string().trim() membershipId: z.string().trim()
}), }),
body: z.object({ body: z.object({
role: z.string().trim() roles: z
.array(
z.union([
z.object({
role: z.string(),
isTemporary: z.literal(false).default(false)
}),
z.object({
role: z.string(),
isTemporary: z.literal(true),
temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode),
temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"),
temporaryAccessStartTime: z.string().datetime()
})
])
)
.min(1)
.refine((data) => data.some(({ isTemporary }) => !isTemporary), "At least long lived role is required")
}), }),
response: { response: {
200: z.object({ 200: z.object({
membership: ProjectMembershipsSchema roles: ProjectUserMembershipRolesSchema.array()
}) })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const membership = await server.services.projectMembership.updateProjectMembership({ const roles = await server.services.projectMembership.updateProjectMembership({
actorId: req.permission.id, actorId: req.permission.id,
actor: req.permission.type, actor: req.permission.type,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
projectId: req.params.workspaceId, projectId: req.params.workspaceId,
membershipId: req.params.membershipId, membershipId: req.params.membershipId,
role: req.body.role roles: req.body.roles
}); });
await server.services.auditLog.createAuditLog({ // await server.services.auditLog.createAuditLog({
...req.auditLogInfo, // ...req.auditLogInfo,
projectId: req.params.workspaceId, // projectId: req.params.workspaceId,
event: { // event: {
type: EventType.UPDATE_USER_WORKSPACE_ROLE, // type: EventType.UPDATE_USER_WORKSPACE_ROLE,
metadata: { // metadata: {
userId: membership.userId, // userId: membership.userId,
newRole: req.body.role, // newRole: req.body.role,
oldRole: membership.role, // oldRole: membership.role,
email: "" // email: ""
} // }
} // }
}); // });
return { membership }; return { roles };
} }
}); });
+26 -11
View File
@@ -60,17 +60,32 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
}), }),
response: { response: {
200: z.object({ 200: z.object({
users: ProjectMembershipsSchema.merge( users: ProjectMembershipsSchema.omit({ role: true })
z.object({ .merge(
user: UsersSchema.pick({ z.object({
username: true, user: UsersSchema.pick({
email: true, username: true,
firstName: true, email: true,
lastName: true, firstName: true,
id: true lastName: true,
}).merge(UserEncryptionKeysSchema.pick({ publicKey: true })) id: true
}) }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })),
) roles: z.array(
z.object({
id: z.string(),
role: z.string(),
customRoleId: z.string().optional().nullable(),
customRoleName: z.string().optional().nullable(),
customRoleSlug: z.string().optional().nullable(),
isTemporary: z.boolean(),
temporaryMode: z.string().optional().nullable(),
temporaryRange: z.string().nullable().optional(),
temporaryAccessStartTime: z.date().nullable().optional(),
temporaryAccessEndTime: z.date().nullable().optional()
})
)
})
)
.omit({ createdAt: true, updatedAt: true }) .omit({ createdAt: true, updatedAt: true })
.array() .array()
}) })
@@ -1,13 +1,15 @@
import ms from "ms";
import { z } from "zod"; import { z } from "zod";
import { import {
IdentitiesSchema, IdentitiesSchema,
IdentityProjectMembershipsSchema, IdentityProjectMembershipsSchema,
ProjectMembershipRole, ProjectMembershipRole,
ProjectRolesSchema ProjectUserMembershipRolesSchema
} from "@app/db/schemas"; } from "@app/db/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types";
export const registerIdentityProjectRouter = async (server: FastifyZodProvider) => { export const registerIdentityProjectRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
@@ -57,24 +59,40 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider)
identityId: z.string().trim() identityId: z.string().trim()
}), }),
body: z.object({ body: z.object({
role: z.string().trim().min(1).default(ProjectMembershipRole.NoAccess) roles: z
.array(
z.union([
z.object({
role: z.string(),
isTemporary: z.literal(false).default(false)
}),
z.object({
role: z.string(),
isTemporary: z.literal(true),
temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode),
temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"),
temporaryAccessStartTime: z.string().datetime()
})
])
)
.min(1)
}), }),
response: { response: {
200: z.object({ 200: z.object({
identityMembership: IdentityProjectMembershipsSchema roles: ProjectUserMembershipRolesSchema.array()
}) })
} }
}, },
handler: async (req) => { handler: async (req) => {
const identityMembership = await server.services.identityProject.updateProjectIdentity({ const roles = await server.services.identityProject.updateProjectIdentity({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
identityId: req.params.identityId, identityId: req.params.identityId,
projectId: req.params.projectId, projectId: req.params.projectId,
role: req.body.role roles: req.body.roles
}); });
return { identityMembership }; return { roles };
} }
}); });
@@ -127,18 +145,29 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider)
}), }),
response: { response: {
200: z.object({ 200: z.object({
identityMemberships: IdentityProjectMembershipsSchema.merge( identityMemberships: z
z.object({ .object({
customRole: ProjectRolesSchema.pick({ id: z.string(),
id: true, identityId: z.string(),
name: true, createdAt: z.date(),
slug: true, updatedAt: z.date(),
permissions: true, roles: z.array(
description: true z.object({
}).optional(), id: z.string(),
role: z.string(),
customRoleId: z.string().optional().nullable(),
customRoleName: z.string().optional().nullable(),
customRoleSlug: z.string().optional().nullable(),
isTemporary: z.boolean(),
temporaryMode: z.string().optional().nullable(),
temporaryRange: z.string().nullable().optional(),
temporaryAccessStartTime: z.date().nullable().optional(),
temporaryAccessEndTime: z.date().nullable().optional()
})
),
identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true }) identity: IdentitiesSchema.pick({ name: true, id: true, authMethod: true })
}) })
).array() .array()
}) })
} }
}, },
@@ -3,7 +3,7 @@ import { Knex } from "knex";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas"; import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex"; import { ormify, sqlNestRelationships } from "@app/lib/knex";
export type TIdentityProjectDALFactory = ReturnType<typeof identityProjectDALFactory>; export type TIdentityProjectDALFactory = ReturnType<typeof identityProjectDALFactory>;
@@ -15,52 +15,81 @@ export const identityProjectDALFactory = (db: TDbClient) => {
const docs = await (tx || db)(TableName.IdentityProjectMembership) const docs = await (tx || db)(TableName.IdentityProjectMembership)
.where(`${TableName.IdentityProjectMembership}.projectId`, projectId) .where(`${TableName.IdentityProjectMembership}.projectId`, projectId)
.join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`) .join(TableName.Identity, `${TableName.IdentityProjectMembership}.identityId`, `${TableName.Identity}.id`)
.join(
TableName.IdentityProjectMembershipRole,
`${TableName.IdentityProjectMembershipRole}.projectMembershipId`,
`${TableName.IdentityProjectMembership}.id`
)
.leftJoin( .leftJoin(
TableName.ProjectRoles, TableName.ProjectRoles,
`${TableName.IdentityProjectMembership}.roleId`, `${TableName.IdentityProjectMembershipRole}.customRoleId`,
`${TableName.ProjectRoles}.id` `${TableName.ProjectRoles}.id`
) )
.select(selectAllTableCols(TableName.IdentityProjectMembership)) .select(
// cr stands for custom role db.ref("id").withSchema(TableName.IdentityProjectMembership),
.select(db.ref("id").as("crId").withSchema(TableName.ProjectRoles)) db.ref("createdAt").withSchema(TableName.IdentityProjectMembership),
.select(db.ref("name").as("crName").withSchema(TableName.ProjectRoles)) db.ref("updatedAt").withSchema(TableName.IdentityProjectMembership),
.select(db.ref("slug").as("crSlug").withSchema(TableName.ProjectRoles)) db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity),
.select(db.ref("description").as("crDescription").withSchema(TableName.ProjectRoles)) db.ref("id").as("identityId").withSchema(TableName.Identity),
.select(db.ref("permissions").as("crPermission").withSchema(TableName.ProjectRoles)) db.ref("name").as("identityName").withSchema(TableName.Identity),
.select(db.ref("permissions").as("crPermission").withSchema(TableName.ProjectRoles)) db.ref("id").withSchema(TableName.IdentityProjectMembership),
.select(db.ref("id").as("identityId").withSchema(TableName.Identity)) db.ref("role").withSchema(TableName.IdentityProjectMembershipRole),
.select(db.ref("name").as("identityName").withSchema(TableName.Identity)) db.ref("id").withSchema(TableName.IdentityProjectMembershipRole).as("membershipRoleId"),
.select(db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity)); db.ref("customRoleId").withSchema(TableName.IdentityProjectMembershipRole),
return docs.map( db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"),
({ db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"),
crId, db.ref("temporaryMode").withSchema(TableName.IdentityProjectMembershipRole),
crDescription, db.ref("isTemporary").withSchema(TableName.IdentityProjectMembershipRole),
crSlug, db.ref("temporaryRange").withSchema(TableName.IdentityProjectMembershipRole),
crPermission, db.ref("temporaryAccessStartTime").withSchema(TableName.IdentityProjectMembershipRole),
crName, db.ref("temporaryAccessEndTime").withSchema(TableName.IdentityProjectMembershipRole)
identityId, );
identityName,
identityAuthMethod, const members = sqlNestRelationships({
...el data: docs,
}) => ({ parentMapper: ({ identityId, identityName, identityAuthMethod, id, createdAt, updatedAt }) => ({
...el, id,
identityId, identityId,
createdAt,
updatedAt,
identity: { identity: {
id: identityId, id: identityId,
name: identityName, name: identityName,
authMethod: identityAuthMethod authMethod: identityAuthMethod
}, }
customRole: el.roleId }),
? { key: "id",
id: crId, childrenMapper: [
name: crName, {
slug: crSlug, label: "roles" as const,
permissions: crPermission, key: "membershipRoleId",
description: crDescription mapper: ({
} role,
: undefined customRoleId,
}) customRoleName,
); customRoleSlug,
membershipRoleId,
temporaryRange,
temporaryMode,
temporaryAccessEndTime,
temporaryAccessStartTime,
isTemporary
}) => ({
id: membershipRoleId,
role,
customRoleId,
customRoleName,
customRoleSlug,
temporaryRange,
temporaryMode,
temporaryAccessEndTime,
temporaryAccessStartTime,
isTemporary
})
}
]
});
return members;
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "FindByProjectId" }); throw new DatabaseError({ error, name: "FindByProjectId" });
} }
@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TIdentityProjectMembershipRoleDALFactory = ReturnType<typeof identityProjectMembershipRoleDALFactory>;
export const identityProjectMembershipRoleDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.IdentityProjectMembershipRole);
return orm;
};
@@ -1,15 +1,20 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import ms from "ms";
import { ProjectMembershipRole, TProjectRoles } from "@app/db/schemas"; import { ProjectMembershipRole } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { isAtLeastAsPrivileged } from "@app/lib/casl";
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn";
import { ActorType } from "../auth/auth-type"; import { ActorType } from "../auth/auth-type";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
import { TProjectDALFactory } from "../project/project-dal"; import { TProjectDALFactory } from "../project/project-dal";
import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types";
import { TProjectRoleDALFactory } from "../project-role/project-role-dal";
import { TIdentityProjectDALFactory } from "./identity-project-dal"; import { TIdentityProjectDALFactory } from "./identity-project-dal";
import { TIdentityProjectMembershipRoleDALFactory } from "./identity-project-membership-role-dal";
import { import {
TCreateProjectIdentityDTO, TCreateProjectIdentityDTO,
TDeleteProjectIdentityDTO, TDeleteProjectIdentityDTO,
@@ -19,7 +24,12 @@ import {
type TIdentityProjectServiceFactoryDep = { type TIdentityProjectServiceFactoryDep = {
identityProjectDAL: TIdentityProjectDALFactory; identityProjectDAL: TIdentityProjectDALFactory;
identityProjectMembershipRoleDAL: Pick<
TIdentityProjectMembershipRoleDALFactory,
"create" | "transaction" | "insertMany" | "delete"
>;
projectDAL: Pick<TProjectDALFactory, "findById">; projectDAL: Pick<TProjectDALFactory, "findById">;
projectRoleDAL: Pick<TProjectRoleDALFactory, "find">;
identityOrgMembershipDAL: Pick<TIdentityOrgDALFactory, "findOne">; identityOrgMembershipDAL: Pick<TIdentityOrgDALFactory, "findOne">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getProjectPermissionByRole">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getProjectPermissionByRole">;
}; };
@@ -30,7 +40,9 @@ export const identityProjectServiceFactory = ({
identityProjectDAL, identityProjectDAL,
permissionService, permissionService,
identityOrgMembershipDAL, identityOrgMembershipDAL,
projectDAL identityProjectMembershipRoleDAL,
projectDAL,
projectRoleDAL
}: TIdentityProjectServiceFactoryDep) => { }: TIdentityProjectServiceFactoryDep) => {
const createProjectIdentity = async ({ const createProjectIdentity = async ({
identityId, identityId,
@@ -70,11 +82,26 @@ export const identityProjectServiceFactory = ({
}); });
const isCustomRole = Boolean(customRole); const isCustomRole = Boolean(customRole);
const projectIdentity = await identityProjectDAL.create({ const projectIdentity = await identityProjectDAL.transaction(async (tx) => {
identityId, const identityProjectMembership = await identityProjectDAL.create(
projectId: project.id, {
role: isCustomRole ? ProjectMembershipRole.Custom : role, identityId,
roleId: customRole?.id projectId: project.id,
role: isCustomRole ? ProjectMembershipRole.Custom : role,
roleId: customRole?.id
},
tx
);
await identityProjectMembershipRoleDAL.create(
{
projectMembershipId: identityProjectMembership.id,
role: isCustomRole ? ProjectMembershipRole.Custom : role,
customRoleId: customRole?.id
},
tx
);
return identityProjectMembership;
}); });
return projectIdentity; return projectIdentity;
}; };
@@ -82,7 +109,7 @@ export const identityProjectServiceFactory = ({
const updateProjectIdentity = async ({ const updateProjectIdentity = async ({
projectId, projectId,
identityId, identityId,
role, roles,
actor, actor,
actorId, actorId,
actorOrgId actorOrgId
@@ -106,28 +133,51 @@ export const identityProjectServiceFactory = ({
if (!hasRequiredPriviledges) if (!hasRequiredPriviledges)
throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" });
let customRole: TProjectRoles | undefined; // validate custom roles input
if (role) { const customInputRoles = roles.filter(
const { permission: rolePermission, role: customOrgRole } = await permissionService.getProjectPermissionByRole( ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole)
role,
projectIdentity.projectId
);
const isCustomRole = Boolean(customOrgRole);
const hasRequiredNewRolePermission = isAtLeastAsPrivileged(permission, rolePermission);
if (!hasRequiredNewRolePermission)
throw new BadRequestError({ message: "Failed to create a more privileged identity" });
if (isCustomRole) customRole = customOrgRole;
}
const [updatedProjectIdentity] = await identityProjectDAL.update(
{ projectId, identityId: projectIdentity.identityId },
{
role: customRole ? ProjectMembershipRole.Custom : role,
roleId: customRole ? customRole.id : null
}
); );
return updatedProjectIdentity; const hasCustomRole = Boolean(customInputRoles.length);
const customRoles = hasCustomRole
? await projectRoleDAL.find({
projectId,
$in: { slug: customInputRoles.map(({ role }) => role) }
})
: [];
if (customRoles.length !== customInputRoles.length) throw new BadRequestError({ message: "Custom role not found" });
const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug);
const santiziedProjectMembershipRoles = roles.map((inputRole) => {
const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]);
if (!inputRole.isTemporary) {
return {
projectMembershipId: projectIdentity.id,
role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role,
customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null
};
}
// check cron or relative here later for now its just relative
const relativeTimeInMs = ms(inputRole.temporaryRange);
return {
projectMembershipId: projectIdentity.id,
role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role,
customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null,
isTemporary: true,
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
temporaryRange: inputRole.temporaryRange,
temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime),
temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs)
};
});
const updatedRoles = await identityProjectMembershipRoleDAL.transaction(async (tx) => {
await identityProjectMembershipRoleDAL.delete({ projectMembershipId: projectIdentity.id }, tx);
return identityProjectMembershipRoleDAL.insertMany(santiziedProjectMembershipRoles, tx);
});
return updatedRoles;
}; };
const deleteProjectIdentity = async ({ const deleteProjectIdentity = async ({
@@ -1,12 +1,26 @@
import { TProjectPermission } from "@app/lib/types"; import { TProjectPermission } from "@app/lib/types";
import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types";
export type TCreateProjectIdentityDTO = { export type TCreateProjectIdentityDTO = {
identityId: string; identityId: string;
role: string; role: string;
} & TProjectPermission; } & TProjectPermission;
export type TUpdateProjectIdentityDTO = { export type TUpdateProjectIdentityDTO = {
role: string; roles: (
| {
role: string;
isTemporary?: false;
}
| {
role: string;
isTemporary: true;
temporaryMode: ProjectUserMembershipTemporaryMode.Relative;
temporaryRange: string;
temporaryAccessStartTime: string;
}
)[];
identityId: string; identityId: string;
} & TProjectPermission; } & TProjectPermission;
+2 -2
View File
@@ -58,7 +58,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol
{ id: roleId, orgId }, { id: roleId, orgId },
{ ...data, permissions: data.permissions ? JSON.stringify(data.permissions) : undefined } { ...data, permissions: data.permissions ? JSON.stringify(data.permissions) : undefined }
); );
if (!updateRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); if (!updatedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" });
return updatedRole; return updatedRole;
}; };
@@ -66,7 +66,7 @@ export const orgRoleServiceFactory = ({ orgRoleDAL, permissionService }: TOrgRol
const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgId); const { permission } = await permissionService.getUserOrgPermission(userId, orgId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Role); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Delete, OrgPermissionSubjects.Role);
const [deletedRole] = await orgRoleDAL.delete({ id: roleId, orgId }); const [deletedRole] = await orgRoleDAL.delete({ id: roleId, orgId });
if (!deleteRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); if (!deletedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" });
return deletedRole; return deletedRole;
}; };
@@ -1,7 +1,7 @@
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; import { TableName, TUserEncryptionKeys } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
export type TProjectMembershipDALFactory = ReturnType<typeof projectMembershipDALFactory>; export type TProjectMembershipDALFactory = ReturnType<typeof projectMembershipDALFactory>;
@@ -11,32 +11,86 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
// special query // special query
const findAllProjectMembers = async (projectId: string) => { const findAllProjectMembers = async (projectId: string) => {
try { try {
const members = await db(TableName.ProjectMembership) const docs = await db(TableName.ProjectMembership)
.where({ projectId }) .where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId })
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
.join<TUserEncryptionKeys>( .join<TUserEncryptionKeys>(
TableName.UserEncryptionKey, TableName.UserEncryptionKey,
`${TableName.UserEncryptionKey}.userId`, `${TableName.UserEncryptionKey}.userId`,
`${TableName.Users}.id` `${TableName.Users}.id`
) )
.join(
TableName.ProjectUserMembershipRole,
`${TableName.ProjectUserMembershipRole}.projectMembershipId`,
`${TableName.ProjectMembership}.id`
)
.leftJoin(
TableName.ProjectRoles,
`${TableName.ProjectUserMembershipRole}.customRoleId`,
`${TableName.ProjectRoles}.id`
)
.select( .select(
db.ref("id").withSchema(TableName.ProjectMembership), db.ref("id").withSchema(TableName.ProjectMembership),
db.ref("projectId").withSchema(TableName.ProjectMembership),
db.ref("role").withSchema(TableName.ProjectMembership),
db.ref("roleId").withSchema(TableName.ProjectMembership),
db.ref("isGhost").withSchema(TableName.Users), db.ref("isGhost").withSchema(TableName.Users),
db.ref("username").withSchema(TableName.Users), db.ref("username").withSchema(TableName.Users),
db.ref("email").withSchema(TableName.Users), db.ref("email").withSchema(TableName.Users),
db.ref("publicKey").withSchema(TableName.UserEncryptionKey), db.ref("publicKey").withSchema(TableName.UserEncryptionKey),
db.ref("firstName").withSchema(TableName.Users), db.ref("firstName").withSchema(TableName.Users),
db.ref("lastName").withSchema(TableName.Users), db.ref("lastName").withSchema(TableName.Users),
db.ref("id").withSchema(TableName.Users).as("userId") db.ref("id").withSchema(TableName.Users).as("userId"),
db.ref("role").withSchema(TableName.ProjectUserMembershipRole),
db.ref("id").withSchema(TableName.ProjectUserMembershipRole).as("membershipRoleId"),
db.ref("customRoleId").withSchema(TableName.ProjectUserMembershipRole),
db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"),
db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"),
db.ref("temporaryMode").withSchema(TableName.ProjectUserMembershipRole),
db.ref("isTemporary").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole)
) )
.where({ isGhost: false }); .where({ isGhost: false });
return members.map(({ username, email, firstName, lastName, publicKey, isGhost, ...data }) => ({
...data, const members = sqlNestRelationships({
user: { username, email, firstName, lastName, id: data.userId, publicKey, isGhost } data: docs,
})); parentMapper: ({ email, firstName, username, lastName, publicKey, isGhost, id, userId }) => ({
id,
userId,
projectId,
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }
}),
key: "id",
childrenMapper: [
{
label: "roles" as const,
key: "membershipRoleId",
mapper: ({
role,
customRoleId,
customRoleName,
customRoleSlug,
membershipRoleId,
temporaryRange,
temporaryMode,
temporaryAccessEndTime,
temporaryAccessStartTime,
isTemporary
}) => ({
id: membershipRoleId,
role,
customRoleId,
customRoleName,
customRoleSlug,
temporaryRange,
temporaryMode,
temporaryAccessEndTime,
temporaryAccessStartTime,
isTemporary
})
}
]
});
return members;
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "Find all project members" }); throw new DatabaseError({ error, name: "Find all project members" });
} }
@@ -1,14 +1,13 @@
/* eslint-disable no-await-in-loop */ /* eslint-disable no-await-in-loop */
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import ms from "ms";
import { import {
OrgMembershipStatus,
ProjectMembershipRole, ProjectMembershipRole,
ProjectVersion, ProjectVersion,
SecretKeyEncoding, SecretKeyEncoding,
TableName, TableName,
TProjectMemberships, TProjectMemberships
TUsers
} from "@app/db/schemas"; } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
@@ -29,22 +28,24 @@ import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TUserDALFactory } from "../user/user-dal"; import { TUserDALFactory } from "../user/user-dal";
import { TProjectMembershipDALFactory } from "./project-membership-dal"; import { TProjectMembershipDALFactory } from "./project-membership-dal";
import { import {
ProjectUserMembershipTemporaryMode,
TAddUsersToWorkspaceDTO, TAddUsersToWorkspaceDTO,
TAddUsersToWorkspaceNonE2EEDTO, TAddUsersToWorkspaceNonE2EEDTO,
TDeleteProjectMembershipOldDTO, TDeleteProjectMembershipOldDTO,
TDeleteProjectMembershipsDTO, TDeleteProjectMembershipsDTO,
TGetProjectMembershipDTO, TGetProjectMembershipDTO,
TInviteUserToProjectDTO,
TUpdateProjectMembershipDTO TUpdateProjectMembershipDTO
} from "./project-membership-types"; } from "./project-membership-types";
import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal";
type TProjectMembershipServiceFactoryDep = { type TProjectMembershipServiceFactoryDep = {
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
smtpService: TSmtpService; smtpService: TSmtpService;
projectBotDAL: TProjectBotDALFactory; projectBotDAL: TProjectBotDALFactory;
projectMembershipDAL: TProjectMembershipDALFactory; projectMembershipDAL: TProjectMembershipDALFactory;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "insertMany" | "find" | "delete">;
userDAL: Pick<TUserDALFactory, "findById" | "findOne" | "findUserByProjectMembershipId" | "find">; userDAL: Pick<TUserDALFactory, "findById" | "findOne" | "findUserByProjectMembershipId" | "find">;
projectRoleDAL: Pick<TProjectRoleDALFactory, "findOne">; projectRoleDAL: Pick<TProjectRoleDALFactory, "find">;
orgDAL: Pick<TOrgDALFactory, "findMembership" | "findOrgMembersByUsername">; orgDAL: Pick<TOrgDALFactory, "findMembership" | "findOrgMembersByUsername">;
projectDAL: Pick<TProjectDALFactory, "findById" | "findProjectGhostUser" | "transaction">; projectDAL: Pick<TProjectDALFactory, "findById" | "findProjectGhostUser" | "transaction">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany">; projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany">;
@@ -56,6 +57,7 @@ export type TProjectMembershipServiceFactory = ReturnType<typeof projectMembersh
export const projectMembershipServiceFactory = ({ export const projectMembershipServiceFactory = ({
permissionService, permissionService,
projectMembershipDAL, projectMembershipDAL,
projectUserMembershipRoleDAL,
smtpService, smtpService,
projectRoleDAL, projectRoleDAL,
projectBotDAL, projectBotDAL,
@@ -72,82 +74,6 @@ export const projectMembershipServiceFactory = ({
return projectMembershipDAL.findAllProjectMembers(projectId); return projectMembershipDAL.findAllProjectMembers(projectId);
}; };
const inviteUserToProject = async ({ actorId, actor, actorOrgId, projectId, emails }: TInviteUserToProjectDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Member);
const invitees: TUsers[] = [];
const project = await projectDAL.findById(projectId);
const users = await userDAL.find({
$in: { email: emails }
});
await projectDAL.transaction(async (tx) => {
for (const invitee of users) {
if (!invitee.isAccepted)
throw new BadRequestError({
message: "Failed to validate invitee",
name: "Invite user to project"
});
const inviteeMembership = await projectMembershipDAL.findOne(
{
userId: invitee.id,
projectId
},
tx
);
if (inviteeMembership) {
throw new BadRequestError({
message: "Existing member of project",
name: "Invite user to project"
});
}
const inviteeMembershipOrg = await orgDAL.findMembership({
userId: invitee.id,
orgId: project.orgId,
status: OrgMembershipStatus.Accepted
});
if (!inviteeMembershipOrg) {
throw new BadRequestError({
message: "Failed to validate invitee org membership",
name: "Invite user to project"
});
}
await projectMembershipDAL.create(
{
userId: invitee.id,
projectId,
role: ProjectMembershipRole.Member
},
tx
);
invitees.push(invitee);
}
const appCfg = getConfig();
await smtpService.sendMail({
template: SmtpTemplates.WorkspaceInvite,
subjectLine: "Infisical project invitation",
recipients: invitees.filter((i) => i.email).map((i) => i.email as string),
substitutions: {
workspaceName: project.name,
callback_url: `${appCfg.SITE_URL}/login`
}
});
});
const latestKey = await projectKeyDAL.findLatestProjectKey(actorId, projectId);
return { invitees, latestKey };
};
const addUsersToProject = async ({ const addUsersToProject = async ({
projectId, projectId,
actorId, actorId,
@@ -176,17 +102,16 @@ export const projectMembershipServiceFactory = ({
if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" }); if (existingMembers.length) throw new BadRequestError({ message: "Some users are already part of project" });
await projectMembershipDAL.transaction(async (tx) => { await projectMembershipDAL.transaction(async (tx) => {
await projectMembershipDAL.insertMany( const projectMemberships = await projectMembershipDAL.insertMany(
orgMembers.map(({ userId, id: membershipId }) => { orgMembers.map(({ userId }) => ({
const role = projectId,
members.find((i) => i.orgMembershipId === membershipId)?.projectRole || ProjectMembershipRole.Member; userId: userId as string,
role: ProjectMembershipRole.Member
return { })),
projectId, tx
userId: userId as string, );
role await projectUserMembershipRoleDAL.insertMany(
}; projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: ProjectMembershipRole.Member })),
}),
tx tx
); );
const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId); const encKeyGroupByOrgMembId = groupBy(members, (i) => i.orgMembershipId);
@@ -296,7 +221,7 @@ export const projectMembershipServiceFactory = ({
const members: TProjectMemberships[] = []; const members: TProjectMemberships[] = [];
await projectMembershipDAL.transaction(async (tx) => { await projectMembershipDAL.transaction(async (tx) => {
const result = await projectMembershipDAL.insertMany( const projectMemberships = await projectMembershipDAL.insertMany(
orgMembers.map(({ user }) => ({ orgMembers.map(({ user }) => ({
projectId, projectId,
userId: user.id, userId: user.id,
@@ -304,8 +229,12 @@ export const projectMembershipServiceFactory = ({
})), })),
tx tx
); );
await projectUserMembershipRoleDAL.insertMany(
projectMemberships.map(({ id }) => ({ projectMembershipId: id, role: ProjectMembershipRole.Member })),
tx
);
members.push(...result); members.push(...projectMemberships);
const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId); const encKeyGroupByOrgMembId = groupBy(newWsMembers, (i) => i.orgMembershipId);
await projectKeyDAL.insertMany( await projectKeyDAL.insertMany(
@@ -346,43 +275,71 @@ export const projectMembershipServiceFactory = ({
actorOrgId, actorOrgId,
projectId, projectId,
membershipId, membershipId,
role roles
}: TUpdateProjectMembershipDTO) => { }: TUpdateProjectMembershipDTO) => {
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member);
const membershipUser = await userDAL.findUserByProjectMembershipId(membershipId); const membershipUser = await userDAL.findUserByProjectMembershipId(membershipId);
if (membershipUser?.isGhost || membershipUser?.projectId !== projectId) {
if (membershipUser?.isGhost) {
throw new BadRequestError({ throw new BadRequestError({
message: "Unauthorized member update", message: "Unauthorized member update",
name: "Update project membership" name: "Update project membership"
}); });
} }
const isCustomRole = !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole); // validate custom roles input
if (isCustomRole) { const customInputRoles = roles.filter(
const customRole = await projectRoleDAL.findOne({ slug: role, projectId }); ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole)
if (!customRole) throw new BadRequestError({ name: "Update project membership", message: "Role not found" }); );
const project = await projectDAL.findById(customRole.projectId); const hasCustomRole = Boolean(customInputRoles.length);
const plan = await licenseService.getPlan(project.orgId); if (hasCustomRole) {
const plan = await licenseService.getPlan(actorOrgId as string);
if (!plan?.rbac) if (!plan?.rbac)
throw new BadRequestError({ throw new BadRequestError({
message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member." message: "Failed to assign custom role due to RBAC restriction. Upgrade plan to assign custom role to member."
}); });
const [membership] = await projectMembershipDAL.update(
{ id: membershipId, projectId },
{
role: ProjectMembershipRole.Custom,
roleId: customRole.id
}
);
return membership;
} }
const [membership] = await projectMembershipDAL.update({ id: membershipId, projectId }, { role, roleId: null }); const customRoles = hasCustomRole
return membership; ? await projectRoleDAL.find({
projectId,
$in: { slug: customInputRoles.map(({ role }) => role) }
})
: [];
if (customRoles.length !== customInputRoles.length) throw new BadRequestError({ message: "Custom role not found" });
const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug);
const santiziedProjectMembershipRoles = roles.map((inputRole) => {
const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]);
if (!inputRole.isTemporary) {
return {
projectMembershipId: membershipId,
role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role,
customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null
};
}
// check cron or relative here later for now its just relative
const relativeTimeInMs = ms(inputRole.temporaryRange);
return {
projectMembershipId: membershipId,
role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role,
customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null,
isTemporary: true,
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
temporaryRange: inputRole.temporaryRange,
temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime),
temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs)
};
});
const updatedRoles = await projectMembershipDAL.transaction(async (tx) => {
await projectUserMembershipRoleDAL.delete({ projectMembershipId: membershipId }, tx);
return projectUserMembershipRoleDAL.insertMany(santiziedProjectMembershipRoles, tx);
});
return updatedRoles;
}; };
// This is old and should be removed later. Its not used anywhere, but it is exposed in our API. So to avoid breaking changes, we are keeping it for now. // This is old and should be removed later. Its not used anywhere, but it is exposed in our API. So to avoid breaking changes, we are keeping it for now.
@@ -481,7 +438,6 @@ export const projectMembershipServiceFactory = ({
return { return {
getProjectMemberships, getProjectMemberships,
inviteUserToProject,
updateProjectMembership, updateProjectMembership,
addUsersToProjectNonE2EE, addUsersToProjectNonE2EE,
deleteProjectMemberships, deleteProjectMemberships,
@@ -1,7 +1,9 @@
import { ProjectMembershipRole } from "@app/db/schemas";
import { TProjectPermission } from "@app/lib/types"; import { TProjectPermission } from "@app/lib/types";
export type TGetProjectMembershipDTO = TProjectPermission; export type TGetProjectMembershipDTO = TProjectPermission;
export enum ProjectUserMembershipTemporaryMode {
Relative = "relative"
}
export type TInviteUserToProjectDTO = { export type TInviteUserToProjectDTO = {
emails: string[]; emails: string[];
@@ -9,7 +11,19 @@ export type TInviteUserToProjectDTO = {
export type TUpdateProjectMembershipDTO = { export type TUpdateProjectMembershipDTO = {
membershipId: string; membershipId: string;
role: string; roles: (
| {
role: string;
isTemporary?: false;
}
| {
role: string;
isTemporary: true;
temporaryMode: ProjectUserMembershipTemporaryMode.Relative;
temporaryRange: string;
temporaryAccessStartTime: string;
}
)[];
} & TProjectPermission; } & TProjectPermission;
export type TDeleteProjectMembershipOldDTO = { export type TDeleteProjectMembershipOldDTO = {
@@ -27,7 +41,6 @@ export type TAddUsersToWorkspaceDTO = {
orgMembershipId: string; orgMembershipId: string;
workspaceEncryptedKey: string; workspaceEncryptedKey: string;
workspaceEncryptedNonce: string; workspaceEncryptedNonce: string;
projectRole: ProjectMembershipRole;
}[]; }[];
} & TProjectPermission; } & TProjectPermission;
@@ -0,0 +1,10 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TProjectUserMembershipRoleDALFactory = ReturnType<typeof projectUserMembershipRoleDALFactory>;
export const projectUserMembershipRoleDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.ProjectUserMembershipRole);
return orm;
};
@@ -76,7 +76,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }:
const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); const { permission } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Role); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Role);
const [deletedRole] = await projectRoleDAL.delete({ id: roleId, projectId }); const [deletedRole] = await projectRoleDAL.delete({ id: roleId, projectId });
if (!deleteRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); if (!deletedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" });
return deletedRole; return deletedRole;
}; };
@@ -92,7 +92,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }:
name: "Admin", name: "Admin",
slug: ProjectMembershipRole.Admin, slug: ProjectMembershipRole.Admin,
description: "Complete administration access over the project", description: "Complete administration access over the project",
permissions: packRules(projectAdminPermissions.rules), permissions: packRules(projectAdminPermissions),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date() updatedAt: new Date()
}, },
@@ -102,7 +102,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }:
name: "Developer", name: "Developer",
slug: ProjectMembershipRole.Member, slug: ProjectMembershipRole.Member,
description: "Non-administrative role in an project", description: "Non-administrative role in an project",
permissions: packRules(projectMemberPermissions.rules), permissions: packRules(projectMemberPermissions),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date() updatedAt: new Date()
}, },
@@ -112,7 +112,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }:
name: "Viewer", name: "Viewer",
slug: ProjectMembershipRole.Viewer, slug: ProjectMembershipRole.Viewer,
description: "Non-administrative role in an project", description: "Non-administrative role in an project",
permissions: packRules(projectViewerPermission.rules), permissions: packRules(projectViewerPermission),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date() updatedAt: new Date()
}, },
@@ -122,7 +122,7 @@ export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService }:
name: "No Access", name: "No Access",
slug: "no-access", slug: "no-access",
description: "No access to any resources in the project", description: "No access to any resources in the project",
permissions: packRules(projectNoAccessPermissions.rules), permissions: packRules(projectNoAccessPermissions),
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date() updatedAt: new Date()
}, },
@@ -38,6 +38,7 @@ import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
import { TSecretDALFactory } from "../secret/secret-dal"; import { TSecretDALFactory } from "../secret/secret-dal";
import { TSecretVersionDALFactory } from "../secret/secret-version-dal"; import { TSecretVersionDALFactory } from "../secret/secret-version-dal";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
@@ -58,9 +59,9 @@ type TProjectQueueFactoryDep = {
projectBotDAL: Pick<TProjectBotDALFactory, "findOne" | "delete" | "create">; projectBotDAL: Pick<TProjectBotDALFactory, "findOne" | "delete" | "create">;
orgService: Pick<TOrgServiceFactory, "addGhostUser">; orgService: Pick<TOrgServiceFactory, "addGhostUser">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create">; projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create">;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">;
integrationAuthDAL: TIntegrationAuthDALFactory; integrationAuthDAL: TIntegrationAuthDALFactory;
userDAL: Pick<TUserDALFactory, "findUserEncKeyByUserId">; userDAL: Pick<TUserDALFactory, "findUserEncKeyByUserId">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "find">; projectEnvDAL: Pick<TProjectEnvDALFactory, "find">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "transaction" | "updateById" | "setProjectUpgradeStatus" | "find">; projectDAL: Pick<TProjectDALFactory, "findOne" | "transaction" | "updateById" | "setProjectUpgradeStatus" | "find">;
orgDAL: Pick<TOrgDALFactory, "findMembership">; orgDAL: Pick<TOrgDALFactory, "findMembership">;
@@ -81,7 +82,8 @@ export const projectQueueFactory = ({
orgDAL, orgDAL,
projectDAL, projectDAL,
orgService, orgService,
projectMembershipDAL projectMembershipDAL,
projectUserMembershipRoleDAL
}: TProjectQueueFactoryDep) => { }: TProjectQueueFactoryDep) => {
const upgradeProject = async (dto: TQueueJobTypes["upgrade-project-to-ghost"]["payload"]) => { const upgradeProject = async (dto: TQueueJobTypes["upgrade-project-to-ghost"]["payload"]) => {
await queueService.queue(QueueName.UpgradeProjectToGhost, QueueJobs.UpgradeProjectToGhost, dto, { await queueService.queue(QueueName.UpgradeProjectToGhost, QueueJobs.UpgradeProjectToGhost, dto, {
@@ -227,7 +229,7 @@ export const projectQueueFactory = ({
); );
// Create a membership for the ghost user // Create a membership for the ghost user
await projectMembershipDAL.create( const projectMembership = await projectMembershipDAL.create(
{ {
projectId: project.id, projectId: project.id,
userId: ghostUser.user.id, userId: ghostUser.user.id,
@@ -235,6 +237,10 @@ export const projectQueueFactory = ({
}, },
tx tx
); );
await projectUserMembershipRoleDAL.create(
{ projectMembershipId: projectMembership.id, role: ProjectMembershipRole.Admin },
tx
);
// If a bot already exists, delete it // If a bot already exists, delete it
if (existingBot) { if (existingBot) {
@@ -17,11 +17,13 @@ import { TProjectPermission } from "@app/lib/types";
import { ActorType } from "../auth/auth-type"; import { ActorType } from "../auth/auth-type";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal"; import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal";
import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal";
import { TOrgServiceFactory } from "../org/org-service"; import { TOrgServiceFactory } from "../org/org-service";
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal"; import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal"; import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal";
import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TUserDALFactory } from "../user/user-dal"; import { TUserDALFactory } from "../user/user-dal";
@@ -50,9 +52,11 @@ type TProjectServiceFactoryDep = {
projectEnvDAL: Pick<TProjectEnvDALFactory, "insertMany" | "find">; projectEnvDAL: Pick<TProjectEnvDALFactory, "insertMany" | "find">;
identityOrgMembershipDAL: TIdentityOrgDALFactory; identityOrgMembershipDAL: TIdentityOrgDALFactory;
identityProjectDAL: TIdentityProjectDALFactory; identityProjectDAL: TIdentityProjectDALFactory;
identityProjectMembershipRoleDAL: Pick<TIdentityProjectMembershipRoleDALFactory, "create">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "create" | "findLatestProjectKey" | "delete" | "find" | "insertMany">; projectKeyDAL: Pick<TProjectKeyDALFactory, "create" | "findLatestProjectKey" | "delete" | "find" | "insertMany">;
projectBotDAL: Pick<TProjectBotDALFactory, "create" | "findById" | "delete" | "findOne">; projectBotDAL: Pick<TProjectBotDALFactory, "create" | "findById" | "delete" | "findOne">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create" | "findProjectGhostUser" | "findOne">; projectMembershipDAL: Pick<TProjectMembershipDALFactory, "create" | "findProjectGhostUser" | "findOne">;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">;
secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "create">; secretBlindIndexDAL: Pick<TSecretBlindIndexDALFactory, "create">;
permissionService: TPermissionServiceFactory; permissionService: TPermissionServiceFactory;
orgService: Pick<TOrgServiceFactory, "addGhostUser">; orgService: Pick<TOrgServiceFactory, "addGhostUser">;
@@ -75,7 +79,9 @@ export const projectServiceFactory = ({
secretBlindIndexDAL, secretBlindIndexDAL,
projectMembershipDAL, projectMembershipDAL,
projectEnvDAL, projectEnvDAL,
licenseService licenseService,
projectUserMembershipRoleDAL,
identityProjectMembershipRoleDAL
}: TProjectServiceFactoryDep) => { }: TProjectServiceFactoryDep) => {
/* /*
* Create workspace. Make user the admin * Create workspace. Make user the admin
@@ -114,14 +120,18 @@ export const projectServiceFactory = ({
tx tx
); );
// set ghost user as admin of project // set ghost user as admin of project
await projectMembershipDAL.create( const projectMembership = await projectMembershipDAL.create(
{ {
userId: ghostUser.user.id, userId: ghostUser.user.id,
role: ProjectMembershipRole.Admin, projectId: project.id,
projectId: project.id role: ProjectMembershipRole.Admin
}, },
tx tx
); );
await projectUserMembershipRoleDAL.create(
{ projectMembershipId: projectMembership.id, role: ProjectMembershipRole.Admin },
tx
);
// generate the blind index for project // generate the blind index for project
await secretBlindIndexDAL.create( await secretBlindIndexDAL.create(
@@ -213,7 +223,7 @@ export const projectServiceFactory = ({
}); });
// Create a membership for the user // Create a membership for the user
await projectMembershipDAL.create( const userProjectMembership = await projectMembershipDAL.create(
{ {
projectId: project.id, projectId: project.id,
userId: user.id, userId: user.id,
@@ -221,6 +231,10 @@ export const projectServiceFactory = ({
}, },
tx tx
); );
await projectUserMembershipRoleDAL.create(
{ projectMembershipId: userProjectMembership.id, role: projectAdmin.projectRole },
tx
);
// Create a project key for the user // Create a project key for the user
await projectKeyDAL.create( await projectKeyDAL.create(
@@ -266,7 +280,7 @@ export const projectServiceFactory = ({
}); });
const isCustomRole = Boolean(customRole); const isCustomRole = Boolean(customRole);
await identityProjectDAL.create( const identityProjectMembership = await identityProjectDAL.create(
{ {
identityId: actorId, identityId: actorId,
projectId: project.id, projectId: project.id,
@@ -275,6 +289,15 @@ export const projectServiceFactory = ({
}, },
tx tx
); );
await identityProjectMembershipRoleDAL.create(
{
projectMembershipId: identityProjectMembership.id,
role: isCustomRole ? ProjectMembershipRole.Custom : ProjectMembershipRole.Admin,
customRoleId: customRole?.id
},
tx
);
} }
return { return {
@@ -350,11 +373,11 @@ export const projectServiceFactory = ({
}; };
const upgradeProject = async ({ projectId, actor, actorId, userPrivateKey }: TUpgradeProjectDTO) => { const upgradeProject = async ({ projectId, actor, actorId, userPrivateKey }: TUpgradeProjectDTO) => {
const { permission, membership } = await permissionService.getProjectPermission(actor, actorId, projectId); const { permission, hasRole } = await permissionService.getProjectPermission(actor, actorId, projectId);
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project);
if (membership?.role !== ProjectMembershipRole.Admin) { if (!hasRole(ProjectMembershipRole.Admin)) {
throw new ForbiddenRequestError({ throw new ForbiddenRequestError({
message: "User must be admin" message: "User must be admin"
}); });
@@ -37,8 +37,8 @@ export const secretBlindIndexServiceFactory = ({
}; };
const getProjectSecrets = async ({ projectId, actorId, actor }: TGetProjectSecretsDTO) => { const getProjectSecrets = async ({ projectId, actorId, actor }: TGetProjectSecretsDTO) => {
const { membership } = await permissionService.getProjectPermission(actor, actorId, projectId); const { hasRole } = await permissionService.getProjectPermission(actor, actorId, projectId);
if (membership?.role !== ProjectMembershipRole.Admin) { if (!hasRole(ProjectMembershipRole.Admin)) {
throw new UnauthorizedError({ message: "User must be admin" }); throw new UnauthorizedError({ message: "User must be admin" });
} }
@@ -53,8 +53,8 @@ export const secretBlindIndexServiceFactory = ({
actorOrgId, actorOrgId,
secretsToUpdate secretsToUpdate
}: TUpdateProjectSecretNameDTO) => { }: TUpdateProjectSecretNameDTO) => {
const { membership } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId); const { hasRole } = await permissionService.getProjectPermission(actor, actorId, projectId, actorOrgId);
if (membership?.role !== ProjectMembershipRole.Admin) { if (!hasRole(ProjectMembershipRole.Admin)) {
throw new UnauthorizedError({ message: "User must be admin" }); throw new UnauthorizedError({ message: "User must be admin" });
} }
+5
View File
@@ -97,6 +97,11 @@ func RequireLogin() {
} }
} }
func IsLoggedIn() bool {
configFile, _ := GetConfigFile()
return configFile.LoggedInUserEmail != ""
}
func RequireServiceToken() { func RequireServiceToken() {
serviceToken := os.Getenv(INFISICAL_TOKEN_NAME) serviceToken := os.Getenv(INFISICAL_TOKEN_NAME)
if serviceToken == "" { if serviceToken == "" {
+2
View File
@@ -427,6 +427,8 @@ func ExpandSecrets(secrets []models.SingleEnvironmentVariable, auth models.Expan
refSecs, err = GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: env, InfisicalToken: auth.InfisicalToken, SecretsPath: secPath}, projectConfigPathDir) refSecs, err = GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: env, InfisicalToken: auth.InfisicalToken, SecretsPath: secPath}, projectConfigPathDir)
} else if auth.UniversalAuthAccessToken != "" { } else if auth.UniversalAuthAccessToken != "" {
refSecs, err = GetAllEnvironmentVariables((models.GetAllSecretsParameters{Environment: env, UniversalAuthAccessToken: auth.UniversalAuthAccessToken, SecretsPath: secPath, WorkspaceId: sec.WorkspaceId}), projectConfigPathDir) refSecs, err = GetAllEnvironmentVariables((models.GetAllSecretsParameters{Environment: env, UniversalAuthAccessToken: auth.UniversalAuthAccessToken, SecretsPath: secPath, WorkspaceId: sec.WorkspaceId}), projectConfigPathDir)
} else if IsLoggedIn() {
refSecs, err = GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: env, SecretsPath: secPath}, projectConfigPathDir)
} else { } else {
HandleError(errors.New("no authentication provided"), "Please provide authentication to fetch secrets") HandleError(errors.New("no authentication provided"), "Please provide authentication to fetch secrets")
} }
+35
View File
@@ -135,3 +135,38 @@ Another option to point the CLI to your self hosted Infisical instance is to set
infisical <any-command> --domain="https://your-self-hosted-infisical.com/api" infisical <any-command> --domain="https://your-self-hosted-infisical.com/api"
``` ```
</Accordion> </Accordion>
## History
Your terminal keeps a history with the commands you run. When you create Infisical secrets directly from your terminal, they'll stay there for a while.
For security and privacy concerns, we recommend you to configure your terminal to ignore those specific Infisical commands.
<Accordion title="Ignore commands">
<Tabs>
<Tab title="Unix/Linux">
<Tip>
`$HOME/.profile` is pretty common but, you could place it under `$HOME/.profile.d/infisical.sh` or any profile file run at login
</Tip>
```bash
cat <<EOF >> $HOME/.profile && source $HOME/.profile
# Ignoring specific Infisical CLI commands
DEFAULT_HISTIGNORE=$HISTIGNORE
export HISTIGNORE="*infisical secrets set*:$DEFAULT_HISTIGNORE"
EOF
```
</Tab>
<Tab title="Windows">
If you're on WSL, then you can use the Unix/Linux method.
<Tip>
Here's some [documentation](https://superuser.com/a/1658331) about how to clear the terminal history, in PowerShell and CMD
</Tip>
</Tab>
</Tabs>
</Accordion>
@@ -0,0 +1,143 @@
---
title: "AWS IAM User"
description: "Rotated access key id and secret key of AWS IAM Users"
---
Infisical's AWS IAM User secret rotation capability lets you update the **Access key** and **Secret access key** credentials of a target IAM user from within Infisical
at a specified interval or on-demand.
## Workflow
The typical workflow for using the AWS IAM User rotation strategy consists of four steps:
1. Creating the target IAM user whose credentials you wish to rotate.
2. Creating the managing IAM user used by Infisical to rotate the credentials of the target IAM user.
3. Configuring the rotation strategy in Infisical with the credentials of the managing IAM user.
4. Pressing the **Rotate** button in the Infisical dashboard to trigger the rotation of the target IAM user's credentials. The strategy can also be configured to rotate the credentials automatically at a specified interval.
In the following steps, we explore the end-to-end workflow for setting up this strategy in Infisical.
<Steps>
<Step title="Create the target IAM user">
To begin, create an IAM user whose credentials you wish to rotate. If you already have an IAM user,
then you can skip this step.
</Step>
<Step title="Create the managing IAM user">
Next, create another IAM user to be used by Infisical to rotate the credentials of the IAM user in the previous step.
2.1. In your AWS console, head to IAM > Access management > Users and press **Create user**.
![iam user secret rotation create user](../../../images/platform/secret-rotation/aws-iam/rotation-manager-create-user.png)
2.2. Next, give the user a username like **infisical-rotation-manager** and press **Next**.
![iam user secret rotation username](../../../images/platform/secret-rotation/aws-iam/rotation-manager-username.png)
2.3. Next, in the **Set permissions** step, select **Attach policies directly** and then press **Create policy**.
![iam user secret rotation create policy](../../../images/platform/secret-rotation/aws-iam/rotation-manager-create-policy.png)
2.4. Next, in the **Policy editor**, paste the following JSON and press **Next**:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": [
"iam:DeleteAccessKey",
"iam:GetAccessKeyLastUsed",
"iam:CreateAccessKey"
],
"Resource": "*"
}
]
}
```
<Note>
The IAM policy above uses the wildcard option in Resource: "*".
You may want to restrict the policy to a specific path, and make any adjustments as necessary, to control access for the managing user in production.
Read more about this [here](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/).
</Note>
In the **Review and create** step, give the policy a name like **infisical-rotation-manager**, press **Create policy** to finish creating the policy.
![iam user secret rotation policy review](../../../images/platform/secret-rotation/aws-iam/rotation-manager-policy-review.png)
2.5. Back in the **Set permissions** step from step 2.3, refresh the policy list and search for the policy you just created from step 2.4.
Select the policy and press **Next**.
![iam user secret rotation attach policy](../../../images/platform/secret-rotation/aws-iam/rotation-manager-attach-policy.png)
In the **Review and create** step, press **Create user** to finish creating the IAM user.
![iam user secret rotation manager user review](../../../images/platform/secret-rotation/aws-iam/rotation-manager-user-review.png)
2.5. Having created the user, head to its Security credentials > Access keys and press **Create access key**.
Follow the subsequent steps to create the **access key** and **secret access key** credential pair for the user.
![iam user secret rotation manager create access key](../../../images/platform/secret-rotation/aws-iam/rotation-manager-create-access-key.png)
At the end of the flow, copy the **Access key** and **Secret access key** to use when configuring the AWS IAM User rotation strategy back in Infisical next.
![iam user secret rotation manager access keys](../../../images/platform/secret-rotation/aws-iam/rotation-manager-access-keys.png)
</Step>
<Step title="Configure the AWS IAM User secret rotation strategy in Infisical">
3.1. Back in Infisical, head to the Project > Secrets > Environment and path where you want the rotated AWS IAM credentials to appear and create two placeholder secrets.
In this example, we'll create two secrets called `AWS_ACCESS_KEY` and `AWS_SECRET_ACCESS_KEY`.
![iam user secret rotation secrets](../../../images/platform/secret-rotation/aws-iam/rotation-config-secrets.png)
3.2. Next, in the **Secret Rotation** tab, press on the **AWS IAM** tile to configure the AWS IAM User rotation strategy.
![iam user secret rotation select aws iam user method](../../../images/platform/secret-rotation/aws-iam/rotations-select-aws-iam-user.png)
3.3. Input the configuration details for the AWS IAM User rotation strategy obtained from steps 1 and 2:
![iam user secret rotation config 1](../../../images/platform/secret-rotation/aws-iam/rotation-config-1.png)
Here's some guidance on each field:
- Manager User Access Key: The managing IAM user's access key from step 2.5.
- Manager User Secret Key: The managing IAM user's secret access key from step 2.5.
- Manager User AWS Region: The [AWS region](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.RegionsAndAvailabilityZones.html) for Infisical to make requests to such as `us-east-1`.
- IAM Username: The IAM username of the user from step 1.
Next, specify the output secret mappings configuration for the rotated AWS IAM credentials; this is the secrets whose values will be replaced with new credentials after each rotation.
Here, you can also specify a rotation interval for the credentials to be automatically rotated periodically.
In this example, we want to map the output of the rotated AWS IAM credentials to the secrets that we created in step 3.1 (i.e. `AWS_ACCESS_KEY` and `AWS_SECRET_ACCESS_KEY`).
![iam user secret rotation config 2](../../../images/platform/secret-rotation/aws-iam/rotation-config-2.png)
Finally, press **Submit** to create the secret rotation strategy.
</Step>
<Step title="Rotate secrets in Infisical">
You should now see the AWS IAM User rotation strategy listed in the **Secret Rotation** tab.
To manually trigger a rotation, you can press the **Rotate** button on the strategy.
Once triggered, the secrets in step 3.1 should be updated with new rotated credential values.
![iam user secret rotations aws iam user](../../../images/platform/secret-rotation/aws-iam/rotations-aws-iam-user.png)
</Step>
</Steps>
**FAQ**
<AccordionGroup>
<Accordion title="Why are my AWS IAM credentials not rotating?">
There are a few reasons for why this might happen:
- The strategy configuration is invalid (e.g. the managing IAM user's credentials are incorrect, the target IAM username is incorrect, etc.).
- The managing IAM user is insufficently permissioned to rotate the credentials of the target IAM user. For instance, you may have setup [paths](https://aws.amazon.com/blogs/security/optimize-aws-administration-with-iam-paths/) for the managing IAM user and the policy does not have the necessary permissions to rotate the credentials.
- The target IAM user already has 2 access keys configured in AWS; you should delete one of the access keys to allow for rotation.
</Accordion>
</AccordionGroup>
Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 691 KiB

+2 -1
View File
@@ -134,7 +134,8 @@
"documentation/platform/secret-rotation/overview", "documentation/platform/secret-rotation/overview",
"documentation/platform/secret-rotation/sendgrid", "documentation/platform/secret-rotation/sendgrid",
"documentation/platform/secret-rotation/postgres", "documentation/platform/secret-rotation/postgres",
"documentation/platform/secret-rotation/mysql" "documentation/platform/secret-rotation/mysql",
"documentation/platform/secret-rotation/aws-iam"
] ]
}, },
{ {
@@ -0,0 +1 @@
<svg width="1306" height="2500" viewBox="0 0 256 490" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M21 165.75l-21 6.856 21.75 2.519-.75-9.375M19.955 206.806L128 213.714l108.045-6.908L128 185.75 19.955 206.806M234.5 175.125l21.5-2.519-21.5-5.731v8.25" fill="#3C4929"/><path d="M157.387 352.929l56.606 13.396-56.756 17.116.15-30.512" fill="#B7CA9D"/><path d="M19.955 92.221V54.019L128 0l.482.405-.248 48.496-.234.102-.405 1.117-59.098 23.856-.542 84.037 31.452-5.29L128 147.002V490.03l-32.369-16.177v-45.771l-28.354-11.338V202.069l-47.322 4.737v-38.195L0 172.606v-72.408l19.955-7.977" fill="#4B612C"/><path d="M99.408 152.727l-32.131 6.424V73.28l32.131 10.018v69.429M183.925 27.959l52.106 26.06v38.202L256 100.198V172.6l-19.969-3.989v38.195l-25.441-2.538-21.881-2.199v42.939h47.336v39.284l-21.997 1.974v39.611l-53.692 10.672v45.77l53.57-15.899.122 40.38-53.692 21.282v45.771L128 490.03V147.002l28.572 5.71 30.583 4.038V73.966l-58.338-22.498-.817-2.465V0l55.925 27.959" fill="#759C3E"/><path d="M160.356 61.941L128 49.01 67.277 73.28l32.131 10.018 60.948-21.357" fill="#3C4929"/><path d="M67.277 73.28L128 49.01l12.775 5.104 19.581 7.827 28.353 11.353-1.515 1.541-28.876 8.991-1.74-.528L128 73.28 99.408 83.298 67.277 73.28" fill="#3C4929"/><path d="M156.578 83.298l32.131-10.004v85.864l-32.131-6.446V83.298" fill="#4B612C"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -56,18 +56,20 @@ export default function NavHeader({
return ( return (
<div className="flex flex-row items-center pt-6"> <div className="flex flex-row items-center pt-6">
<div className="mr-2 flex h-5 w-5 items-center justify-center rounded-md bg-primary text-sm text-black"> <div className="mr-2 flex h-5 w-5 items-center justify-center rounded-md bg-primary text-sm text-black min-w-[1.25rem]">
{currentOrg?.name?.charAt(0)} {currentOrg?.name?.charAt(0)}
</div> </div>
<Link passHref legacyBehavior href={`/org/${currentOrg?.id}/overview`}> <Link passHref legacyBehavior href={`/org/${currentOrg?.id}/overview`}>
<a className="pl-0.5 text-sm font-semibold text-primary/80 hover:text-primary"> <a className="truncate pl-0.5 text-sm font-semibold text-primary/80 hover:text-primary">
{currentOrg?.name} {currentOrg?.name}
</a> </a>
</Link> </Link>
{isProjectRelated && ( {isProjectRelated && (
<> <>
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-3 text-xs text-gray-400" /> <FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-3 text-xs text-gray-400" />
<div className="text-sm font-semibold text-bunker-300">{currentWorkspace?.name}</div> <div className="truncate text-sm font-semibold text-bunker-300">
{currentWorkspace?.name}
</div>
</> </>
)} )}
{isOrganizationRelated && ( {isOrganizationRelated && (
@@ -245,6 +245,7 @@ export default function UserInfoStep({
placeholder="Infisical" placeholder="Infisical"
onChange={(e) => setOrganizationName(e.target.value)} onChange={(e) => setOrganizationName(e.target.value)}
value={organizationName} value={organizationName}
maxLength={64}
isRequired isRequired
className="h-12" className="h-12"
/> />
@@ -12,6 +12,7 @@ export const Popover = PopoverPrimitive.Root;
export type PopoverContentProps = { export type PopoverContentProps = {
children?: ReactNode; children?: ReactNode;
arrowClassName?: string;
hideCloseBtn?: boolean; hideCloseBtn?: boolean;
} & PopoverPrimitive.PopoverContentProps; } & PopoverPrimitive.PopoverContentProps;
@@ -19,6 +20,7 @@ export const PopoverContent = ({
children, children,
className, className,
hideCloseBtn, hideCloseBtn,
arrowClassName,
...props ...props
}: PopoverContentProps) => ( }: PopoverContentProps) => (
<PopoverPrimitive.Portal> <PopoverPrimitive.Portal>
@@ -48,7 +50,7 @@ export const PopoverContent = ({
</IconButton> </IconButton>
</PopoverPrimitive.Close> </PopoverPrimitive.Close>
)} )}
<PopoverPrimitive.Arrow className="fill-inherit" /> <PopoverPrimitive.Arrow className={twMerge("fill-inherit", arrowClassName)} />
</PopoverPrimitive.Content> </PopoverPrimitive.Content>
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
); );
+13 -4
View File
@@ -1,4 +1,4 @@
import { TOrgRole, TProjectRole } from "../roles/types"; import { TOrgRole } from "../roles/types";
import { IdentityAuthMethod } from "./enums"; import { IdentityAuthMethod } from "./enums";
export type IdentityTrustedIp = { export type IdentityTrustedIp = {
@@ -29,9 +29,18 @@ export type IdentityMembershipOrg = {
export type IdentityMembership = { export type IdentityMembership = {
id: string; id: string;
identity: Identity; identity: Identity;
organization: string; roles: {
role: "admin" | "member" | "viewer" | "no-access" | "custom"; id: string;
customRole?: TProjectRole; role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
isTemporary: boolean;
temporaryMode: string | null;
temporaryRange: string | null;
temporaryAccessStartTime: string | null;
temporaryAccessEndTime: string | null;
}[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
+27 -1
View File
@@ -64,7 +64,33 @@ export type TProjectMembership = {
roleId: string; roleId: string;
}; };
export type TWorkspaceUser = OrgUser; export type TWorkspaceUser = {
id: string;
user: {
email: string;
username: string;
firstName: string;
lastName: string;
id: string;
publicKey: string;
};
inviteEmail: string;
organization: string;
roles: {
id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string;
customRoleName: string;
customRoleSlug: string;
isTemporary: boolean;
temporaryMode: string | null;
temporaryRange: string | null;
temporaryAccessStartTime: string | null;
temporaryAccessEndTime: string | null;
}[];
status: "invited" | "accepted" | "verified" | "completed";
deniedPermissions: any[];
};
export type AddUserToWsDTOE2EE = { export type AddUserToWsDTOE2EE = {
workspaceId: string; workspaceId: string;
+9 -19
View File
@@ -16,6 +16,8 @@ import {
RenameWorkspaceDTO, RenameWorkspaceDTO,
TGetUpgradeProjectStatusDTO, TGetUpgradeProjectStatusDTO,
ToggleAutoCapitalizationDTO, ToggleAutoCapitalizationDTO,
TUpdateWorkspaceIdentityRoleDTO,
TUpdateWorkspaceUserRoleDTO,
UpdateEnvironmentDTO, UpdateEnvironmentDTO,
Workspace Workspace
} from "./types"; } from "./types";
@@ -340,27 +342,19 @@ export const useDeleteUserFromWorkspace = () => {
export const useUpdateUserWorkspaceRole = () => { export const useUpdateUserWorkspaceRole = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ mutationFn: async ({ membershipId, roles, workspaceId }: TUpdateWorkspaceUserRoleDTO) => {
membershipId,
role,
workspaceId
}: {
membershipId: string;
role: string;
workspaceId: string;
}) => {
const { const {
data: { membership } data: { membership }
} = await apiRequest.patch<{ membership: { projectId: string } }>( } = await apiRequest.patch<{ membership: { projectId: string } }>(
`/api/v1/workspace/${workspaceId}/memberships/${membershipId}`, `/api/v1/workspace/${workspaceId}/memberships/${membershipId}`,
{ {
role roles
} }
); );
return membership; return membership;
}, },
onSuccess: (res) => { onSuccess: (_, { workspaceId }) => {
queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(res.projectId)); queryClient.invalidateQueries(workspaceKeys.getWorkspaceUsers(workspaceId));
} }
}); });
}; };
@@ -400,18 +394,14 @@ export const useUpdateIdentityWorkspaceRole = () => {
mutationFn: async ({ mutationFn: async ({
identityId, identityId,
workspaceId, workspaceId,
role roles
}: { }:TUpdateWorkspaceIdentityRoleDTO)=> {
identityId: string;
workspaceId: string;
role?: string;
}) => {
const { const {
data: { identityMembership } data: { identityMembership }
} = await apiRequest.patch( } = await apiRequest.patch(
`/api/v2/workspace/${workspaceId}/identity-memberships/${identityId}`, `/api/v2/workspace/${workspaceId}/identity-memberships/${identityId}`,
{ {
role roles
} }
); );
+40
View File
@@ -3,6 +3,10 @@ export enum ProjectVersion {
V2 = 2 V2 = 2
} }
export enum ProjectUserMembershipTemporaryMode {
Relative = "relative"
}
export type Workspace = { export type Workspace = {
__v: number; __v: number;
id: string; id: string;
@@ -72,3 +76,39 @@ export type UpdateEnvironmentDTO = {
}; };
export type DeleteEnvironmentDTO = { workspaceId: string; id: string }; export type DeleteEnvironmentDTO = { workspaceId: string; id: string };
export type TUpdateWorkspaceUserRoleDTO = {
membershipId: string;
workspaceId: string;
roles: (
| {
role: string;
isTemporary?: false;
}
| {
role: string;
isTemporary: true;
temporaryMode: ProjectUserMembershipTemporaryMode;
temporaryRange: string;
temporaryAccessStartTime: string;
}
)[];
};
export type TUpdateWorkspaceIdentityRoleDTO = {
identityId: string;
workspaceId: string;
roles: (
| {
role: string;
isTemporary?: false;
}
| {
role: string;
isTemporary: true;
temporaryMode: ProjectUserMembershipTemporaryMode;
temporaryRange: string;
temporaryAccessStartTime: string;
}
)[];
};
+14 -7
View File
@@ -28,6 +28,7 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu";
import { twMerge } from "tailwind-merge";
import * as yup from "yup"; import * as yup from "yup";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
@@ -99,7 +100,7 @@ const supportOptions = [
]; ];
const formSchema = yup.object({ const formSchema = yup.object({
name: yup.string().required().label("Project Name").trim(), name: yup.string().required().label("Project Name").trim().max(64, "Too long, maximum length is 64 characters"),
addMembers: yup.bool().required().label("Add Members") addMembers: yup.bool().required().label("Add Members")
}); });
@@ -273,13 +274,16 @@ export const AppLayout = ({ children }: LayoutProps) => {
</Link> </Link>
)} )}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild className="data-[state=open]:bg-mineshaft-600"> <DropdownMenuTrigger
asChild
className="max-w-[160px] data-[state=open]:bg-mineshaft-600"
>
<div className="mr-auto flex items-center rounded-md py-1.5 pl-1.5 pr-2 hover:bg-mineshaft-600"> <div className="mr-auto flex items-center rounded-md py-1.5 pl-1.5 pr-2 hover:bg-mineshaft-600">
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-primary text-sm"> <div className="flex h-5 w-5 min-w-[20px] items-center justify-center rounded-md bg-primary text-sm">
{currentOrg?.name.charAt(0)} {currentOrg?.name.charAt(0)}
</div> </div>
<div <div
className="overflow-hidden text-ellipsis pl-2 text-sm text-mineshaft-100" className="overflow-hidden truncate text-ellipsis pl-2 text-sm text-mineshaft-100"
style={{ maxWidth: "140px" }} style={{ maxWidth: "140px" }}
> >
{currentOrg?.name} {currentOrg?.name}
@@ -323,7 +327,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
) )
} }
> >
<div className="flex w-full items-center justify-between"> <div className="flex w-full max-w-[150px] items-center justify-between truncate">
{org.name} {org.name}
</div> </div>
</Button> </Button>
@@ -422,7 +426,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
<Select <Select
defaultValue={currentWorkspace?.id} defaultValue={currentWorkspace?.id}
value={currentWorkspace?.id} value={currentWorkspace?.id}
className="w-full truncate bg-mineshaft-600 py-2.5 font-medium" className="w-full [&>*:first-child]:truncate bg-mineshaft-600 py-2.5 font-medium"
onValueChange={(value) => { onValueChange={(value) => {
router.push(`/project/${value}/secrets/overview`); router.push(`/project/${value}/secrets/overview`);
localStorage.setItem("projectData.id", value); localStorage.setItem("projectData.id", value);
@@ -437,7 +441,10 @@ export const AppLayout = ({ children }: LayoutProps) => {
<SelectItem <SelectItem
key={`ws-layout-list-${id}`} key={`ws-layout-list-${id}`}
value={id} value={id}
className={`${currentWorkspace?.id === id && "bg-mineshaft-600"}`} className={twMerge(
currentWorkspace?.id === id && "bg-mineshaft-600",
"truncate"
)}
> >
{name} {name}
</SelectItem> </SelectItem>
+15
View File
@@ -0,0 +1,15 @@
/**
* Sorts an array of items into groups. The return value is a map where the keys are
* the group ids the given getGroupId function produced and the value is an array of
* each item in that group.
*/
export const groupBy = <T, Key extends string | number | symbol>(
array: readonly T[],
getGroupId: (item: T) => Key
): Record<Key, T[]> =>
array.reduce((acc, item) => {
const groupId = getGroupId(item);
if (!acc[groupId]) acc[groupId] = [];
acc[groupId].push(item);
return acc;
}, {} as Record<Key, T[]>);
@@ -453,7 +453,7 @@ const LearningItemSquare = ({
}; };
const formSchema = yup.object({ const formSchema = yup.object({
name: yup.string().required().label("Project Name").trim(), name: yup.string().required().label("Project Name").trim().max(64, "Too long, maximum length is 64 characters"),
addMembers: yup.bool().required().label("Add Members") addMembers: yup.bool().required().label("Add Members")
}); });
@@ -633,7 +633,7 @@ const OrganizationPage = withPermission(
key={workspace.id} key={workspace.id}
className="min-w-72 flex h-40 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4" className="min-w-72 flex h-40 flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4"
> >
<div className="mt-0 text-lg text-mineshaft-100">{workspace.name}</div> <div className="mt-0 truncate text-lg text-mineshaft-100">{workspace.name}</div>
<div className="mt-0 pb-6 text-sm text-mineshaft-300"> <div className="mt-0 pb-6 text-sm text-mineshaft-300">
{workspace.environments?.length || 0} environments {workspace.environments?.length || 0} environments
</div> </div>
@@ -5,12 +5,7 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc"; import { withProjectPermission } from "@app/hoc";
import { import { IdentityTab, MemberListTab, ProjectRoleListTab, ServiceTokenTab } from "./components";
IdentityTab,
MemberListTab,
ProjectRoleListTab,
ServiceTokenTab
} from "./components";
enum TabSections { enum TabSections {
Member = "members", Member = "members",
@@ -23,19 +18,14 @@ export const MembersPage = withProjectPermission(
() => { () => {
return ( return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white"> <div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mb-6 w-full py-6 px-6 max-w-7xl mx-auto"> <div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
<p className="mr-4 mb-4 text-3xl font-semibold text-white"> <p className="mr-4 mb-4 text-3xl font-semibold text-white">Project Access Control</p>
Project Access Control
</p>
<Tabs defaultValue={TabSections.Member}> <Tabs defaultValue={TabSections.Member}>
<TabList> <TabList>
<Tab value={TabSections.Member}>People</Tab> <Tab value={TabSections.Member}>People</Tab>
<Tab value={TabSections.Identities}> <Tab value={TabSections.Identities}>
<div className="flex items-center"> <div className="flex items-center">
<p>Machine Identities</p> <p>Machine Identities</p>
<div className="ml-2 rounded-md text-yellow text-sm inline-block bg-yellow/20 px-1.5 pb-[0.03rem] pt-[0.04rem] opacity-80 hover:opacity-100 cursor-default">
New
</div>
</div> </div>
</Tab> </Tab>
<Tab value={TabSections.ServiceTokens}>Service Tokens</Tab> <Tab value={TabSections.ServiceTokens}>Service Tokens</Tab>
@@ -0,0 +1,459 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
Checkbox,
FormControl,
HoverCard,
HoverCardContent,
HoverCardTrigger,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Spinner,
Tag,
Tooltip
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { TWorkspaceUser } from "@app/hooks/api/types";
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
import { groupBy } from "@app/lib/fn/array";
const temporaryRoleFormSchema = z.object({
temporaryRange: z.string().min(1, "Required")
});
type TTemporaryRoleFormSchema = z.infer<typeof temporaryRoleFormSchema>;
type TTemporaryRoleFormProps = {
temporaryConfig?: {
isTemporary?: boolean;
temporaryAccessEndTime?: string | null;
temporaryAccessStartTime?: string | null;
temporaryRange?: string | null;
};
onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void;
onRemoveTemporary: () => void;
};
const IdentityTemporaryRoleForm = ({
temporaryConfig: defaultValues = {},
onSetTemporary,
onRemoveTemporary
}: TTemporaryRoleFormProps) => {
const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const);
const { control, handleSubmit } = useForm<TTemporaryRoleFormSchema>({
resolver: zodResolver(temporaryRoleFormSchema),
values: {
temporaryRange: defaultValues.temporaryRange || "1h"
}
});
const isTemporaryFieldValue = defaultValues.isTemporary;
const isExpired =
isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || "");
return (
<Popover
open={popUp.setTempRole.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("setTempRole", isOpen);
}}
>
<PopoverTrigger>
<IconButton ariaLabel="role-temp" variant="plain" size="md">
<Tooltip content={isExpired ? "Access Expired" : "Grant Temporary Access"}>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
isTemporaryFieldValue && "text-primary",
isExpired && "text-red-600"
)}
/>
</Tooltip>
</IconButton>
</PopoverTrigger>
<PopoverContent
arrowClassName="fill-gray-600"
side="right"
sideOffset={12}
hideCloseBtn
className="border border-gray-600 pt-4"
>
<div className="flex flex-col space-y-4">
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
Set Role Temporarily
</div>
{isExpired && <Tag colorSchema="red">Expired</Tag>}
<Controller
control={control}
name="temporaryRange"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Validity"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText={
<span>
1m, 2h, 3d.{" "}
<a
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
target="_blank"
rel="noopener noreferrer"
className="text-primary-700"
>
More
</a>
</span>
}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
{isTemporaryFieldValue && (
<Button
size="xs"
type="submit"
onClick={() => {
handleSubmit(({ temporaryRange }) => {
onSetTemporary({
temporaryRange,
temporaryAccessStartTime: new Date().toISOString()
});
handlePopUpToggle("setTempRole");
})();
}}
>
Restart
</Button>
)}
{!isTemporaryFieldValue ? (
<Button
size="xs"
type="submit"
onClick={() =>
handleSubmit(({ temporaryRange }) => {
onSetTemporary({
temporaryRange,
temporaryAccessStartTime:
defaultValues.temporaryAccessStartTime || new Date().toISOString()
});
handlePopUpToggle("setTempRole");
})()
}
>
Grant access
</Button>
) : (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
onRemoveTemporary();
handlePopUpToggle("setTempRole");
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
};
const formSchema = z.record(
z.object({
isChecked: z.boolean().optional(),
temporaryAccess: z.union([
z.object({
isTemporary: z.literal(true),
temporaryRange: z.string().min(1),
temporaryAccessStartTime: z.string().datetime(),
temporaryAccessEndTime: z.string().datetime().nullable().optional()
}),
z.boolean()
])
})
);
type TForm = z.infer<typeof formSchema>;
export type TMemberRolesProp = {
disableEdit?: boolean;
identityId: string;
roles: TWorkspaceUser["roles"];
};
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
export const IdentityRoles = ({
roles = [],
disableEdit = false,
identityId
}: TMemberRolesProp) => {
const { currentWorkspace } = useWorkspace();
const { createNotification } = useNotificationContext();
const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const);
const [searchRoles, setSearchRoles] = useState("");
const {
handleSubmit,
control,
reset,
setValue,
formState: { isSubmitting, isDirty }
} = useForm<TForm>({
resolver: zodResolver(formSchema)
});
const workspaceId = currentWorkspace?.id || "";
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role);
const updateIdentityWorkspaceRole = useUpdateIdentityWorkspaceRole();
const handleRoleUpdate = async (data: TForm) => {
const selectedRoles = Object.keys(data)
.filter((el) => Boolean(data[el].isChecked))
.map((el) => {
const isTemporary = Boolean(data[el].temporaryAccess);
if (!isTemporary) {
return { role: el, isTemporary: false as const };
}
const tempCfg = data[el].temporaryAccess as {
temporaryRange: string;
temporaryAccessStartTime: string;
};
return {
role: el,
isTemporary: true as const,
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
temporaryRange: tempCfg.temporaryRange,
temporaryAccessStartTime: tempCfg.temporaryAccessStartTime
};
});
try {
await updateIdentityWorkspaceRole.mutateAsync({
workspaceId,
identityId,
roles: selectedRoles
});
createNotification({ text: "Successfully updated identity role", type: "success" });
handlePopUpToggle("editRole");
setSearchRoles("");
} catch (err) {
createNotification({ text: "Failed to update identity role", type: "error" });
}
};
const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Member) return "Developer";
return role;
};
return (
<div className="flex items-center space-x-2">
{roles
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={id} className="capitalize">
<div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && (
<div>
<Tooltip content={isExpired ? "Expired Temporary Access" : "Temporary Access"}>
<FontAwesomeIcon
icon={faClock}
className={twMerge(isExpired && "text-red-600")}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
})}
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
<HoverCard>
<HoverCardTrigger>
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
</HoverCardTrigger>
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
{roles
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={id} className="capitalize">
<div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && (
<div>
<Tooltip
content={isExpired ? "Expired Temporary Access" : "Temporary Access"}
>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
new Date() > new Date(temporaryAccessEndTime as string) &&
"text-red-600"
)}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
})}{" "}
</HoverCardContent>
</HoverCard>
)}
<div>
<Popover
open={popUp.editRole.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("editRole", isOpen);
reset();
}}
>
{!disableEdit && (
<PopoverTrigger>
<IconButton size="sm" variant="plain" ariaLabel="update">
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</PopoverTrigger>
)}
<PopoverContent hideCloseBtn className="pt-4">
{isRolesLoading ? (
<div className="flex h-8 w-full items-center justify-center">
<Spinner />
</div>
) : (
<form onSubmit={handleSubmit(handleRoleUpdate)} id="role-update-form">
<div className="thin-scrollbar max-h-80 space-y-4 overflow-y-auto">
{projectRoles
?.filter(
({ name, slug }) =>
name.toLowerCase().includes(searchRoles.toLowerCase()) ||
slug.toLowerCase().includes(searchRoles.toLowerCase())
)
?.map(({ id, name, slug }) => {
const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0];
return (
<div key={id} className="flex items-center space-x-4">
<div className="flex-grow">
<Controller
control={control}
defaultValue={Boolean(userProjectRoleDetails?.id)}
name={`${slug}.isChecked`}
render={({ field }) => (
<Checkbox
id={slug}
isChecked={field.value}
onCheckedChange={(isChecked) => {
field.onChange(isChecked);
setValue(`${slug}.temporaryAccess`, false);
}}
>
{name}
</Checkbox>
)}
/>
</div>
<div>
<Controller
control={control}
name={`${slug}.temporaryAccess`}
defaultValue={
userProjectRoleDetails?.isTemporary
? {
isTemporary: true,
temporaryAccessStartTime:
userProjectRoleDetails.temporaryAccessStartTime as string,
temporaryRange:
userProjectRoleDetails.temporaryRange as string,
temporaryAccessEndTime:
userProjectRoleDetails.temporaryAccessEndTime
}
: false
}
render={({ field }) => (
<IdentityTemporaryRoleForm
temporaryConfig={
typeof field.value === "boolean"
? { isTemporary: field.value }
: field.value
}
onSetTemporary={(data) => {
setValue(`${slug}.isChecked`, true, { shouldDirty: true });
console.log(data);
field.onChange({ isTemporary: true, ...data });
}}
onRemoveTemporary={() => {
setValue(`${slug}.isChecked`, false, { shouldDirty: true });
field.onChange(false);
}}
/>
)}
/>
</div>
</div>
);
})}
</div>
<div className="mt-3 flex items-center space-x-2 border-t border-t-gray-700 pt-3">
<div>
<Input
className="w-full p-1.5 pl-8"
size="xs"
value={searchRoles}
onChange={(el) => setSearchRoles(el.target.value)}
leftIcon={<FontAwesomeIcon icon={faSearch} />}
placeholder="Search roles.."
/>
</div>
<div>
<Button
size="xs"
type="submit"
form="role-update-form"
leftIcon={<FontAwesomeIcon icon={faCheck} />}
isDisabled={!isDirty || isSubmitting}
isLoading={isSubmitting}
>
Save
</Button>
</div>
</div>
</form>
)}
</PopoverContent>
</Popover>
</div>
</div>
);
};
@@ -2,13 +2,10 @@ import { faServer, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns"; import { format } from "date-fns";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
EmptyState, EmptyState,
IconButton, IconButton,
Select,
SelectItem,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -19,13 +16,11 @@ import {
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { import { useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
useGetProjectRoles,
useGetWorkspaceIdentityMemberships,
useUpdateIdentityWorkspaceRole
} from "@app/hooks/api";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import { IdentityRoles } from "./IdentityRoles";
type Props = { type Props = {
handlePopUpOpen: ( handlePopUpOpen: (
popUpName: keyof UsePopUpState<["deleteIdentity", "identity"]>, popUpName: keyof UsePopUpState<["deleteIdentity", "identity"]>,
@@ -37,39 +32,9 @@ type Props = {
}; };
export const IdentityTable = ({ handlePopUpOpen }: Props) => { export const IdentityTable = ({ handlePopUpOpen }: Props) => {
const { createNotification } = useNotificationContext();
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || ""); const { data, isLoading } = useGetWorkspaceIdentityMemberships(currentWorkspace?.id || "");
const { data: roles } = useGetProjectRoles(workspaceId);
const { mutateAsync: updateMutateAsync } = useUpdateIdentityWorkspaceRole();
const handleChangeRole = async ({ identityId, role }: { identityId: string; role: string }) => {
try {
await updateMutateAsync({
identityId,
workspaceId,
role
});
createNotification({
text: "Successfully updated identity role",
type: "success"
});
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to update identity role";
createNotification({
text,
type: "error"
});
}
};
return ( return (
<TableContainer> <TableContainer>
<Table> <Table>
@@ -86,7 +51,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
{!isLoading && {!isLoading &&
data && data &&
data.length > 0 && data.length > 0 &&
data.map(({ identity: { id, name }, role, customRole, createdAt }) => { data.map(({ identity: { id, name }, roles, createdAt }) => {
return ( return (
<Tr className="h-10" key={`st-v3-${id}`}> <Tr className="h-10" key={`st-v3-${id}`}>
<Td>{name}</Td> <Td>{name}</Td>
@@ -95,28 +60,9 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Identity} a={ProjectPermissionSub.Identity}
> >
{(isAllowed) => { {(isAllowed) => (
return ( <IdentityRoles roles={roles} disableEdit={!isAllowed} identityId={id} />
<Select )}
value={role === "custom" ? (customRole?.slug as string) : role}
isDisabled={!isAllowed}
className="w-40 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
handleChangeRole({
identityId: id,
role: selectedRole
})
}
>
{(roles || []).map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
);
}}
</ProjectPermissionCan> </ProjectPermissionCan>
</Td> </Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td> <Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Link from "next/link"; import Link from "next/link";
@@ -34,7 +34,6 @@ import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionSub, ProjectPermissionSub,
useOrganization, useOrganization,
useSubscription,
useUser, useUser,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
@@ -44,14 +43,13 @@ import {
useAddUserToWsNonE2EE, useAddUserToWsNonE2EE,
useDeleteUserFromWorkspace, useDeleteUserFromWorkspace,
useGetOrgUsers, useGetOrgUsers,
useGetProjectRoles,
useGetUserWsKey, useGetUserWsKey,
useGetWorkspaceUsers, useGetWorkspaceUsers
useUpdateUserWorkspaceRole
} from "@app/hooks/api"; } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { ProjectVersion } from "@app/hooks/api/workspace/types"; import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { MemberRoles } from "./MemberRoles";
const addMemberFormSchema = z.object({ const addMemberFormSchema = z.object({
orgMembershipId: z.string().trim() orgMembershipId: z.string().trim()
}); });
@@ -60,7 +58,6 @@ type TAddMemberForm = z.infer<typeof addMemberFormSchema>;
export const MemberListTab = () => { export const MemberListTab = () => {
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const { subscription } = useSubscription();
const { t } = useTranslation(); const { t } = useTranslation();
const { currentOrg } = useOrganization(); const { currentOrg } = useOrganization();
@@ -71,8 +68,6 @@ export const MemberListTab = () => {
const orgId = currentOrg?.id || ""; const orgId = currentOrg?.id || "";
const workspaceId = currentWorkspace?.id || ""; const workspaceId = currentWorkspace?.id || "";
const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
const { data: wsKey } = useGetUserWsKey(workspaceId); const { data: wsKey } = useGetUserWsKey(workspaceId);
const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId); const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
const { data: orgUsers } = useGetOrgUsers(orgId); const { data: orgUsers } = useGetOrgUsers(orgId);
@@ -95,7 +90,6 @@ export const MemberListTab = () => {
const { mutateAsync: addUserToWorkspace } = useAddUserToWsE2EE(); const { mutateAsync: addUserToWorkspace } = useAddUserToWsE2EE();
const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE(); const { mutateAsync: addUserToWorkspaceNonE2EE } = useAddUserToWsNonE2EE();
const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace(); const { mutateAsync: removeUserFromWorkspace } = useDeleteUserFromWorkspace();
const { mutateAsync: updateUserWorkspaceRole } = useUpdateUserWorkspaceRole();
const onAddMember = async ({ orgMembershipId }: TAddMemberForm) => { const onAddMember = async ({ orgMembershipId }: TAddMemberForm) => {
if (!currentWorkspace) return; if (!currentWorkspace) return;
@@ -167,47 +161,6 @@ export const MemberListTab = () => {
handlePopUpClose("removeMember"); handlePopUpClose("removeMember");
}; };
const isIamOwner = useMemo(
() => members?.find(({ user: u }) => userId === u?.id)?.role === "owner",
[userId, members]
);
const findRoleFromId = useCallback(
(roleId: string) => {
return (roles || []).find(({ id }) => id === roleId);
},
[roles]
);
const onRoleChange = async (membershipId: string, role: string) => {
if (!currentOrg?.id) return;
try {
const isCustomRole = !Object.values(ProjectMembershipRole).includes(
role as ProjectMembershipRole
);
if (isCustomRole && subscription && !subscription?.rbac) {
handlePopUpOpen("upgradePlan", {
description: "You can assign custom roles to members if you upgrade your Infisical plan."
});
return;
}
await updateUserWorkspaceRole({ membershipId, role, workspaceId });
createNotification({
text: "Successfully updated user role",
type: "success"
});
} catch (error) {
console.error(error);
createNotification({
text: "Failed to update user role",
type: "error"
});
}
};
const filterdUsers = useMemo( const filterdUsers = useMemo(
() => () =>
members?.filter( members?.filter(
@@ -231,8 +184,6 @@ export const MemberListTab = () => {
); );
}, [orgUsers, members]); }, [orgUsers, members]);
const isLoading = isMembersLoading || isRolesLoading;
return ( return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
@@ -269,75 +220,61 @@ export const MemberListTab = () => {
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isLoading && <TableSkeleton columns={4} innerKey="project-members" />} {isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />}
{!isLoading && {!isMembersLoading &&
filterdUsers?.map( filterdUsers?.map(({ user: u, inviteEmail, id: membershipId, roles }) => {
({ user: u, id: membershipId, roleId, role }) => { const name = u ? `${u.firstName} ${u.lastName}` : "-";
const name = u ? `${u.firstName} ${u.lastName}` : "-"; const email = u?.email || inviteEmail;
const username = u?.username ?? "-";
return ( return (
<Tr key={`membership-${membershipId}`} className="w-full"> <Tr key={`membership-${membershipId}`} className="w-full">
<Td>{name}</Td> <Td>{name}</Td>
<Td>{username}</Td> <Td>{email}</Td>
<Td> <Td>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<MemberRoles
roles={roles}
disableEdit={u.id === user?.id || !isAllowed}
onOpenUpgradeModal={(description) =>
handlePopUpOpen("upgradePlan", { description })
}
membershipId={membershipId}
/>
)}
</ProjectPermissionCan>
</Td>
<Td>
{userId !== u?.id && (
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.Member} a={ProjectPermissionSub.Member}
> >
{(isAllowed) => ( {(isAllowed) => (
<Select <IconButton
value={role === "custom" ? findRoleFromId(roleId)?.slug : role} size="lg"
isDisabled={userId === u?.id || !isAllowed} colorSchema="danger"
className="w-40 bg-mineshaft-600" variant="plain"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800" ariaLabel="update"
onValueChange={(selectedRole) => className="ml-4"
onRoleChange(membershipId, selectedRole) isDisabled={userId === u?.id || !isAllowed}
} onClick={() => handlePopUpOpen("removeMember", { email: u.email })}
> >
{(roles || []) <FontAwesomeIcon icon={faXmark} />
.filter(({ slug }) => </IconButton>
slug === "owner" ? isIamOwner || role === "owner" : true
)
.map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</Td> )}
<Td> </Td>
{userId !== u?.id && ( </Tr>
<ProjectPermissionCan );
I={ProjectPermissionActions.Delete} })}
a={ProjectPermissionSub.Member}
>
{(isAllowed) => (
<IconButton
size="lg"
colorSchema="danger"
variant="plain"
ariaLabel="update"
className="ml-4"
isDisabled={userId === u?.id || !isAllowed}
onClick={() =>
handlePopUpOpen("removeMember", { username: u.username })
}
>
<FontAwesomeIcon icon={faXmark} />
</IconButton>
)}
</ProjectPermissionCan>
)}
</Td>
</Tr>
);
}
)}
</TBody> </TBody>
</Table> </Table>
{!isLoading && filterdUsers?.length === 0 && ( {!isMembersLoading && filterdUsers?.length === 0 && (
<EmptyState title="No project members found" icon={faUsers} /> <EmptyState title="No project members found" icon={faUsers} />
)} )}
</TableContainer> </TableContainer>
@@ -0,0 +1,473 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faCheck, faClock, faEdit, faSearch } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import {
Button,
Checkbox,
FormControl,
HoverCard,
HoverCardContent,
HoverCardTrigger,
IconButton,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Spinner,
Tag,
Tooltip
} from "@app/components/v2";
import { useSubscription, useWorkspace } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useGetProjectRoles, useUpdateUserWorkspaceRole } from "@app/hooks/api";
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
import { TWorkspaceUser } from "@app/hooks/api/types";
import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/workspace/types";
import { groupBy } from "@app/lib/fn/array";
const temporaryRoleFormSchema = z.object({
temporaryRange: z.string().min(1, "Required")
});
type TTemporaryRoleFormSchema = z.infer<typeof temporaryRoleFormSchema>;
type TTemporaryRoleFormProps = {
temporaryConfig?: {
isTemporary?: boolean;
temporaryAccessEndTime?: string | null;
temporaryAccessStartTime?: string | null;
temporaryRange?: string | null;
};
onSetTemporary: (data: { temporaryRange: string; temporaryAccessStartTime?: string }) => void;
onRemoveTemporary: () => void;
};
const TemporaryRoleForm = ({
temporaryConfig: defaultValues = {},
onSetTemporary,
onRemoveTemporary
}: TTemporaryRoleFormProps) => {
const { popUp, handlePopUpToggle } = usePopUp(["setTempRole"] as const);
const { control, handleSubmit } = useForm<TTemporaryRoleFormSchema>({
resolver: zodResolver(temporaryRoleFormSchema),
values: {
temporaryRange: defaultValues.temporaryRange || "1h"
}
});
const isTemporaryFieldValue = defaultValues.isTemporary;
const isExpired =
isTemporaryFieldValue && new Date() > new Date(defaultValues.temporaryAccessEndTime || "");
return (
<Popover
open={popUp.setTempRole.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("setTempRole", isOpen);
}}
>
<PopoverTrigger>
<IconButton ariaLabel="role-temp" variant="plain" size="md">
<Tooltip content={isExpired ? "Timed access expired" : "Grant timed access"}>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
isTemporaryFieldValue && "text-primary",
isExpired && "text-red-600"
)}
/>
</Tooltip>
</IconButton>
</PopoverTrigger>
<PopoverContent
arrowClassName="fill-gray-600"
side="right"
sideOffset={12}
hideCloseBtn
className="border border-gray-600 pt-4"
>
<div className="flex flex-col space-y-4">
<div className="border-b border-b-gray-700 pb-2 text-sm text-mineshaft-300">
Configure timed access
</div>
{isExpired && <Tag colorSchema="red">Expired</Tag>}
<Controller
control={control}
name="temporaryRange"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Validity"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText={
<span>
1m, 2h, 3d.{" "}
<a
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
target="_blank"
rel="noopener noreferrer"
className="text-primary-700"
>
More
</a>
</span>
}
>
<Input {...field} />
</FormControl>
)}
/>
<div className="flex items-center space-x-2">
{isTemporaryFieldValue && (
<Button
size="xs"
type="submit"
onClick={() => {
handleSubmit(({ temporaryRange }) => {
onSetTemporary({
temporaryRange,
temporaryAccessStartTime: new Date().toISOString()
});
handlePopUpToggle("setTempRole");
})();
}}
>
Restart
</Button>
)}
{!isTemporaryFieldValue ? (
<Button
size="xs"
type="submit"
onClick={() =>
handleSubmit(({ temporaryRange }) => {
onSetTemporary({
temporaryRange,
temporaryAccessStartTime:
defaultValues.temporaryAccessStartTime || new Date().toISOString()
});
handlePopUpToggle("setTempRole");
})()
}
>
Grant access
</Button>
) : (
<Button
size="xs"
variant="outline_bg"
colorSchema="danger"
onClick={() => {
onRemoveTemporary();
handlePopUpToggle("setTempRole");
}}
>
Revoke Access
</Button>
)}
</div>
</div>
</PopoverContent>
</Popover>
);
};
const formSchema = z.record(
z.object({
isChecked: z.boolean().optional(),
temporaryAccess: z.union([
z.object({
isTemporary: z.literal(true),
temporaryRange: z.string().min(1),
temporaryAccessStartTime: z.string().datetime(),
temporaryAccessEndTime: z.string().datetime().nullable().optional()
}),
z.boolean()
])
})
);
type TForm = z.infer<typeof formSchema>;
export type TMemberRolesProp = {
disableEdit?: boolean;
membershipId: string;
onOpenUpgradeModal: (description: string) => void;
roles: TWorkspaceUser["roles"];
};
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
export const MemberRoles = ({
roles = [],
disableEdit = false,
membershipId,
onOpenUpgradeModal
}: TMemberRolesProp) => {
const { currentWorkspace } = useWorkspace();
const { createNotification } = useNotificationContext();
const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const);
const [searchRoles, setSearchRoles] = useState("");
const { subscription } = useSubscription();
const {
handleSubmit,
control,
reset,
setValue,
formState: { isSubmitting, isDirty }
} = useForm<TForm>({
resolver: zodResolver(formSchema)
});
const workspaceId = currentWorkspace?.id || "";
const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId);
const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role);
const updateMembershipRole = useUpdateUserWorkspaceRole();
const handleRoleUpdate = async (data: TForm) => {
const selectedRoles = Object.keys(data)
.filter((el) => Boolean(data[el].isChecked))
.map((el) => {
const isTemporary = Boolean(data[el].temporaryAccess);
if (!isTemporary) {
return { role: el, isTemporary: false as const };
}
const tempCfg = data[el].temporaryAccess as {
temporaryRange: string;
temporaryAccessStartTime: string;
};
return {
role: el,
isTemporary: true as const,
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
temporaryRange: tempCfg.temporaryRange,
temporaryAccessStartTime: tempCfg.temporaryAccessStartTime
};
});
const hasCustomRoleSelected = selectedRoles.some(
(el) => !Object.values(ProjectMembershipRole).includes(el.role as ProjectMembershipRole)
);
if (hasCustomRoleSelected && subscription && !subscription?.rbac) {
onOpenUpgradeModal(
"You can assign custom roles to members if you upgrade your Infisical plan."
);
return;
}
try {
await updateMembershipRole.mutateAsync({
workspaceId,
membershipId,
roles: selectedRoles
});
createNotification({ text: "Successfully updated role", type: "success" });
handlePopUpToggle("editRole");
setSearchRoles("");
} catch (err) {
createNotification({ text: "Failed to update role", type: "error" });
}
};
const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Member) return "Developer";
return role;
};
return (
<div className="flex items-center space-x-2">
{roles
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={id}>
<div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && (
<div>
<Tooltip content={isExpired ? "Timed role expired" : "Timed role access"}>
<FontAwesomeIcon
icon={faClock}
className={twMerge(isExpired && "text-red-600")}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
})}
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
<HoverCard>
<HoverCardTrigger>
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
</HoverCardTrigger>
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
{roles
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(({ role, customRoleName, id, isTemporary, temporaryAccessEndTime }) => {
const isExpired = new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={id} className="capitalize">
<div className="flex items-center space-x-2">
<div>{formatRoleName(role, customRoleName)}</div>
{isTemporary && (
<div>
<Tooltip
content={isExpired ? "Access expired" : "Temporary access"}
>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
new Date() > new Date(temporaryAccessEndTime as string) &&
"text-red-600"
)}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
})}{" "}
</HoverCardContent>
</HoverCard>
)}
<div>
<Popover
open={popUp.editRole.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("editRole", isOpen);
reset();
}}
>
{!disableEdit && (
<PopoverTrigger>
<IconButton size="sm" variant="plain" ariaLabel="update">
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</PopoverTrigger>
)}
<PopoverContent hideCloseBtn className="pt-4">
{isRolesLoading ? (
<div className="flex h-8 w-full items-center justify-center">
<Spinner />
</div>
) : (
<form onSubmit={handleSubmit(handleRoleUpdate)} id="role-update-form">
<div className="thin-scrollbar max-h-80 space-y-4 overflow-y-auto">
{projectRoles
?.filter(
({ name, slug }) =>
name.toLowerCase().includes(searchRoles.toLowerCase()) ||
slug.toLowerCase().includes(searchRoles.toLowerCase())
)
?.map(({ id, name, slug }) => {
const userProjectRoleDetails = userRolesGroupBySlug?.[slug]?.[0];
return (
<div key={id} className="flex items-center space-x-4">
<div className="flex-grow">
<Controller
control={control}
defaultValue={Boolean(userProjectRoleDetails?.id)}
name={`${slug}.isChecked`}
render={({ field }) => (
<Checkbox
id={slug}
isChecked={field.value}
onCheckedChange={(isChecked) => {
field.onChange(isChecked);
setValue(`${slug}.temporaryAccess`, false);
}}
>
{name}
</Checkbox>
)}
/>
</div>
<div>
<Controller
control={control}
name={`${slug}.temporaryAccess`}
defaultValue={
userProjectRoleDetails?.isTemporary
? {
isTemporary: true,
temporaryAccessStartTime:
userProjectRoleDetails.temporaryAccessStartTime as string,
temporaryRange:
userProjectRoleDetails.temporaryRange as string,
temporaryAccessEndTime:
userProjectRoleDetails.temporaryAccessEndTime
}
: false
}
render={({ field }) => (
<TemporaryRoleForm
temporaryConfig={
typeof field.value === "boolean"
? { isTemporary: field.value }
: field.value
}
onSetTemporary={(data) => {
setValue(`${slug}.isChecked`, true, { shouldDirty: true });
console.log(data);
field.onChange({ isTemporary: true, ...data });
}}
onRemoveTemporary={() => {
setValue(`${slug}.isChecked`, false, { shouldDirty: true });
field.onChange(false);
}}
/>
)}
/>
</div>
</div>
);
})}
</div>
<div className="mt-3 flex items-center space-x-2 border-t border-t-gray-700 pt-3">
<div>
<Input
className="w-full p-1.5 pl-8"
size="xs"
value={searchRoles}
onChange={(el) => setSearchRoles(el.target.value)}
leftIcon={<FontAwesomeIcon icon={faSearch} />}
placeholder="Search roles.."
/>
</div>
<div>
<Button
size="xs"
type="submit"
form="role-update-form"
leftIcon={<FontAwesomeIcon icon={faCheck} />}
isDisabled={!isDirty || isSubmitting}
isLoading={isSubmitting}
>
Save
</Button>
</div>
</div>
</form>
)}
</PopoverContent>
</Popover>
</div>
</div>
);
};
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; import { faCheckCircle, faCircle } from "@fortawesome/free-regular-svg-icons";
import { import {
faAngleDown, faAngleDown,
faArrowDown, faArrowDown,
@@ -18,7 +18,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import NavHeader from "@app/components/navigation/NavHeader"; import NavHeader from "@app/components/navigation/NavHeader";
import { PermissionDeniedBanner, ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
Button, Button,
DropdownMenu, DropdownMenu,
@@ -108,7 +108,11 @@ export const SecretOverviewPage = () => {
}, [isWorkspaceLoading, workspaceId, router.isReady]); }, [isWorkspaceLoading, workspaceId, router.isReady]);
const userAvailableEnvs = currentWorkspace?.environments || []; const userAvailableEnvs = currentWorkspace?.environments || [];
const [visibleEnvs, setVisisbleEnvs] = useState(userAvailableEnvs); const [visibleEnvs, setVisibleEnvs] = useState(userAvailableEnvs);
useEffect(() => {
setVisibleEnvs(userAvailableEnvs);
}, [userAvailableEnvs]);
const { const {
data: secrets, data: secrets,
@@ -208,9 +212,9 @@ export const SecretOverviewPage = () => {
const handleEnvSelect = (envId: string) => { const handleEnvSelect = (envId: string) => {
if (visibleEnvs.map((env) => env.id).includes(envId)) { if (visibleEnvs.map((env) => env.id).includes(envId)) {
setVisisbleEnvs(visibleEnvs.filter((env) => env.id !== envId)); setVisibleEnvs(visibleEnvs.filter((env) => env.id !== envId));
} else { } else {
setVisisbleEnvs(visibleEnvs.concat(userAvailableEnvs.filter((env) => env.id === envId))); setVisibleEnvs(visibleEnvs.concat(userAvailableEnvs.filter((env) => env.id === envId)));
} }
}; };
@@ -390,41 +394,44 @@ export const SecretOverviewPage = () => {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<FolderBreadCrumbs secretPath={secretPath} onResetSearch={handleResetSearch} /> <FolderBreadCrumbs secretPath={secretPath} onResetSearch={handleResetSearch} />
<div className="flex flex-row items-center justify-center space-x-2"> <div className="flex flex-row items-center justify-center space-x-2">
<DropdownMenu> {userAvailableEnvs.length > 0 && (
<DropdownMenuTrigger asChild> <DropdownMenu>
<IconButton <DropdownMenuTrigger asChild>
ariaLabel="Environments" <IconButton
variant="plain" ariaLabel="Environments"
size="sm" variant="plain"
className="mr-2 flex w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 hover:border-primary/60 hover:bg-primary/10" size="sm"
> className="flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 hover:border-primary/60 hover:bg-primary/10"
<Tooltip content="Choose visible environments" className="mb-2"> >
<FontAwesomeIcon icon={faList} /> <Tooltip content="Choose visible environments" className="mb-2">
</Tooltip> <FontAwesomeIcon icon={faList} />
</IconButton> </Tooltip>
</DropdownMenuTrigger> </IconButton>
<DropdownMenuContent align="end"> </DropdownMenuTrigger>
<DropdownMenuLabel>Choose visible environments</DropdownMenuLabel> <DropdownMenuContent align="end">
{userAvailableEnvs.map((avaiableEnv) => { <DropdownMenuLabel>Choose visible environments</DropdownMenuLabel>
const { id: envId, name } = avaiableEnv; {userAvailableEnvs.map((availableEnv) => {
const { id: envId, name } = availableEnv;
const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId); const isEnvSelected = visibleEnvs.map((env) => env.id).includes(envId);
return ( return (
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleEnvSelect(envId)} onClick={() => handleEnvSelect(envId)}
key={envId} key={envId}
icon={ icon={
isEnvSelected && ( isEnvSelected ? (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} /> <FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
) ) : (
} <FontAwesomeIcon className="text-mineshaft-400" icon={faCircle} />
iconPos="left" )
> }
<div className="flex items-center">{name}</div> iconPos="left"
</DropdownMenuItem> >
); <div className="flex items-center">{name}</div>
})} </DropdownMenuItem>
{/* <DropdownMenuItem className="px-1.5" asChild> );
})}
{/* <DropdownMenuItem className="px-1.5" asChild>
<Button <Button
size="xs" size="xs"
className="w-full" className="w-full"
@@ -436,8 +443,9 @@ export const SecretOverviewPage = () => {
Create an environment Create an environment
</Button> </Button>
</DropdownMenuItem> */} </DropdownMenuItem> */}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)}
<div className="w-80"> <div className="w-80">
<Input <Input
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80" className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
@@ -447,62 +455,64 @@ export const SecretOverviewPage = () => {
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
/> />
</div> </div>
<div> {userAvailableEnvs.length > 0 && (
<ProjectPermissionCan <div>
I={ProjectPermissionActions.Create} <ProjectPermissionCan
a={subject(ProjectPermissionSub.Secrets, { secretPath })} I={ProjectPermissionActions.Create}
> a={subject(ProjectPermissionSub.Secrets, { secretPath })}
{(isAllowed) => ( >
<Button {(isAllowed) => (
variant="outline_bg" <Button
leftIcon={<FontAwesomeIcon icon={faPlus} />} variant="outline_bg"
onClick={() => handlePopUpOpen("addSecretsInAllEnvs")} leftIcon={<FontAwesomeIcon icon={faPlus} />}
className="h-10 rounded-r-none" onClick={() => handlePopUpOpen("addSecretsInAllEnvs")}
isDisabled={!isAllowed} className="h-10 rounded-r-none"
> isDisabled={!isAllowed}
Add Secret
</Button>
)}
</ProjectPermissionCan>
<DropdownMenu
open={popUp.misc.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="add-folder-or-import"
variant="outline_bg"
className="rounded-l-none bg-mineshaft-600 p-3"
>
<FontAwesomeIcon icon={faAngleDown} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<div className="flex flex-col space-y-1 p-1.5">
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, { secretPath })}
> >
{(isAllowed) => ( Add Secret
<Button </Button>
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />} )}
onClick={() => { </ProjectPermissionCan>
handlePopUpOpen("addFolder"); <DropdownMenu
handlePopUpClose("misc"); open={popUp.misc.isOpen}
}} onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
isDisabled={!isAllowed} >
variant="outline_bg" <DropdownMenuTrigger asChild>
className="h-10" <IconButton
isFullWidth ariaLabel="add-folder-or-import"
> variant="outline_bg"
Add Folder className="rounded-l-none bg-mineshaft-600 p-3"
</Button> >
)} <FontAwesomeIcon icon={faAngleDown} />
</ProjectPermissionCan> </IconButton>
</div> </DropdownMenuTrigger>
</DropdownMenuContent> <DropdownMenuContent align="end">
</DropdownMenu> <div className="flex flex-col space-y-1 p-1.5">
</div> <ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, { secretPath })}
>
{(isAllowed) => (
<Button
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
onClick={() => {
handlePopUpOpen("addFolder");
handlePopUpClose("misc");
}}
isDisabled={!isAllowed}
variant="outline_bg"
className="h-10"
isFullWidth
>
Add Folder
</Button>
)}
</ProjectPermissionCan>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -565,14 +575,25 @@ export const SecretOverviewPage = () => {
className="bg-mineshaft-700" className="bg-mineshaft-700"
/> />
)} )}
{isTableEmpty && !isTableLoading && ( {userAvailableEnvs.length > 0 && visibleEnvs.length === 0 && (
<Tr> <Tr>
<Td colSpan={visibleEnvs.length + 1}> <Td colSpan={visibleEnvs.length + 1}>
<EmptyState title="Let's add some secrets" icon={faFolderBlank} iconSize="3x"> <EmptyState title="You have no visible environments" iconSize="3x" />
</Td>
</Tr>
)}
{userAvailableEnvs.length === 0 && (
<Tr>
<Td colSpan={visibleEnvs.length + 1}>
<EmptyState
title="You have no environments, start by adding some"
iconSize="3x"
>
<Link <Link
href={{ href={{
pathname: "/project/[id]/secrets/[env]", pathname: "/project/[id]/settings",
query: { id: workspaceId, env: visibleEnvs?.[0]?.slug } query: { id: workspaceId },
hash: "environments"
}} }}
> >
<Button <Button
@@ -581,13 +602,38 @@ export const SecretOverviewPage = () => {
colorSchema="primary" colorSchema="primary"
size="md" size="md"
> >
Go to {visibleEnvs?.[0]?.name} Add environments
</Button> </Button>
</Link> </Link>
</EmptyState> </EmptyState>
</Td> </Td>
</Tr> </Tr>
)} )}
{isTableEmpty && !isTableLoading && visibleEnvs.length > 0 && (
<Tr>
<Td colSpan={visibleEnvs.length + 1}>
<EmptyState
title={
searchFilter
? "No secret found for your search, add one now"
: "Let's add some secrets"
}
icon={faFolderBlank}
iconSize="3x"
>
<Button
className="mt-4"
variant="outline_bg"
colorSchema="primary"
size="md"
onClick={() => handlePopUpOpen("addSecretsInAllEnvs")}
>
Add Secrets
</Button>
</EmptyState>
</Td>
</Tr>
)}
{!isTableLoading && {!isTableLoading &&
filteredFolderNames.map((folderName, index) => ( filteredFolderNames.map((folderName, index) => (
<SecretOverviewFolderRow <SecretOverviewFolderRow
@@ -599,22 +645,19 @@ export const SecretOverviewPage = () => {
/> />
))} ))}
{!isTableLoading && {!isTableLoading &&
(visibleEnvs?.length > 0 ? ( visibleEnvs?.length > 0 &&
filteredSecretNames.map((key, index) => ( filteredSecretNames.map((key, index) => (
<SecretOverviewTableRow <SecretOverviewTableRow
secretPath={secretPath} secretPath={secretPath}
onSecretCreate={handleSecretCreate} onSecretCreate={handleSecretCreate}
onSecretDelete={handleSecretDelete} onSecretDelete={handleSecretDelete}
onSecretUpdate={handleSecretUpdate} onSecretUpdate={handleSecretUpdate}
key={`overview-${key}-${index + 1}`} key={`overview-${key}-${index + 1}`}
environments={visibleEnvs} environments={visibleEnvs}
secretKey={key} secretKey={key}
getSecretByKey={getSecretByKey} getSecretByKey={getSecretByKey}
expandableColWidth={expandableTableWidth} expandableColWidth={expandableTableWidth}
/> />
))
) : (
<PermissionDeniedBanner />
))} ))}
</TBody> </TBody>
<TFoot> <TFoot>
@@ -171,7 +171,7 @@ export const CreateSecretForm = ({
)} )}
/> />
<FormLabel label="Environments" className="mb-2" /> <FormLabel label="Environments" className="mb-2" />
<div className="thin-scrollbar grid max-h-64 grid-cols-3 gap-4 overflow-auto "> <div className="thin-scrollbar grid max-h-64 grid-cols-3 gap-4 overflow-auto py-2">
{environments.map((env) => { {environments.map((env) => {
return ( return (
<Controller <Controller
@@ -183,13 +183,23 @@ export const CreateSecretForm = ({
isChecked={field.value} isChecked={field.value}
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
id={`secret-input-${env.slug}`} id={`secret-input-${env.slug}`}
className="!justify-start"
> >
{env.name} <span className="flex w-full flex-row items-center justify-start whitespace-pre-wrap">
{getSecretByKey(env.slug, newSecretKey) && ( <span title={env.name} className="truncate">
<Tooltip content="Secret exists. Will be overwritten"> {env.name}
<FontAwesomeIcon icon={faWarning} className="ml-1 text-yellow-400" /> </span>
</Tooltip> <span>
)} {getSecretByKey(env.slug, newSecretKey) && (
<Tooltip
className="max-w-[150px]"
content="Secret already exists, and it will be overwritten"
>
<FontAwesomeIcon icon={faWarning} className="ml-1 text-yellow-400" />
</Tooltip>
)}
</span>
</span>
</Checkbox> </Checkbox>
)} )}
/> />
@@ -148,8 +148,13 @@ export const SecretOverviewTableRow = ({
key={`secret-expanded-${slug}-${secretKey}`} key={`secret-expanded-${slug}-${secretKey}`}
className="hover:bg-mineshaft-700" className="hover:bg-mineshaft-700"
> >
<td className="flex" style={{ padding: "0.25rem 1rem" }}> <td
<div className="flex h-8 items-center">{name}</div> className="flex h-full items-center"
style={{ padding: "0.25rem 1rem" }}
>
<div title={name} className="flex h-8 w-[8rem] items-center ">
<span className="truncate">{name}</span>
</div>
</td> </td>
<td className="col-span-2 h-8 w-full"> <td className="col-span-2 h-8 w-full">
<SecretEditRow <SecretEditRow
@@ -351,7 +351,7 @@ export const SecretRotationPage = withProjectPermission(
{!isRotationProviderLoading && {!isRotationProviderLoading &&
secretRotationProviders?.providers.map((provider) => ( secretRotationProviders?.providers.map((provider) => (
<div <div
className="group relative flex h-32 cursor-pointer flex-row items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 hover:border-primary/40 hover:bg-primary/10" className="group relative flex h-32 cursor-pointer flex-row items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4 hover:border-primary/40 hover:bg-primary/10"
key={`infisical-rotation-provider-${provider.name}`} key={`infisical-rotation-provider-${provider.name}`}
tabIndex={0} tabIndex={0}
role="button" role="button"
@@ -362,8 +362,8 @@ export const SecretRotationPage = withProjectPermission(
> >
<img <img
src={`/images/secretRotation/${provider.image}`} src={`/images/secretRotation/${provider.image}`}
height={70} className="max-h-16"
width={70} style={{ maxWidth: "6rem" }}
alt="rotation provider logo" alt="rotation provider logo"
/> />
<div className="ml-4 max-w-xs text-xl font-semibold text-gray-300 duration-200 group-hover:text-gray-200"> <div className="ml-4 max-w-xs text-xl font-semibold text-gray-300 duration-200 group-hover:text-gray-200">
@@ -10,7 +10,7 @@ import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@a
import { useUpdateOrg } from "@app/hooks/api"; import { useUpdateOrg } from "@app/hooks/api";
const formSchema = yup.object({ const formSchema = yup.object({
name: yup.string().required().label("Organization Name"), name: yup.string().required().label("Organization Name").max(64, "Too long, maximum length is 64 characters"),
slug: yup slug: yup
.string() .string()
.matches(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens") .matches(/^[a-zA-Z0-9-]+$/, "Name must only contain alphanumeric characters or hyphens")
@@ -63,7 +63,10 @@ export const EnvironmentSection = () => {
}; };
return ( return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"> <div
id="environments"
className="mb-6 scroll-m-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
>
<div className="mb-8 flex justify-between"> <div className="mb-8 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Environments</p> <p className="text-xl font-semibold text-mineshaft-100">Environments</p>
<div> <div>
@@ -12,7 +12,7 @@ import { useRenameWorkspace } from "@app/hooks/api";
import { CopyButton } from "./CopyButton"; import { CopyButton } from "./CopyButton";
const formSchema = yup.object({ const formSchema = yup.object({
name: yup.string().required().label("Project Name") name: yup.string().required().label("Project Name").max(64, "Too long, maximum length is 64 characters"),
}); });
type FormData = yup.InferType<typeof formSchema>; type FormData = yup.InferType<typeof formSchema>;
@@ -78,18 +78,26 @@ export const ProjectNameChangeSection = () => {
</CopyButton> </CopyButton>
</div> </div>
</div> </div>
<div className="max-w-md"> <div className="max-w-md">
<Controller <ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Workspace}>
defaultValue="" {(isAllowed) => (
render={({ field, fieldState: { error } }) => ( <Controller
<FormControl isError={Boolean(error)} errorText={error?.message}> defaultValue=""
<Input placeholder="Project name" {...field} className="bg-mineshaft-800" /> render={({ field, fieldState: { error } }) => (
</FormControl> <FormControl isError={Boolean(error)} errorText={error?.message}>
<Input
placeholder="Project name"
{...field}
className="bg-mineshaft-800"
isDisabled={!isAllowed}
/>
</FormControl>
)}
control={control}
name="name"
/>
)} )}
control={control} </ProjectPermissionCan>
name="name"
/>
</div> </div>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Workspace}> <ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Workspace}>
{(isAllowed) => ( {(isAllowed) => (
@@ -244,6 +244,7 @@ export const UserInfoSSOStep = ({
onChange={(e) => setOrganizationName(e.target.value)} onChange={(e) => setOrganizationName(e.target.value)}
isRequired isRequired
className="h-12" className="h-12"
maxLength={64}
disabled disabled
/> />
{organizationNameError && ( {organizationNameError && (