Merge remote-tracking branch 'origin/main' into feat/PKI-76

This commit is contained in:
Carlos Monastyrski
2025-12-17 00:30:45 -03:00
88 changed files with 4013 additions and 952 deletions

View File

@@ -58,4 +58,5 @@ docs/documentation/platform/pki/enrollment-methods/api.mdx:private-key:139
docs/documentation/platform/pki/certificate-syncs/aws-secrets-manager.mdx:private-key:62
docs/documentation/platform/pki/certificate-syncs/chef.mdx:private-key:61
backend/src/services/certificate-request/certificate-request-service.test.ts:private-key:246
backend/src/services/certificate-request/certificate-request-service.test.ts:private-key:248
backend/src/services/certificate-request/certificate-request-service.test.ts:private-key:248
docs/documentation/platform/pki/enrollment-methods/api.mdx:private-key:142

View File

@@ -14,7 +14,7 @@ up-dev-metrics:
docker compose -f docker-compose.dev.yml --profile metrics up --build
up-prod:
docker-compose -f docker-compose.prod.yml up --build
docker compose -f docker-compose.prod.yml up --build
down:
docker compose -f docker-compose.dev.yml down

View File

@@ -170,6 +170,9 @@ import {
TIdentityGcpAuths,
TIdentityGcpAuthsInsert,
TIdentityGcpAuthsUpdate,
TIdentityGroupMembership,
TIdentityGroupMembershipInsert,
TIdentityGroupMembershipUpdate,
TIdentityJwtAuths,
TIdentityJwtAuthsInsert,
TIdentityJwtAuthsUpdate,
@@ -857,6 +860,11 @@ declare module "knex/types/tables" {
TUserGroupMembershipInsert,
TUserGroupMembershipUpdate
>;
[TableName.IdentityGroupMembership]: KnexOriginal.CompositeTableType<
TIdentityGroupMembership,
TIdentityGroupMembershipInsert,
TIdentityGroupMembershipUpdate
>;
[TableName.GroupProjectMembership]: KnexOriginal.CompositeTableType<
TGroupProjectMemberships,
TGroupProjectMembershipsInsert,

View File

@@ -0,0 +1,28 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.IdentityGroupMembership))) {
await knex.schema.createTable(TableName.IdentityGroupMembership, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("identityId").notNullable();
t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE");
t.uuid("groupId").notNullable();
t.foreign("groupId").references("id").inTable(TableName.Groups).onDelete("CASCADE");
t.timestamps(true, true, true);
t.unique(["identityId", "groupId"]);
});
}
await createOnUpdateTrigger(knex, TableName.IdentityGroupMembership);
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.IdentityGroupMembership)) {
await knex.schema.dropTable(TableName.IdentityGroupMembership);
await dropOnUpdateTrigger(knex, TableName.IdentityGroupMembership);
}
}

View File

@@ -0,0 +1,22 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const IdentityGroupMembershipSchema = z.object({
id: z.string().uuid(),
identityId: z.string().uuid(),
groupId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TIdentityGroupMembership = z.infer<typeof IdentityGroupMembershipSchema>;
export type TIdentityGroupMembershipInsert = Omit<z.input<typeof IdentityGroupMembershipSchema>, TImmutableDBKeys>;
export type TIdentityGroupMembershipUpdate = Partial<
Omit<z.input<typeof IdentityGroupMembershipSchema>, TImmutableDBKeys>
>;

View File

@@ -55,6 +55,7 @@ export * from "./identity-alicloud-auths";
export * from "./identity-aws-auths";
export * from "./identity-azure-auths";
export * from "./identity-gcp-auths";
export * from "./identity-group-membership";
export * from "./identity-jwt-auths";
export * from "./identity-kubernetes-auths";
export * from "./identity-metadata";

View File

@@ -42,6 +42,7 @@ export enum TableName {
GroupProjectMembershipRole = "group_project_membership_roles",
ExternalGroupOrgRoleMapping = "external_group_org_role_mappings",
UserGroupMembership = "user_group_membership",
IdentityGroupMembership = "identity_group_membership",
UserAliases = "user_aliases",
UserEncryptionKey = "user_encryption_keys",
AuthTokens = "auth_tokens",

View File

@@ -1,18 +1,27 @@
import { z } from "zod";
import { GroupsSchema, OrgMembershipRole, ProjectsSchema, UsersSchema } from "@app/db/schemas";
import { GroupsSchema, IdentitiesSchema, OrgMembershipRole, ProjectsSchema, UsersSchema } from "@app/db/schemas";
import {
EFilterReturnedProjects,
EFilterReturnedUsers,
EGroupProjectsOrderBy
FilterMemberType,
FilterReturnedMachineIdentities,
FilterReturnedProjects,
FilterReturnedUsers,
GroupMembersOrderBy,
GroupProjectsOrderBy
} from "@app/ee/services/group/group-types";
import { ApiDocsTags, GROUPS } from "@app/lib/api-docs";
import { OrderByDirection } from "@app/lib/types";
import { CharacterType, characterValidator } from "@app/lib/validator/validate-string";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
const GroupIdentityResponseSchema = IdentitiesSchema.pick({
id: true,
name: true
});
export const registerGroupRouter = async (server: FastifyZodProvider) => {
server.route({
url: "/",
@@ -190,8 +199,15 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_USERS.offset),
limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit),
username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username),
search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search),
filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers)
search: z
.string()
.trim()
.refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), {
message: "Invalid pattern: only alphanumeric characters, - are allowed."
})
.optional()
.describe(GROUPS.LIST_USERS.search),
filter: z.nativeEnum(FilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers)
}),
response: {
200: z.object({
@@ -202,12 +218,10 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
lastName: true,
id: true
})
.merge(
z.object({
isPartOfGroup: z.boolean(),
joinedGroupAt: z.date().nullable()
})
)
.extend({
isPartOfGroup: z.boolean(),
joinedGroupAt: z.date().nullable()
})
.array(),
totalCount: z.number()
})
@@ -227,6 +241,134 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
}
});
server.route({
method: "GET",
url: "/:id/machine-identities",
config: {
rateLimit: readLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
hide: false,
tags: [ApiDocsTags.Groups],
params: z.object({
id: z.string().trim().describe(GROUPS.LIST_MACHINE_IDENTITIES.id)
}),
querystring: z.object({
offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_MACHINE_IDENTITIES.offset),
limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_MACHINE_IDENTITIES.limit),
search: z
.string()
.trim()
.refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), {
message: "Invalid pattern: only alphanumeric characters, - are allowed."
})
.optional()
.describe(GROUPS.LIST_MACHINE_IDENTITIES.search),
filter: z
.nativeEnum(FilterReturnedMachineIdentities)
.optional()
.describe(GROUPS.LIST_MACHINE_IDENTITIES.filterMachineIdentities)
}),
response: {
200: z.object({
machineIdentities: GroupIdentityResponseSchema.extend({
isPartOfGroup: z.boolean(),
joinedGroupAt: z.date().nullable()
}).array(),
totalCount: z.number()
})
}
},
handler: async (req) => {
const { machineIdentities, totalCount } = await server.services.group.listGroupMachineIdentities({
id: req.params.id,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.query
});
return { machineIdentities, totalCount };
}
});
server.route({
method: "GET",
url: "/:id/members",
config: {
rateLimit: readLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
hide: false,
tags: [ApiDocsTags.Groups],
params: z.object({
id: z.string().trim().describe(GROUPS.LIST_MEMBERS.id)
}),
querystring: z.object({
offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_MEMBERS.offset),
limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_MEMBERS.limit),
search: z
.string()
.trim()
.refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), {
message: "Invalid pattern: only alphanumeric characters, - are allowed."
})
.optional()
.describe(GROUPS.LIST_MEMBERS.search),
orderBy: z
.nativeEnum(GroupMembersOrderBy)
.default(GroupMembersOrderBy.Name)
.optional()
.describe(GROUPS.LIST_MEMBERS.orderBy),
orderDirection: z.nativeEnum(OrderByDirection).optional().describe(GROUPS.LIST_MEMBERS.orderDirection),
memberTypeFilter: z
.union([z.nativeEnum(FilterMemberType), z.array(z.nativeEnum(FilterMemberType))])
.optional()
.describe(GROUPS.LIST_MEMBERS.memberTypeFilter)
.transform((val) => {
if (!val) return undefined;
return Array.isArray(val) ? val : [val];
})
}),
response: {
200: z.object({
members: z
.discriminatedUnion("type", [
z.object({
id: z.string(),
joinedGroupAt: z.date().nullable(),
type: z.literal("user"),
user: UsersSchema.pick({ id: true, firstName: true, lastName: true, email: true, username: true })
}),
z.object({
id: z.string(),
joinedGroupAt: z.date().nullable(),
type: z.literal("machineIdentity"),
machineIdentity: GroupIdentityResponseSchema
})
])
.array(),
totalCount: z.number()
})
}
},
handler: async (req) => {
const { members, totalCount } = await server.services.group.listGroupMembers({
id: req.params.id,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.query
});
return { members, totalCount };
}
});
server.route({
method: "GET",
url: "/:id/projects",
@@ -243,11 +385,18 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
querystring: z.object({
offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_PROJECTS.offset),
limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_PROJECTS.limit),
search: z.string().trim().optional().describe(GROUPS.LIST_PROJECTS.search),
filter: z.nativeEnum(EFilterReturnedProjects).optional().describe(GROUPS.LIST_PROJECTS.filterProjects),
search: z
.string()
.trim()
.refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), {
message: "Invalid pattern: only alphanumeric characters, - are allowed."
})
.optional()
.describe(GROUPS.LIST_PROJECTS.search),
filter: z.nativeEnum(FilterReturnedProjects).optional().describe(GROUPS.LIST_PROJECTS.filterProjects),
orderBy: z
.nativeEnum(EGroupProjectsOrderBy)
.default(EGroupProjectsOrderBy.Name)
.nativeEnum(GroupProjectsOrderBy)
.default(GroupProjectsOrderBy.Name)
.describe(GROUPS.LIST_PROJECTS.orderBy),
orderDirection: z
.nativeEnum(OrderByDirection)
@@ -263,11 +412,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
description: true,
type: true
})
.merge(
z.object({
joinedGroupAt: z.date().nullable()
})
)
.extend({
joinedGroupAt: z.date().nullable()
})
.array(),
totalCount: z.number()
})
@@ -325,6 +472,40 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
}
});
server.route({
method: "POST",
url: "/:id/machine-identities/:machineIdentityId",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
hide: false,
tags: [ApiDocsTags.Groups],
params: z.object({
id: z.string().trim().describe(GROUPS.ADD_MACHINE_IDENTITY.id),
machineIdentityId: z.string().trim().describe(GROUPS.ADD_MACHINE_IDENTITY.machineIdentityId)
}),
response: {
200: z.object({
id: z.string()
})
}
},
handler: async (req) => {
const machineIdentity = await server.services.group.addMachineIdentityToGroup({
id: req.params.id,
identityId: req.params.machineIdentityId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
return machineIdentity;
}
});
server.route({
method: "DELETE",
url: "/:id/users/:username",
@@ -362,4 +543,38 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
return user;
}
});
server.route({
method: "DELETE",
url: "/:id/machine-identities/:machineIdentityId",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
hide: false,
tags: [ApiDocsTags.Groups],
params: z.object({
id: z.string().trim().describe(GROUPS.DELETE_MACHINE_IDENTITY.id),
machineIdentityId: z.string().trim().describe(GROUPS.DELETE_MACHINE_IDENTITY.machineIdentityId)
}),
response: {
200: z.object({
id: z.string()
})
}
},
handler: async (req) => {
const machineIdentity = await server.services.group.removeMachineIdentityFromGroup({
id: req.params.id,
identityId: req.params.machineIdentityId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
return machineIdentity;
}
});
};

View File

@@ -56,7 +56,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
TAccessApprovalRequestReviewerDALFactory,
"create" | "find" | "findOne" | "transaction" | "delete"
>;
groupDAL: Pick<TGroupDALFactory, "findAllGroupPossibleMembers">;
groupDAL: Pick<TGroupDALFactory, "findAllGroupPossibleUsers">;
smtpService: Pick<TSmtpService, "sendMail">;
userDAL: Pick<
TUserDALFactory,
@@ -182,7 +182,7 @@ export const accessApprovalRequestServiceFactory = ({
await Promise.all(
approverGroupIds.map((groupApproverId) =>
groupDAL
.findAllGroupPossibleMembers({
.findAllGroupPossibleUsers({
orgId: actorOrgId,
groupId: groupApproverId
})

View File

@@ -6,6 +6,8 @@ import { BadRequestError } from "@app/lib/errors";
import { isPrivateIp } from "@app/lib/ip/ipRange";
import { getDbConnectionHost } from "@app/lib/knex";
const ERROR_MESSAGE = "Invalid host";
export const verifyHostInputValidity = async (host: string, isGateway = false) => {
const appCfg = getConfig();
@@ -40,13 +42,13 @@ export const verifyHostInputValidity = async (host: string, isGateway = false) =
}
}
const normalizedHost = host.split(":")[0];
const normalizedHost = host.split(":")[0].toLowerCase();
const inputHostIps: string[] = [];
if (net.isIPv4(host)) {
inputHostIps.push(host);
} else {
if (normalizedHost === "localhost" || normalizedHost === "host.docker.internal") {
throw new BadRequestError({ message: "Invalid db host" });
throw new BadRequestError({ message: ERROR_MESSAGE });
}
try {
const resolvedIps = await dns.resolve4(host);
@@ -62,10 +64,10 @@ export const verifyHostInputValidity = async (host: string, isGateway = false) =
if (!(appCfg.DYNAMIC_SECRET_ALLOW_INTERNAL_IP || appCfg.ALLOW_INTERNAL_IP_CONNECTIONS)) {
const isInternalIp = inputHostIps.some((el) => isPrivateIp(el));
if (isInternalIp) throw new BadRequestError({ message: "Invalid db host" });
if (isInternalIp) throw new BadRequestError({ message: ERROR_MESSAGE });
}
const isAppUsedIps = inputHostIps.some((el) => exclusiveIps.includes(el));
if (isAppUsedIps) throw new BadRequestError({ message: "Invalid db host" });
if (isAppUsedIps) throw new BadRequestError({ message: ERROR_MESSAGE });
return inputHostIps;
};

View File

@@ -6,7 +6,14 @@ import { DatabaseError } from "@app/lib/errors";
import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex";
import { OrderByDirection } from "@app/lib/types";
import { EFilterReturnedProjects, EFilterReturnedUsers, EGroupProjectsOrderBy } from "./group-types";
import {
FilterMemberType,
FilterReturnedMachineIdentities,
FilterReturnedProjects,
FilterReturnedUsers,
GroupMembersOrderBy,
GroupProjectsOrderBy
} from "./group-types";
export type TGroupDALFactory = ReturnType<typeof groupDALFactory>;
@@ -70,7 +77,7 @@ export const groupDALFactory = (db: TDbClient) => {
};
// special query
const findAllGroupPossibleMembers = async ({
const findAllGroupPossibleUsers = async ({
orgId,
groupId,
offset = 0,
@@ -85,7 +92,7 @@ export const groupDALFactory = (db: TDbClient) => {
limit?: number;
username?: string;
search?: string;
filter?: EFilterReturnedUsers;
filter?: FilterReturnedUsers;
}) => {
try {
const query = db
@@ -127,11 +134,11 @@ export const groupDALFactory = (db: TDbClient) => {
}
switch (filter) {
case EFilterReturnedUsers.EXISTING_MEMBERS:
void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is not", null);
case FilterReturnedUsers.EXISTING_MEMBERS:
void query.whereNotNull(`${TableName.UserGroupMembership}.createdAt`);
break;
case EFilterReturnedUsers.NON_MEMBERS:
void query.andWhere(`${TableName.UserGroupMembership}.createdAt`, "is", null);
case FilterReturnedUsers.NON_MEMBERS:
void query.whereNull(`${TableName.UserGroupMembership}.createdAt`);
break;
default:
break;
@@ -155,7 +162,7 @@ export const groupDALFactory = (db: TDbClient) => {
username: memberUsername,
firstName,
lastName,
isPartOfGroup: !!memberGroupId,
isPartOfGroup: Boolean(memberGroupId),
joinedGroupAt
})
),
@@ -167,6 +174,256 @@ export const groupDALFactory = (db: TDbClient) => {
}
};
const findAllGroupPossibleMachineIdentities = async ({
orgId,
groupId,
offset = 0,
limit,
search,
filter
}: {
orgId: string;
groupId: string;
offset?: number;
limit?: number;
search?: string;
filter?: FilterReturnedMachineIdentities;
}) => {
try {
const query = db
.replicaNode()(TableName.Membership)
.where(`${TableName.Membership}.scopeOrgId`, orgId)
.where(`${TableName.Membership}.scope`, AccessScope.Organization)
.whereNotNull(`${TableName.Membership}.actorIdentityId`)
.whereNull(`${TableName.Identity}.projectId`)
.join(TableName.Identity, `${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`)
.leftJoin(TableName.IdentityGroupMembership, (bd) => {
bd.on(`${TableName.IdentityGroupMembership}.identityId`, "=", `${TableName.Identity}.id`).andOn(
`${TableName.IdentityGroupMembership}.groupId`,
"=",
db.raw("?", [groupId])
);
})
.select(
db.ref("id").withSchema(TableName.Membership),
db.ref("groupId").withSchema(TableName.IdentityGroupMembership),
db.ref("createdAt").withSchema(TableName.IdentityGroupMembership).as("joinedGroupAt"),
db.ref("name").withSchema(TableName.Identity),
db.ref("id").withSchema(TableName.Identity).as("identityId"),
db.raw(`count(*) OVER() as total_count`)
)
.offset(offset)
.orderBy("name", "asc");
if (limit) {
void query.limit(limit);
}
if (search) {
void query.andWhereRaw(`LOWER("${TableName.Identity}"."name") ilike ?`, `%${search}%`);
}
switch (filter) {
case FilterReturnedMachineIdentities.ASSIGNED_MACHINE_IDENTITIES:
void query.whereNotNull(`${TableName.IdentityGroupMembership}.createdAt`);
break;
case FilterReturnedMachineIdentities.NON_ASSIGNED_MACHINE_IDENTITIES:
void query.whereNull(`${TableName.IdentityGroupMembership}.createdAt`);
break;
default:
break;
}
const machineIdentities = await query;
return {
machineIdentities: machineIdentities.map(({ name, identityId, joinedGroupAt, groupId: identityGroupId }) => ({
id: identityId,
name,
isPartOfGroup: Boolean(identityGroupId),
joinedGroupAt
})),
// @ts-expect-error col select is raw and not strongly typed
totalCount: Number(machineIdentities?.[0]?.total_count ?? 0)
};
} catch (error) {
throw new DatabaseError({ error, name: "Find all group identities" });
}
};
const findAllGroupPossibleMembers = async ({
orgId,
groupId,
offset = 0,
limit,
search,
orderBy = GroupMembersOrderBy.Name,
orderDirection = OrderByDirection.ASC,
memberTypeFilter
}: {
orgId: string;
groupId: string;
offset?: number;
limit?: number;
search?: string;
orderBy?: GroupMembersOrderBy;
orderDirection?: OrderByDirection;
memberTypeFilter?: FilterMemberType[];
}) => {
try {
const includeUsers =
!memberTypeFilter || memberTypeFilter.length === 0 || memberTypeFilter.includes(FilterMemberType.USERS);
const includeMachineIdentities =
!memberTypeFilter ||
memberTypeFilter.length === 0 ||
memberTypeFilter.includes(FilterMemberType.MACHINE_IDENTITIES);
const query = db
.replicaNode()(TableName.Membership)
.where(`${TableName.Membership}.scopeOrgId`, orgId)
.where(`${TableName.Membership}.scope`, AccessScope.Organization)
.leftJoin(TableName.Users, `${TableName.Membership}.actorUserId`, `${TableName.Users}.id`)
.leftJoin(TableName.Identity, `${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`)
.leftJoin(TableName.UserGroupMembership, (bd) => {
bd.on(`${TableName.UserGroupMembership}.userId`, "=", `${TableName.Users}.id`).andOn(
`${TableName.UserGroupMembership}.groupId`,
"=",
db.raw("?", [groupId])
);
})
.leftJoin(TableName.IdentityGroupMembership, (bd) => {
bd.on(`${TableName.IdentityGroupMembership}.identityId`, "=", `${TableName.Identity}.id`).andOn(
`${TableName.IdentityGroupMembership}.groupId`,
"=",
db.raw("?", [groupId])
);
})
.where((qb) => {
void qb
.where((innerQb) => {
void innerQb
.whereNotNull(`${TableName.Membership}.actorUserId`)
.whereNotNull(`${TableName.UserGroupMembership}.createdAt`)
.where(`${TableName.Users}.isGhost`, false);
})
.orWhere((innerQb) => {
void innerQb
.whereNotNull(`${TableName.Membership}.actorIdentityId`)
.whereNotNull(`${TableName.IdentityGroupMembership}.createdAt`)
.whereNull(`${TableName.Identity}.projectId`);
});
})
.select(
db.raw(
`CASE WHEN "${TableName.Membership}"."actorUserId" IS NOT NULL THEN "${TableName.UserGroupMembership}"."createdAt" ELSE "${TableName.IdentityGroupMembership}"."createdAt" END as "joinedGroupAt"`
),
db.ref("email").withSchema(TableName.Users),
db.ref("username").withSchema(TableName.Users),
db.ref("firstName").withSchema(TableName.Users),
db.ref("lastName").withSchema(TableName.Users),
db.raw(`"${TableName.Users}"."id"::text as "userId"`),
db.raw(`"${TableName.Identity}"."id"::text as "identityId"`),
db.ref("name").withSchema(TableName.Identity).as("identityName"),
db.raw(
`CASE WHEN "${TableName.Membership}"."actorUserId" IS NOT NULL THEN 'user' ELSE 'machineIdentity' END as "member_type"`
),
db.raw(`count(*) OVER() as total_count`)
);
void query.andWhere((qb) => {
if (includeUsers) {
void qb.whereNotNull(`${TableName.Membership}.actorUserId`);
}
if (includeMachineIdentities) {
void qb[includeUsers ? "orWhere" : "where"]((innerQb) => {
void innerQb.whereNotNull(`${TableName.Membership}.actorIdentityId`);
});
}
if (!includeUsers && !includeMachineIdentities) {
void qb.whereRaw("FALSE");
}
});
if (search) {
void query.andWhere((qb) => {
void qb
.whereRaw(
`CONCAT_WS(' ', "${TableName.Users}"."firstName", "${TableName.Users}"."lastName", lower("${TableName.Users}"."username")) ilike ?`,
[`%${search}%`]
)
.orWhereRaw(`LOWER("${TableName.Identity}"."name") ilike ?`, [`%${search}%`]);
});
}
if (orderBy === GroupMembersOrderBy.Name) {
const orderDirectionClause = orderDirection === OrderByDirection.ASC ? "ASC" : "DESC";
// This order by clause is used to sort the members by name.
// It first checks if the full name (first name and last name) is not empty, then the username, then the email, then the identity name. If all of these are empty, it returns null.
void query.orderByRaw(
`LOWER(COALESCE(NULLIF(TRIM(CONCAT_WS(' ', "${TableName.Users}"."firstName", "${TableName.Users}"."lastName")), ''), "${TableName.Users}"."username", "${TableName.Users}"."email", "${TableName.Identity}"."name")) ${orderDirectionClause}`
);
}
if (offset) {
void query.offset(offset);
}
if (limit) {
void query.limit(limit);
}
const results = (await query) as unknown as {
email: string;
username: string;
firstName: string;
lastName: string;
userId: string;
identityId: string;
identityName: string;
member_type: "user" | "machineIdentity";
joinedGroupAt: Date;
total_count: string;
}[];
const members = results.map(
({ email, username, firstName, lastName, userId, identityId, identityName, member_type, joinedGroupAt }) => {
if (member_type === "user") {
return {
id: userId,
joinedGroupAt,
type: "user" as const,
user: {
id: userId,
email,
username,
firstName,
lastName
}
};
}
return {
id: identityId,
joinedGroupAt,
type: "machineIdentity" as const,
machineIdentity: {
id: identityId,
name: identityName
}
};
}
);
return {
members,
totalCount: Number(results?.[0]?.total_count ?? 0)
};
} catch (error) {
throw new DatabaseError({ error, name: "Find all group possible members" });
}
};
const findAllGroupProjects = async ({
orgId,
groupId,
@@ -182,8 +439,8 @@ export const groupDALFactory = (db: TDbClient) => {
offset?: number;
limit?: number;
search?: string;
filter?: EFilterReturnedProjects;
orderBy?: EGroupProjectsOrderBy;
filter?: FilterReturnedProjects;
orderBy?: GroupProjectsOrderBy;
orderDirection?: OrderByDirection;
}) => {
try {
@@ -225,10 +482,10 @@ export const groupDALFactory = (db: TDbClient) => {
}
switch (filter) {
case EFilterReturnedProjects.ASSIGNED_PROJECTS:
case FilterReturnedProjects.ASSIGNED_PROJECTS:
void query.whereNotNull(`${TableName.Membership}.id`);
break;
case EFilterReturnedProjects.UNASSIGNED_PROJECTS:
case FilterReturnedProjects.UNASSIGNED_PROJECTS:
void query.whereNull(`${TableName.Membership}.id`);
break;
default:
@@ -313,6 +570,8 @@ export const groupDALFactory = (db: TDbClient) => {
...groupOrm,
findGroups,
findByOrgId,
findAllGroupPossibleUsers,
findAllGroupPossibleMachineIdentities,
findAllGroupPossibleMembers,
findAllGroupProjects,
findGroupsByProjectId,

View File

@@ -5,9 +5,11 @@ import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, ForbiddenRequestError, NotFoundError, ScimRequestError } from "@app/lib/errors";
import {
TAddIdentitiesToGroup,
TAddUsersToGroup,
TAddUsersToGroupByUserIds,
TConvertPendingGroupAdditionsToGroupMemberships,
TRemoveIdentitiesFromGroup,
TRemoveUsersFromGroupByUserIds
} from "./group-types";
@@ -285,6 +287,70 @@ export const addUsersToGroupByUserIds = async ({
});
};
/**
* Add identities with identity ids [identityIds] to group [group].
* @param {group} group - group to add identity(s) to
* @param {string[]} identityIds - id(s) of organization scoped identity(s) to add to group
* @returns {Promise<{ id: string }[]>} - id(s) of added identity(s)
*/
export const addIdentitiesToGroup = async ({
group,
identityIds,
identityDAL,
identityGroupMembershipDAL,
membershipDAL
}: TAddIdentitiesToGroup) => {
const identityIdsSet = new Set(identityIds);
const identityIdsArray = Array.from(identityIdsSet);
// ensure all identities exist and belong to the org via org scoped membership
const foundIdentitiesMemberships = await membershipDAL.find({
scope: AccessScope.Organization,
scopeOrgId: group.orgId,
$in: {
actorIdentityId: identityIdsArray
}
});
const existingIdentityOrgMembershipsIdentityIdsSet = new Set(
foundIdentitiesMemberships.map((u) => u.actorIdentityId as string)
);
identityIdsArray.forEach((identityId) => {
if (!existingIdentityOrgMembershipsIdentityIdsSet.has(identityId)) {
throw new ForbiddenRequestError({
message: `Identity with id ${identityId} is not part of the organization`
});
}
});
// check if identity group membership already exists
const existingIdentityGroupMemberships = await identityGroupMembershipDAL.find({
groupId: group.id,
$in: {
identityId: identityIdsArray
}
});
if (existingIdentityGroupMemberships.length) {
throw new BadRequestError({
message: `${identityIdsArray.length > 1 ? `Identities are` : `Identity is`} already part of the group ${group.slug}`
});
}
return identityDAL.transaction(async (tx) => {
await identityGroupMembershipDAL.insertMany(
foundIdentitiesMemberships.map((membership) => ({
identityId: membership.actorIdentityId as string,
groupId: group.id
})),
tx
);
return identityIdsArray.map((identityId) => ({ id: identityId }));
});
};
/**
* Remove users with user ids [userIds] from group [group].
* - Users may be part of the group (non-pending + pending);
@@ -421,6 +487,75 @@ export const removeUsersFromGroupByUserIds = async ({
});
};
/**
* Remove identities with identity ids [identityIds] from group [group].
* @param {group} group - group to remove identity(s) from
* @param {string[]} identityIds - id(s) of identity(s) to remove from group
* @returns {Promise<{ id: string }[]>} - id(s) of removed identity(s)
*/
export const removeIdentitiesFromGroup = async ({
group,
identityIds,
identityDAL,
membershipDAL,
identityGroupMembershipDAL
}: TRemoveIdentitiesFromGroup) => {
const identityIdsSet = new Set(identityIds);
const identityIdsArray = Array.from(identityIdsSet);
// ensure all identities exist and belong to the org via org scoped membership
const foundIdentitiesMemberships = await membershipDAL.find({
scope: AccessScope.Organization,
scopeOrgId: group.orgId,
$in: {
actorIdentityId: identityIdsArray
}
});
const foundIdentitiesMembershipsIdentityIdsSet = new Set(
foundIdentitiesMemberships.map((u) => u.actorIdentityId as string)
);
if (foundIdentitiesMembershipsIdentityIdsSet.size !== identityIdsArray.length) {
throw new NotFoundError({
message: `Machine identities not found`
});
}
// check if identity group membership already exists
const existingIdentityGroupMemberships = await identityGroupMembershipDAL.find({
groupId: group.id,
$in: {
identityId: identityIdsArray
}
});
const existingIdentityGroupMembershipsIdentityIdsSet = new Set(
existingIdentityGroupMemberships.map((u) => u.identityId)
);
identityIdsArray.forEach((identityId) => {
if (!existingIdentityGroupMembershipsIdentityIdsSet.has(identityId)) {
throw new ForbiddenRequestError({
message: `Machine identities are not part of the group ${group.slug}`
});
}
});
return identityDAL.transaction(async (tx) => {
await identityGroupMembershipDAL.delete(
{
groupId: group.id,
$in: {
identityId: identityIdsArray
}
},
tx
);
return identityIdsArray.map((identityId) => ({ id: identityId }));
});
};
/**
* Convert pending group additions for users with ids [userIds] to group memberships.
* @param {string[]} userIds - id(s) of user(s) to try to convert pending group additions to group memberships

View File

@@ -5,6 +5,8 @@ import { AccessScope, OrganizationActionScope, OrgMembershipRole, TRoles } from
import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal";
import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
import { TMembershipDALFactory } from "@app/services/membership/membership-dal";
import { TMembershipRoleDALFactory } from "@app/services/membership/membership-role-dal";
import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal";
import { TOrgDALFactory } from "@app/services/org/org-dal";
@@ -18,33 +20,48 @@ import { OrgPermissionGroupActions, OrgPermissionSubjects } from "../permission/
import { constructPermissionErrorMessage, validatePrivilegeChangeOperation } from "../permission/permission-fns";
import { TPermissionServiceFactory } from "../permission/permission-service-types";
import { TGroupDALFactory } from "./group-dal";
import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "./group-fns";
import {
addIdentitiesToGroup,
addUsersToGroupByUserIds,
removeIdentitiesFromGroup,
removeUsersFromGroupByUserIds
} from "./group-fns";
import {
TAddMachineIdentityToGroupDTO,
TAddUserToGroupDTO,
TCreateGroupDTO,
TDeleteGroupDTO,
TGetGroupByIdDTO,
TListGroupMachineIdentitiesDTO,
TListGroupMembersDTO,
TListGroupProjectsDTO,
TListGroupUsersDTO,
TRemoveMachineIdentityFromGroupDTO,
TRemoveUserFromGroupDTO,
TUpdateGroupDTO
} from "./group-types";
import { TIdentityGroupMembershipDALFactory } from "./identity-group-membership-dal";
import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal";
type TGroupServiceFactoryDep = {
userDAL: Pick<TUserDALFactory, "find" | "findUserEncKeyByUserIdsBatch" | "transaction" | "findUserByUsername">;
identityDAL: Pick<TIdentityDALFactory, "findOne" | "find" | "transaction">;
identityGroupMembershipDAL: Pick<TIdentityGroupMembershipDALFactory, "find" | "delete" | "insertMany">;
groupDAL: Pick<
TGroupDALFactory,
| "create"
| "findOne"
| "update"
| "delete"
| "findAllGroupPossibleUsers"
| "findAllGroupPossibleMachineIdentities"
| "findAllGroupPossibleMembers"
| "findById"
| "transaction"
| "findAllGroupProjects"
>;
membershipGroupDAL: Pick<TMembershipGroupDALFactory, "find" | "findOne" | "create">;
membershipDAL: Pick<TMembershipDALFactory, "find" | "findOne">;
membershipRoleDAL: Pick<TMembershipRoleDALFactory, "create" | "delete">;
orgDAL: Pick<TOrgDALFactory, "findMembership" | "countAllOrgMembers" | "findById">;
userGroupMembershipDAL: Pick<
@@ -65,6 +82,9 @@ type TGroupServiceFactoryDep = {
export type TGroupServiceFactory = ReturnType<typeof groupServiceFactory>;
export const groupServiceFactory = ({
identityDAL,
membershipDAL,
identityGroupMembershipDAL,
userDAL,
groupDAL,
orgDAL,
@@ -362,7 +382,7 @@ export const groupServiceFactory = ({
message: `Failed to find group with ID ${id}`
});
const { members, totalCount } = await groupDAL.findAllGroupPossibleMembers({
const { members, totalCount } = await groupDAL.findAllGroupPossibleUsers({
orgId: group.orgId,
groupId: group.id,
offset,
@@ -375,6 +395,100 @@ export const groupServiceFactory = ({
return { users: members, totalCount };
};
const listGroupMachineIdentities = async ({
id,
offset,
limit,
actor,
actorId,
actorAuthMethod,
actorOrgId,
search,
filter
}: TListGroupMachineIdentitiesDTO) => {
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
const { permission } = await permissionService.getOrgPermission({
scope: OrganizationActionScope.Any,
actor,
actorId,
orgId: actorOrgId,
actorAuthMethod,
actorOrgId
});
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups);
const group = await groupDAL.findOne({
orgId: actorOrgId,
id
});
if (!group)
throw new NotFoundError({
message: `Failed to find group with ID ${id}`
});
const { machineIdentities, totalCount } = await groupDAL.findAllGroupPossibleMachineIdentities({
orgId: group.orgId,
groupId: group.id,
offset,
limit,
search,
filter
});
return { machineIdentities, totalCount };
};
const listGroupMembers = async ({
id,
offset,
limit,
search,
orderBy,
orderDirection,
memberTypeFilter,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TListGroupMembersDTO) => {
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
const { permission } = await permissionService.getOrgPermission({
scope: OrganizationActionScope.Any,
actor,
actorId,
orgId: actorOrgId,
actorAuthMethod,
actorOrgId
});
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups);
const group = await groupDAL.findOne({
orgId: actorOrgId,
id
});
if (!group)
throw new NotFoundError({
message: `Failed to find group with ID ${id}`
});
const { members, totalCount } = await groupDAL.findAllGroupPossibleMembers({
orgId: group.orgId,
groupId: group.id,
offset,
limit,
search,
orderBy,
orderDirection,
memberTypeFilter
});
return { members, totalCount };
};
const listGroupProjects = async ({
id,
offset,
@@ -504,6 +618,81 @@ export const groupServiceFactory = ({
return users[0];
};
const addMachineIdentityToGroup = async ({
id,
identityId,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TAddMachineIdentityToGroupDTO) => {
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
const { permission } = await permissionService.getOrgPermission({
scope: OrganizationActionScope.Any,
actor,
actorId,
orgId: actorOrgId,
actorAuthMethod,
actorOrgId
});
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups);
// check if group with slug exists
const group = await groupDAL.findOne({
orgId: actorOrgId,
id
});
if (!group)
throw new NotFoundError({
message: `Failed to find group with ID ${id}`
});
const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([group.role], actorOrgId);
const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId);
// check if user has broader or equal to privileges than group
const permissionBoundary = validatePrivilegeChangeOperation(
shouldUseNewPrivilegeSystem,
OrgPermissionGroupActions.AddIdentities,
OrgPermissionSubjects.Groups,
permission,
rolePermissionDetails.permission
);
if (!permissionBoundary.isValid)
throw new PermissionBoundaryError({
message: constructPermissionErrorMessage(
"Failed to add identity to more privileged group",
shouldUseNewPrivilegeSystem,
OrgPermissionGroupActions.AddIdentities,
OrgPermissionSubjects.Groups
),
details: { missingPermissions: permissionBoundary.missingPermissions }
});
const identityMembership = await membershipDAL.findOne({
scope: AccessScope.Organization,
scopeOrgId: group.orgId,
actorIdentityId: identityId
});
if (!identityMembership) {
throw new NotFoundError({ message: `Identity with id ${identityId} is not part of the organization` });
}
const identities = await addIdentitiesToGroup({
group,
identityIds: [identityId],
identityDAL,
membershipDAL,
identityGroupMembershipDAL
});
return identities[0];
};
const removeUserFromGroup = async ({
id,
username,
@@ -587,14 +776,91 @@ export const groupServiceFactory = ({
return users[0];
};
const removeMachineIdentityFromGroup = async ({
id,
identityId,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TRemoveMachineIdentityFromGroupDTO) => {
if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" });
const { permission } = await permissionService.getOrgPermission({
scope: OrganizationActionScope.Any,
actor,
actorId,
orgId: actorOrgId,
actorAuthMethod,
actorOrgId
});
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups);
const group = await groupDAL.findOne({
orgId: actorOrgId,
id
});
if (!group)
throw new NotFoundError({
message: `Failed to find group with ID ${id}`
});
const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([group.role], actorOrgId);
const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId);
// check if user has broader or equal to privileges than group
const permissionBoundary = validatePrivilegeChangeOperation(
shouldUseNewPrivilegeSystem,
OrgPermissionGroupActions.RemoveIdentities,
OrgPermissionSubjects.Groups,
permission,
rolePermissionDetails.permission
);
if (!permissionBoundary.isValid)
throw new PermissionBoundaryError({
message: constructPermissionErrorMessage(
"Failed to remove identity from more privileged group",
shouldUseNewPrivilegeSystem,
OrgPermissionGroupActions.RemoveIdentities,
OrgPermissionSubjects.Groups
),
details: { missingPermissions: permissionBoundary.missingPermissions }
});
const identityMembership = await membershipDAL.findOne({
scope: AccessScope.Organization,
scopeOrgId: group.orgId,
actorIdentityId: identityId
});
if (!identityMembership) {
throw new NotFoundError({ message: `Identity with id ${identityId} is not part of the organization` });
}
const identities = await removeIdentitiesFromGroup({
group,
identityIds: [identityId],
identityDAL,
membershipDAL,
identityGroupMembershipDAL
});
return identities[0];
};
return {
createGroup,
updateGroup,
deleteGroup,
listGroupUsers,
listGroupMachineIdentities,
listGroupMembers,
listGroupProjects,
addUserToGroup,
addMachineIdentityToGroup,
removeUserFromGroup,
removeMachineIdentityFromGroup,
getGroupById
};
};

View File

@@ -3,6 +3,8 @@ import { Knex } from "knex";
import { TGroups } from "@app/db/schemas";
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
import { OrderByDirection, TGenericPermission } from "@app/lib/types";
import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
import { TMembershipDALFactory } from "@app/services/membership/membership-dal";
import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
@@ -10,6 +12,8 @@ import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal
import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { TIdentityGroupMembershipDALFactory } from "./identity-group-membership-dal";
export type TCreateGroupDTO = {
name: string;
slug?: string;
@@ -39,7 +43,25 @@ export type TListGroupUsersDTO = {
limit: number;
username?: string;
search?: string;
filter?: EFilterReturnedUsers;
filter?: FilterReturnedUsers;
} & TGenericPermission;
export type TListGroupMachineIdentitiesDTO = {
id: string;
offset: number;
limit: number;
search?: string;
filter?: FilterReturnedMachineIdentities;
} & TGenericPermission;
export type TListGroupMembersDTO = {
id: string;
offset: number;
limit: number;
search?: string;
orderBy?: GroupMembersOrderBy;
orderDirection?: OrderByDirection;
memberTypeFilter?: FilterMemberType[];
} & TGenericPermission;
export type TListGroupProjectsDTO = {
@@ -47,8 +69,8 @@ export type TListGroupProjectsDTO = {
offset: number;
limit: number;
search?: string;
filter?: EFilterReturnedProjects;
orderBy?: EGroupProjectsOrderBy;
filter?: FilterReturnedProjects;
orderBy?: GroupProjectsOrderBy;
orderDirection?: OrderByDirection;
} & TGenericPermission;
@@ -61,11 +83,21 @@ export type TAddUserToGroupDTO = {
username: string;
} & TGenericPermission;
export type TAddMachineIdentityToGroupDTO = {
id: string;
identityId: string;
} & TGenericPermission;
export type TRemoveUserFromGroupDTO = {
id: string;
username: string;
} & TGenericPermission;
export type TRemoveMachineIdentityFromGroupDTO = {
id: string;
identityId: string;
} & TGenericPermission;
// group fns types
export type TAddUsersToGroup = {
@@ -93,6 +125,14 @@ export type TAddUsersToGroupByUserIds = {
tx?: Knex;
};
export type TAddIdentitiesToGroup = {
group: TGroups;
identityIds: string[];
identityDAL: Pick<TIdentityDALFactory, "transaction">;
identityGroupMembershipDAL: Pick<TIdentityGroupMembershipDALFactory, "find" | "insertMany">;
membershipDAL: Pick<TMembershipDALFactory, "find">;
};
export type TRemoveUsersFromGroupByUserIds = {
group: TGroups;
userIds: string[];
@@ -103,6 +143,14 @@ export type TRemoveUsersFromGroupByUserIds = {
tx?: Knex;
};
export type TRemoveIdentitiesFromGroup = {
group: TGroups;
identityIds: string[];
identityDAL: Pick<TIdentityDALFactory, "find" | "transaction">;
membershipDAL: Pick<TMembershipDALFactory, "find">;
identityGroupMembershipDAL: Pick<TIdentityGroupMembershipDALFactory, "find" | "delete">;
};
export type TConvertPendingGroupAdditionsToGroupMemberships = {
userIds: string[];
userDAL: Pick<TUserDALFactory, "findUserEncKeyByUserIdsBatch" | "transaction" | "find" | "findById">;
@@ -117,16 +165,30 @@ export type TConvertPendingGroupAdditionsToGroupMemberships = {
tx?: Knex;
};
export enum EFilterReturnedUsers {
export enum FilterReturnedUsers {
EXISTING_MEMBERS = "existingMembers",
NON_MEMBERS = "nonMembers"
}
export enum EFilterReturnedProjects {
export enum FilterReturnedMachineIdentities {
ASSIGNED_MACHINE_IDENTITIES = "assignedMachineIdentities",
NON_ASSIGNED_MACHINE_IDENTITIES = "nonAssignedMachineIdentities"
}
export enum FilterReturnedProjects {
ASSIGNED_PROJECTS = "assignedProjects",
UNASSIGNED_PROJECTS = "unassignedProjects"
}
export enum EGroupProjectsOrderBy {
export enum GroupProjectsOrderBy {
Name = "name"
}
export enum GroupMembersOrderBy {
Name = "name"
}
export enum FilterMemberType {
USERS = "users",
MACHINE_IDENTITIES = "machineIdentities"
}

View File

@@ -0,0 +1,13 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TIdentityGroupMembershipDALFactory = ReturnType<typeof identityGroupMembershipDALFactory>;
export const identityGroupMembershipDALFactory = (db: TDbClient) => {
const identityGroupMembershipOrm = ormify(db, TableName.IdentityGroupMembership);
return {
...identityGroupMembershipOrm
};
};

View File

@@ -88,8 +88,10 @@ export enum OrgPermissionGroupActions {
Edit = "edit",
Delete = "delete",
GrantPrivileges = "grant-privileges",
AddIdentities = "add-identities",
AddMembers = "add-members",
RemoveMembers = "remove-members"
RemoveMembers = "remove-members",
RemoveIdentities = "remove-identities"
}
export enum OrgPermissionBillingActions {
@@ -381,8 +383,10 @@ const buildAdminPermission = () => {
can(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups);
can(OrgPermissionGroupActions.Delete, OrgPermissionSubjects.Groups);
can(OrgPermissionGroupActions.GrantPrivileges, OrgPermissionSubjects.Groups);
can(OrgPermissionGroupActions.AddIdentities, OrgPermissionSubjects.Groups);
can(OrgPermissionGroupActions.AddMembers, OrgPermissionSubjects.Groups);
can(OrgPermissionGroupActions.RemoveMembers, OrgPermissionSubjects.Groups);
can(OrgPermissionGroupActions.RemoveIdentities, OrgPermissionSubjects.Groups);
can(OrgPermissionBillingActions.Read, OrgPermissionSubjects.Billing);
can(OrgPermissionBillingActions.ManageBilling, OrgPermissionSubjects.Billing);

View File

@@ -178,6 +178,16 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
.where(`${TableName.UserGroupMembership}.userId`, actorId)
.select(db.ref("id").withSchema(TableName.Groups));
const identityGroupSubquery = (tx || db)(TableName.Groups)
.leftJoin(
TableName.IdentityGroupMembership,
`${TableName.IdentityGroupMembership}.groupId`,
`${TableName.Groups}.id`
)
.where(`${TableName.Groups}.orgId`, scopeData.orgId)
.where(`${TableName.IdentityGroupMembership}.identityId`, actorId)
.select(db.ref("id").withSchema(TableName.Groups));
const docs = await (tx || db)
.replicaNode()(TableName.Membership)
.join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`)
@@ -214,7 +224,9 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
.where(`${TableName.Membership}.actorUserId`, actorId)
.orWhereIn(`${TableName.Membership}.actorGroupId`, userGroupSubquery);
} else if (actorType === ActorType.IDENTITY) {
void qb.where(`${TableName.Membership}.actorIdentityId`, actorId);
void qb
.where(`${TableName.Membership}.actorIdentityId`, actorId)
.orWhereIn(`${TableName.Membership}.actorGroupId`, identityGroupSubquery);
}
})
.where((qb) => {
@@ -653,6 +665,15 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
orgId: string
) => {
try {
const identityGroupSubquery = db(TableName.Groups)
.leftJoin(
TableName.IdentityGroupMembership,
`${TableName.IdentityGroupMembership}.groupId`,
`${TableName.Groups}.id`
)
.where(`${TableName.Groups}.orgId`, orgId)
.select(db.ref("id").withSchema(TableName.Groups));
const docs = await db
.replicaNode()(TableName.Membership)
.join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`)
@@ -668,7 +689,11 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
void queryBuilder.on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`);
})
.where(`${TableName.Membership}.scopeOrgId`, orgId)
.whereNotNull(`${TableName.Membership}.actorIdentityId`)
.where((qb) => {
void qb
.whereNotNull(`${TableName.Membership}.actorIdentityId`)
.orWhereIn(`${TableName.Membership}.actorGroupId`, identityGroupSubquery);
})
.where(`${TableName.Membership}.scope`, AccessScope.Project)
.where(`${TableName.Membership}.scopeProjectId`, projectId)
.select(selectAllTableCols(TableName.MembershipRole))

View File

@@ -72,7 +72,7 @@ type TScimServiceFactoryDep = {
TGroupDALFactory,
| "create"
| "findOne"
| "findAllGroupPossibleMembers"
| "findAllGroupPossibleUsers"
| "delete"
| "findGroups"
| "transaction"
@@ -952,7 +952,7 @@ export const scimServiceFactory = ({
}
const users = await groupDAL
.findAllGroupPossibleMembers({
.findAllGroupPossibleUsers({
orgId: group.orgId,
groupId: group.id
})

View File

@@ -106,6 +106,25 @@ export const GROUPS = {
filterUsers:
"Whether to filter the list of returned users. 'existingMembers' will only return existing users in the group, 'nonMembers' will only return users not in the group, undefined will return all users in the organization."
},
LIST_MACHINE_IDENTITIES: {
id: "The ID of the group to list identities for.",
offset: "The offset to start from. If you enter 10, it will start from the 10th identity.",
limit: "The number of identities to return.",
search: "The text string that machine identity name will be filtered by.",
filterMachineIdentities:
"Whether to filter the list of returned identities. 'assignedMachineIdentities' will only return identities assigned to the group, 'nonAssignedMachineIdentities' will only return identities not assigned to the group, undefined will return all identities in the organization."
},
LIST_MEMBERS: {
id: "The ID of the group to list members for.",
offset: "The offset to start from. If you enter 10, it will start from the 10th member.",
limit: "The number of members to return.",
search:
"The text string that member email(in case of users) or name(in case of machine identities) will be filtered by.",
orderBy: "The column to order members by.",
orderDirection: "The direction to order members in.",
memberTypeFilter:
"Filter members by type. Can be a single value ('users' or 'machineIdentities') or an array of values. If not specified, both users and machine identities will be returned."
},
LIST_PROJECTS: {
id: "The ID of the group to list projects for.",
offset: "The offset to start from. If you enter 10, it will start from the 10th project.",
@@ -120,12 +139,20 @@ export const GROUPS = {
id: "The ID of the group to add the user to.",
username: "The username of the user to add to the group."
},
ADD_MACHINE_IDENTITY: {
id: "The ID of the group to add the machine identity to.",
machineIdentityId: "The ID of the machine identity to add to the group."
},
GET_BY_ID: {
id: "The ID of the group to fetch."
},
DELETE_USER: {
id: "The ID of the group to remove the user from.",
username: "The username of the user to remove from the group."
},
DELETE_MACHINE_IDENTITY: {
id: "The ID of the group to remove the machine identity from.",
machineIdentityId: "The ID of the machine identity to remove from the group."
}
} as const;

View File

@@ -229,6 +229,7 @@ const envSchema = z
CAPTCHA_SECRET: zpStr(z.string().optional()),
CAPTCHA_SITE_KEY: zpStr(z.string().optional()),
INTERCOM_ID: zpStr(z.string().optional()),
CDN_HOST: zpStr(z.string().optional()),
// TELEMETRY
OTEL_TELEMETRY_COLLECTION_ENABLED: zodStrBool.default("false"),

View File

@@ -1,6 +1,8 @@
import fs from "node:fs";
import path from "node:path";
import staticServe from "@fastify/static";
import RE2 from "re2";
import { getConfig, IS_PACKAGED } from "@app/lib/config/env";
@@ -15,6 +17,9 @@ export const registerServeUI = async (
dir: string;
}
) => {
const appCfg = getConfig();
const cdnHost = appCfg.CDN_HOST || "";
// use this only for frontend runtime static non-sensitive configuration in standalone mode
// that app needs before loading like posthog dsn key
// for most of the other usecase use server config
@@ -25,15 +30,26 @@ export const registerServeUI = async (
hide: true
},
handler: (_req, res) => {
const appCfg = getConfig();
void res.type("application/javascript");
const config = {
CAPTCHA_SITE_KEY: appCfg.CAPTCHA_SITE_KEY,
POSTHOG_API_KEY: appCfg.POSTHOG_PROJECT_API_KEY,
INTERCOM_ID: appCfg.INTERCOM_ID,
TELEMETRY_CAPTURING_ENABLED: appCfg.TELEMETRY_ENABLED
TELEMETRY_CAPTURING_ENABLED: appCfg.TELEMETRY_ENABLED,
CDN_HOST: cdnHost
};
const js = `window.__INFISICAL_RUNTIME_ENV__ = Object.freeze(${JSON.stringify(config)});`;
// Define window.__toCdnUrl for Vite's experimental.renderBuiltUrl runtime support
// This function is called by dynamically imported chunks to resolve CDN URLs
const js = `
window.__INFISICAL_RUNTIME_ENV__ = Object.freeze(${JSON.stringify(config)});
window.__toCdnUrl = function(filename) {
var cdnHost = window.__INFISICAL_RUNTIME_ENV__.CDN_HOST || "";
if (cdnHost && filename.startsWith("assets/")) {
return cdnHost + "/" + filename;
}
return "/" + filename;
};
`.trim();
return res.send(js);
}
});
@@ -41,6 +57,21 @@ export const registerServeUI = async (
if (standaloneMode) {
const frontendName = IS_PACKAGED ? "frontend" : "frontend-build";
const frontendPath = path.join(dir, frontendName);
const indexHtmlPath = path.join(frontendPath, "index.html");
let indexHtml = fs.readFileSync(indexHtmlPath, "utf-8");
if (cdnHost) {
// Replace relative asset paths with CDN URLs in script and link tags
indexHtml = indexHtml
.replace(/src="\/assets\//g, `src="${cdnHost}/assets/`)
.replace(/href="\/assets\//g, `href="${cdnHost}/assets/`);
indexHtml = indexHtml.replace(new RE2(`(__INFISICAL_CDN_HOST__)`, "g"), cdnHost);
} else {
indexHtml = indexHtml.replace(new RE2(`(__INFISICAL_CDN_HOST__)`, "g"), "");
}
await server.register(staticServe, {
root: frontendPath,
wildcard: false,
@@ -60,12 +91,12 @@ export const registerServeUI = async (
return;
}
return reply.sendFile("index.html", {
immutable: false,
maxAge: 0,
lastModified: false,
etag: false
});
return reply
.type("text/html")
.header("Cache-Control", "no-cache, no-store, must-revalidate")
.header("Pragma", "no-cache")
.header("Expires", "0")
.send(indexHtml);
}
});
}

View File

@@ -46,6 +46,7 @@ import { githubOrgSyncDALFactory } from "@app/ee/services/github-org-sync/github
import { githubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service";
import { groupDALFactory } from "@app/ee/services/group/group-dal";
import { groupServiceFactory } from "@app/ee/services/group/group-service";
import { identityGroupMembershipDALFactory } from "@app/ee/services/group/identity-group-membership-dal";
import { userGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
import { isHsmActiveAndEnabled } from "@app/ee/services/hsm/hsm-fns";
import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service";
@@ -470,6 +471,7 @@ export const registerRoutes = async (
const identityMetadataDAL = identityMetadataDALFactory(db);
const identityAccessTokenDAL = identityAccessTokenDALFactory(db);
const identityOrgMembershipDAL = identityOrgDALFactory(db);
const identityGroupMembershipDAL = identityGroupMembershipDALFactory(db);
const identityProjectDAL = identityProjectDALFactory(db);
const identityAuthTemplateDAL = identityAuthTemplateDALFactory(db);
@@ -754,6 +756,9 @@ export const registerRoutes = async (
membershipGroupDAL
});
const groupService = groupServiceFactory({
identityDAL,
membershipDAL,
identityGroupMembershipDAL,
userDAL,
groupDAL,
orgDAL,

View File

@@ -7,6 +7,7 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { ApprovalPolicyType } from "@app/services/approval-policy/approval-policy-enums";
import {
TApprovalPolicy,
TApprovalPolicyInputs,
TCreatePolicyDTO,
TCreateRequestDTO,
TUpdatePolicyDTO
@@ -21,7 +22,8 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
policyResponseSchema,
createRequestSchema,
requestResponseSchema,
grantResponseSchema
grantResponseSchema,
inputsSchema
}: {
server: FastifyZodProvider;
policyType: ApprovalPolicyType;
@@ -41,6 +43,7 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
createRequestSchema: z.ZodType<TCreateRequestDTO>;
requestResponseSchema: z.ZodTypeAny;
grantResponseSchema: z.ZodTypeAny;
inputsSchema: z.ZodType<TApprovalPolicyInputs>;
}) => {
// Policies
server.route({
@@ -622,4 +625,31 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
return { grant };
}
});
server.route({
method: "POST",
url: "/check-policy-match",
config: {
rateLimit: readLimit
},
schema: {
description: "Check if a resource path matches any approval policy and if the user has an active grant",
body: z.object({
projectId: z.string().uuid(),
inputs: inputsSchema
}),
response: {
200: z.object({
requiresApproval: z.boolean(),
hasActiveGrant: z.boolean()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const result = await server.services.approvalPolicy.checkPolicyMatch(policyType, req.body, req.permission);
return result;
}
});
};

View File

@@ -2,6 +2,7 @@ import { ApprovalPolicyType } from "@app/services/approval-policy/approval-polic
import {
CreatePamAccessPolicySchema,
CreatePamAccessRequestSchema,
PamAccessPolicyInputsSchema,
PamAccessPolicySchema,
PamAccessRequestGrantSchema,
PamAccessRequestSchema,
@@ -23,7 +24,8 @@ export const APPROVAL_POLICY_REGISTER_ROUTER_MAP: Record<
policyResponseSchema: PamAccessPolicySchema,
createRequestSchema: CreatePamAccessRequestSchema,
requestResponseSchema: PamAccessRequestSchema,
grantResponseSchema: PamAccessRequestGrantSchema
grantResponseSchema: PamAccessRequestGrantSchema,
inputsSchema: PamAccessPolicyInputsSchema
});
}
};

View File

@@ -9,7 +9,7 @@ import {
TemporaryPermissionMode,
UsersSchema
} from "@app/db/schemas";
import { EFilterReturnedUsers } from "@app/ee/services/group/group-types";
import { FilterReturnedUsers } from "@app/ee/services/group/group-types";
import { ApiDocsTags, GROUPS, PROJECTS } from "@app/lib/api-docs";
import { ms } from "@app/lib/ms";
import { isUuidV4 } from "@app/lib/validator";
@@ -355,9 +355,10 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) =>
rateLimit: readLimit
},
schema: {
hide: false,
hide: true,
deprecated: true,
tags: [ApiDocsTags.ProjectGroups],
description: "Return project group users",
description: "Return project group users (Deprecated: Use /api/v1/groups/{id}/users instead)",
params: z.object({
projectId: z.string().trim().describe(GROUPS.LIST_USERS.projectId),
groupId: z.string().trim().describe(GROUPS.LIST_USERS.id)
@@ -367,7 +368,7 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) =>
limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit),
username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username),
search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search),
filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers)
filter: z.nativeEnum(FilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers)
}),
response: {
200: z.object({

View File

@@ -9,7 +9,7 @@ import {
TemporaryPermissionMode,
UsersSchema
} from "@app/db/schemas";
import { EFilterReturnedUsers } from "@app/ee/services/group/group-types";
import { FilterReturnedUsers } from "@app/ee/services/group/group-types";
import { ApiDocsTags, GROUPS, PROJECTS } from "@app/lib/api-docs";
import { ms } from "@app/lib/ms";
import { isUuidV4 } from "@app/lib/validator";
@@ -367,7 +367,7 @@ export const registerDeprecatedGroupProjectRouter = async (server: FastifyZodPro
limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit),
username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username),
search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search),
filter: z.nativeEnum(EFilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers)
filter: z.nativeEnum(FilterReturnedUsers).optional().describe(GROUPS.LIST_USERS.filterUsers)
}),
response: {
200: z.object({

View File

@@ -31,6 +31,7 @@ import {
import { APPROVAL_POLICY_FACTORY_MAP } from "./approval-policy-factory";
import {
ApprovalPolicyStep,
TApprovalPolicyInputs,
TApprovalRequest,
TCreatePolicyDTO,
TCreateRequestDTO,
@@ -883,6 +884,36 @@ export const approvalPolicyServiceFactory = ({
return { grant: updatedGrant };
};
const checkPolicyMatch = async (
policyType: ApprovalPolicyType,
{ projectId, inputs }: { projectId: string; inputs: TApprovalPolicyInputs },
actor: OrgServiceActor
) => {
await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId,
actionProjectType: ActionProjectType.Any
});
const fac = APPROVAL_POLICY_FACTORY_MAP[policyType](policyType);
const policy = await fac.matchPolicy(approvalPolicyDAL, projectId, inputs);
if (!policy) {
return { requiresApproval: false, hasActiveGrant: false };
}
const hasActiveGrant = await fac.canAccess(approvalRequestGrantsDAL, projectId, actor.id, inputs);
return {
requiresApproval: !hasActiveGrant,
hasActiveGrant
};
};
return {
create,
list,
@@ -897,6 +928,7 @@ export const approvalPolicyServiceFactory = ({
cancelRequest,
listGrants,
getGrantById,
revokeGrant
revokeGrant,
checkPolicyMatch
};
};

View File

@@ -26,13 +26,16 @@ export const pamAccessPolicyFactory: TApprovalResourceFactory<
let bestMatch: { policy: TPamAccessPolicy; wildcardCount: number; pathLength: number } | null = null;
const normalizedAccountPath = inputs.accountPath.startsWith("/") ? inputs.accountPath.slice(1) : inputs.accountPath;
for (const policy of policies) {
const p = policy as TPamAccessPolicy;
for (const c of p.conditions.conditions) {
// Find the most specific path pattern
// TODO(andrey): Make matching logic more advanced by accounting for wildcard positions
for (const pathPattern of c.accountPaths) {
if (picomatch(pathPattern)(inputs.accountPath)) {
const normalizedPathPattern = pathPattern.startsWith("/") ? pathPattern.slice(1) : pathPattern;
if (picomatch(normalizedPathPattern)(normalizedAccountPath)) {
const wildcardCount = (pathPattern.match(/\*/g) || []).length;
const pathLength = pathPattern.length;

View File

@@ -10,7 +10,7 @@ import { TGroupDALFactory } from "../../ee/services/group/group-dal";
import { TProjectDALFactory } from "../project/project-dal";
type TGroupProjectServiceFactoryDep = {
groupDAL: Pick<TGroupDALFactory, "findOne" | "findAllGroupPossibleMembers">;
groupDAL: Pick<TGroupDALFactory, "findOne" | "findAllGroupPossibleUsers">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "findProjectGhostUser" | "findById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getProjectPermissionByRoles">;
};
@@ -51,7 +51,7 @@ export const groupProjectServiceFactory = ({
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups);
const { members, totalCount } = await groupDAL.findAllGroupPossibleMembers({
const { members, totalCount } = await groupDAL.findAllGroupPossibleUsers({
orgId: project.orgId,
groupId: id,
offset,

View File

@@ -32,7 +32,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => {
const removeExpiredTokens = async (tx?: Knex) => {
logger.info(`${QueueName.DailyResourceCleanUp}: remove expired access token started`);
const BATCH_SIZE = 10000;
const BATCH_SIZE = 5000;
const MAX_RETRY_ON_FAILURE = 3;
const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
const MAX_TTL = 315_360_000; // Maximum TTL value in seconds (10 years)
@@ -101,7 +101,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => {
} finally {
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 10); // time to breathe for db
setTimeout(resolve, 500); // time to breathe for db
});
}
isRetrying = numberOfRetryOnFailure > 0;

View File

@@ -25,12 +25,27 @@ export const projectDALFactory = (db: TDbClient) => {
const findIdentityProjects = async (identityId: string, orgId: string, projectType?: ProjectType) => {
try {
const identityGroupSubquery = db
.replicaNode()(TableName.Groups)
.leftJoin(
TableName.IdentityGroupMembership,
`${TableName.IdentityGroupMembership}.groupId`,
`${TableName.Groups}.id`
)
.where(`${TableName.Groups}.orgId`, orgId)
.where(`${TableName.IdentityGroupMembership}.identityId`, identityId)
.select(db.ref("id").withSchema(TableName.Groups));
const workspaces = await db
.replicaNode()(TableName.Membership)
.where(`${TableName.Membership}.scope`, AccessScope.Project)
.where(`${TableName.Membership}.actorIdentityId`, identityId)
.join(TableName.Project, `${TableName.Membership}.scopeProjectId`, `${TableName.Project}.id`)
.where(`${TableName.Project}.orgId`, orgId)
.andWhere((qb) => {
void qb
.where(`${TableName.Membership}.actorIdentityId`, identityId)
.orWhereIn(`${TableName.Membership}.actorGroupId`, identityGroupSubquery);
})
.andWhere((qb) => {
if (projectType) {
void qb.where(`${TableName.Project}.type`, projectType);
@@ -347,11 +362,25 @@ export const projectDALFactory = (db: TDbClient) => {
.where(`${TableName.Groups}.orgId`, dto.orgId)
.where(`${TableName.UserGroupMembership}.userId`, dto.actorId)
.select(db.ref("id").withSchema(TableName.Groups));
const identityGroupMembershipSubquery = db
.replicaNode()(TableName.Groups)
.leftJoin(
TableName.IdentityGroupMembership,
`${TableName.IdentityGroupMembership}.groupId`,
`${TableName.Groups}.id`
)
.where(`${TableName.Groups}.orgId`, dto.orgId)
.where(`${TableName.IdentityGroupMembership}.identityId`, dto.actorId)
.select(db.ref("id").withSchema(TableName.Groups));
const membershipSubQuery = db(TableName.Membership)
.where(`${TableName.Membership}.scope`, AccessScope.Project)
.where((qb) => {
if (dto.actor === ActorType.IDENTITY) {
void qb.where(`${TableName.Membership}.actorIdentityId`, dto.actorId);
void qb
.where(`${TableName.Membership}.actorIdentityId`, dto.actorId)
.orWhereIn(`${TableName.Membership}.actorGroupId`, identityGroupMembershipSubquery);
} else {
void qb
.where(`${TableName.Membership}.actorUserId`, dto.actorId)

View File

@@ -200,6 +200,11 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
.leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SecretVersionV2}.userActorId`)
.leftJoin(TableName.Identity, `${TableName.Identity}.id`, `${TableName.SecretVersionV2}.identityActorId`)
.leftJoin(TableName.UserGroupMembership, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`)
.leftJoin(
TableName.IdentityGroupMembership,
`${TableName.IdentityGroupMembership}.identityId`,
`${TableName.Identity}.id`
)
.leftJoin(TableName.Membership, (qb) => {
void qb
.on(`${TableName.Membership}.scope`, db.raw("?", [AccessScope.Project]))
@@ -208,7 +213,8 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => {
void sqb
.on(`${TableName.Membership}.actorUserId`, `${TableName.SecretVersionV2}.userActorId`)
.orOn(`${TableName.Membership}.actorIdentityId`, `${TableName.SecretVersionV2}.identityActorId`)
.orOn(`${TableName.Membership}.actorGroupId`, `${TableName.UserGroupMembership}.groupId`);
.orOn(`${TableName.Membership}.actorGroupId`, `${TableName.UserGroupMembership}.groupId`)
.orOn(`${TableName.Membership}.actorGroupId`, `${TableName.IdentityGroupMembership}.groupId`);
});
})
.leftJoin(TableName.SecretV2, `${TableName.SecretVersionV2}.secretId`, `${TableName.SecretV2}.id`)

View File

@@ -9,7 +9,7 @@ services:
condition: service_healthy
redis:
condition: service_started
image: infisical/infisical:latest-postgres
image: infisical/infisical:latest # PIN THIS TO A SPECIFIC TAG
pull_policy: always
env_file: .env
ports:

View File

@@ -0,0 +1,4 @@
---
title: "Get Certificate Request"
openapi: "GET /api/v1/cert-manager/certificates/certificate-requests/{requestId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "Issue Certificate"
openapi: "POST /api/v1/cert-manager/certificates"
---

View File

@@ -0,0 +1,4 @@
---
title: "Add Machine Identity to Group"
openapi: "POST /api/v1/groups/{id}/machine-identities/{machineIdentityId}"
---

View File

@@ -0,0 +1,4 @@
---
title: "List Group Machine Identities"
openapi: "GET /api/v1/groups/{id}/machine-identities"
---

View File

@@ -0,0 +1,5 @@
---
title: "List Group Members"
openapi: "GET /api/v1/groups/{id}/members"
---

View File

@@ -0,0 +1,5 @@
---
title: "List Group Projects"
openapi: "GET /api/v1/groups/{id}/projects"
---

View File

@@ -0,0 +1,4 @@
---
title: "Remove Machine Identity from Group"
openapi: "DELETE /api/v1/groups/{id}/machine-identities/{machineIdentityId}"
---

View File

@@ -33,7 +33,10 @@
},
{
"group": "Guides",
"pages": ["documentation/guides/organization-structure"]
"pages": [
"documentation/guides/governance-models",
"documentation/guides/organization-structure"
]
}
]
},
@@ -662,7 +665,16 @@
"group": "Concepts",
"pages": [
"documentation/platform/pki/concepts/certificate-mgmt",
"documentation/platform/pki/concepts/certificate-lifecycle"
"documentation/platform/pki/concepts/certificate-lifecycle",
"documentation/platform/pki/concepts/certificate-components"
]
},
{
"group": "Guides",
"pages": [
"documentation/platform/pki/guides/request-cert-agent",
"documentation/platform/pki/guides/request-cert-api",
"documentation/platform/pki/guides/request-cert-acme"
]
}
]
@@ -702,6 +714,7 @@
{
"group": "Infrastructure Integrations",
"pages": [
"integrations/platforms/certificate-agent",
"documentation/platform/pki/k8s-cert-manager",
"documentation/platform/pki/integration-guides/gloo-mesh",
"documentation/platform/pki/integration-guides/windows-server-acme",
@@ -876,7 +889,12 @@
"api-reference/endpoints/groups/get-by-id",
"api-reference/endpoints/groups/add-group-user",
"api-reference/endpoints/groups/remove-group-user",
"api-reference/endpoints/groups/list-group-users"
"api-reference/endpoints/groups/list-group-users",
"api-reference/endpoints/groups/add-group-machine-identity",
"api-reference/endpoints/groups/remove-group-machine-identity",
"api-reference/endpoints/groups/list-group-machine-identities",
"api-reference/endpoints/groups/list-group-projects",
"api-reference/endpoints/groups/list-group-members"
]
},
{
@@ -2481,7 +2499,7 @@
]
},
{
"group": "Infisical PKI",
"group": "Certificate Management",
"pages": [
{
"group": "Certificate Authorities",
@@ -2520,6 +2538,8 @@
"pages": [
"api-reference/endpoints/certificates/list",
"api-reference/endpoints/certificates/read",
"api-reference/endpoints/certificates/certificate-request",
"api-reference/endpoints/certificates/create-certificate",
"api-reference/endpoints/certificates/renew",
"api-reference/endpoints/certificates/update-config",
"api-reference/endpoints/certificates/revoke",

View File

@@ -0,0 +1,479 @@
---
title: "Centralized vs. Self-Service Governance"
sidebarTitle: "Governance Models"
description: "Learn how to structure Infisical for centralized platform administration or team self-service autonomy"
---
Organizations adopt different approaches to secrets management governance based on their security requirements, compliance obligations, and team structures. Infisical supports a spectrum of governance models—from fully centralized platform administration to team-driven self-service.
This guide covers how to configure Infisical for different governance approaches and what tradeoffs to consider.
## Understanding the Spectrum
Most organizations don't operate at the extremes. Instead, they land somewhere on a spectrum between two models:
**Centralized Administration**: A dedicated platform or security team controls project creation, access policies, integrations, and secret lifecycle management. Application teams consume secrets but don't manage the underlying infrastructure.
**Self-Service**: Teams have autonomy to create projects, manage their own access, configure integrations, and operate independently. Central teams provide guardrails and standards rather than direct management.
<Note>
The right model depends on your regulatory environment, team maturity, organizational scale, and security posture. Highly regulated industries often lean toward centralized control, while organizations with mature DevOps practices may benefit from self-service with guardrails.
</Note>
## Organizational Structure
Project and environment structure is where governance decisions start to take shape.
### Project Ownership
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **Project creation** | Platform team creates all projects on behalf of application teams | Teams create their own projects as needed |
| **Naming conventions** | Enforced through process and templates | Documented standards, team-enforced |
| **Folder structure** | Predefined conventions (e.g., `/apps/{app-name}/{component}`) | Teams define hierarchies that fit their needs |
### Project Templates
[Project Templates](/documentation/platform/project-templates) allow you to define standard environments, roles, and settings that are applied when creating new projects. This feature supports both governance models:
- **Centralized**: Require all projects to use approved templates, ensuring consistent environment structures and role definitions across the organization
- **Self-Service**: Provide templates as a starting point that teams can build upon, reducing setup time while allowing customization
<Info>
Project Templates apply at creation time and don't propagate changes to existing projects. Plan your template strategy before widespread adoption.
</Info>
### Environment Strategy
Environments define the deployment stages where secrets are managed.
- **Standardized environments** (e.g., `dev`, `staging`, `prod`) provide consistency and simplify cross-team collaboration
- **Custom environments** allow teams to model their specific deployment pipelines (e.g., `qa`, `uat`, `perf-test`, `prod-eu`, `prod-us`)
With Project Templates, you can enforce a base set of environments while optionally allowing teams to add additional ones.
## Authentication and Identity
How you manage identity—both for users and machines—significantly affects your governance strategy.
### User Authentication
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **Login methods** | SSO enforced, local accounts disabled | SSO available, local accounts permitted |
| **MFA** | Required organization-wide | Encouraged or optional |
| **Session duration** | Short sessions enforced | Longer sessions permitted |
Infisical supports multiple authentication methods that can be configured based on your requirements:
- [SAML SSO](/documentation/platform/sso/overview) with providers like Okta, Azure AD, Google Workspace, and JumpCloud
- [OIDC SSO](/documentation/platform/sso/general-oidc) for standards-based authentication
- [LDAP](/documentation/platform/ldap/overview) for directory-based authentication
### User Provisioning
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **User onboarding** | Automatic via SCIM from identity provider | Direct invitations by project admins |
| **Role assignment** | Mapped from IdP groups | Assigned manually per project |
| **Offboarding** | Automatic deprovisioning via SCIM | Manual removal required |
[SCIM provisioning](/documentation/platform/scim/overview) enables automatic user lifecycle management synced with your identity provider. Combined with [group mappings](/documentation/platform/scim/group-mappings), you can automatically assign organization roles based on IdP group membership.
For organizations using SAML, [group membership mapping](/documentation/platform/sso/google-saml#saml-group-membership-mapping) synchronizes group memberships when users log in, ensuring access reflects current IdP state.
### Machine Identity Management
[Machine identities](/documentation/platform/identities/machine-identities) authenticate applications, services, and automated systems with Infisical. Your governance model shapes how these identities are managed:
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **Identity creation** | Platform team creates all identities; teams submit requests | Teams create identities for their own projects |
| **Auth method selection** | Standardized methods enforced (e.g., "Kubernetes Auth only in production") | Teams choose methods appropriate to their infrastructure |
| **Credential management** | Platform team manages and distributes credentials | Teams manage their own identity credentials |
Infisical supports multiple machine identity authentication methods:
- [Universal Auth](/documentation/platform/identities/universal-auth) — Client ID/secret authentication for any environment
- [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) — Native authentication using Kubernetes service accounts
- [AWS Auth](/documentation/platform/identities/aws-auth) — Authentication using AWS IAM roles
- [Azure Auth](/documentation/platform/identities/azure-auth) — Authentication using Azure managed identities
- [GCP Auth](/documentation/platform/identities/gcp-auth) — Authentication using GCP service accounts
- [OIDC Auth](/documentation/platform/identities/oidc-auth) — Authentication using OIDC identity tokens
Centralized organizations often standardize on platform-native authentication methods (Kubernetes Auth, cloud provider auth) to eliminate static credentials, while self-service models may permit Universal Auth for flexibility.
## Access Control
Infisical's [role-based access control](/documentation/platform/access-controls/role-based-access-controls) operates at two levels: organization and project. How you configure these controls determines who can do what across your secrets infrastructure.
### Organization-Level Roles
[Organization roles](/documentation/platform/access-controls/role-based-access-controls#organization-level-access-controls) govern access to organization-wide resources like billing, member management, and identity provider configuration.
| Role | Capabilities |
|------|--------------|
| **Admin** | Full access to all organization settings and all projects |
| **Member** | Basic organization access; project access determined separately |
| **Custom roles** | Tailored permissions for specific administrative functions |
<Warning>
The Admin role grants access to all projects in the organization. In both governance models, this role should be assigned sparingly to prevent unintended access to sensitive secrets.
</Warning>
### Project-Level Roles
[Project roles](/documentation/platform/access-controls/role-based-access-controls#project-level-access-controls) control what users and machine identities can do within a specific project.
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **Role definition** | Custom roles defined by platform team; teams assigned predefined roles | Teams create project-level custom roles as needed |
| **Production access** | Restricted to specific roles; requires approval | Teams determine their own access patterns |
| **Role assignment** | Managed through groups synced from IdP | Project admins assign roles directly |
Built-in project roles include:
- **Admin**: Full access to all environments, folders, secrets, and project settings
- **Developer**: Standard access with restrictions on project administration and policy management
- **Viewer**: Read-only access to secrets and project resources
[Custom roles](/documentation/platform/access-controls/role-based-access-controls#creating-custom-roles) let you define granular permissions for specific environments, folder paths, and actions—useful for implementing least-privilege access.
### Groups
[Groups](/documentation/platform/groups) simplify access management by allowing you to assign roles to collections of users rather than individuals.
Key behaviors:
- Adding a group to a project grants all group members access with the assigned role(s)
- Users inherit composite permissions from all groups they belong to
- Group membership can be managed locally or synced from your identity provider via SCIM
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **Group management** | Groups defined in IdP, synced via SCIM | Project admins create and manage local groups |
| **Project membership** | Controlled through IdP group assignments | Direct group/user additions by project admins |
### Temporary and Just-in-Time Access
For sensitive environments, both governance models benefit from time-limited access:
- [Temporary access](/documentation/platform/access-controls/temporary-access) grants permissions that automatically expire after a defined period
- [Additional privileges](/documentation/platform/access-controls/additional-privileges) allow temporary elevation beyond a user's base role
Centralized organizations typically require temporary access for production environments, while self-service models may use it selectively for high-risk operations.
## Approval Workflows
[Approval workflows](/documentation/platform/pr-workflows) add oversight to sensitive operations, supporting compliance requirements and change management practices.
### Change Policies
Change policies require approval before secrets can be modified in specific environments or folder paths. When a policy applies, proposed changes enter a review queue where designated approvers can approve and merge—or reject—the changes.
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **Policy scope** | Required for all production environments | Teams define policies for their sensitive environments |
| **Approvers** | Security team members or designated reviewers | Team leads or senior engineers |
| **Bypass permissions** | Strictly limited | May allow emergency bypass for on-call |
### Access Requests
[Access requests](/documentation/platform/access-controls/access-requests) formalize the process of granting access to sensitive resources. Combined with temporary access, this enables just-in-time access patterns where users request and receive time-limited permissions.
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **Request requirement** | Mandatory for production access | Optional or environment-specific |
| **Approval workflow** | Formal review by security team | Peer approval or team lead sign-off |
| **Access duration** | Strictly time-limited | Flexible based on need |
### Notifications
Approval workflows integrate with [Slack](/documentation/platform/workflow-integrations/slack-integration) and [Microsoft Teams](/documentation/platform/workflow-integrations/microsoft-teams-integration) to notify approvers in real-time, reducing delays in the approval process.
## Secret Lifecycle
Who creates, rotates, and retires secrets—and how—depends on your governance model.
### App Connections
[App Connections](/integrations/app-connections/overview) are reusable integrations with third-party platforms like AWS, GCP, Azure, databases, and other services. They're required for secret rotation, dynamic secrets, and secret syncs—so how you manage them affects multiple workflows.
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **Connection creation** | Platform team creates connections at the organization level and distributes access to projects | Teams create their own connections at the project level |
| **Credential management** | Platform team manages service accounts and API keys used by connections | Teams manage credentials for their own connections |
| **Access distribution** | Connections shared across multiple projects as needed | Each team maintains their own set of connections |
### Secret Creation and Ownership
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **Shared secrets** | Platform team provisions infrastructure secrets (databases, APIs) | Teams request or create their own |
| **Application secrets** | Teams manage within their designated paths | Teams have full ownership |
| **Secret standards** | Naming conventions and metadata requirements enforced | Guidelines provided, team-enforced |
### Secret Rotation
[Secret rotation](/documentation/platform/secret-rotation/overview) automates credential lifecycle management, reducing the risk of long-lived secrets.
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **Rotation policies** | Defined and managed by platform team | Teams configure for their services |
| **Rotation schedules** | Standardized intervals based on secret classification | Teams determine appropriate intervals |
### Dynamic Secrets
[Dynamic secrets](/documentation/platform/dynamic-secrets/overview) generate short-lived credentials on demand, eliminating standing access to sensitive systems.
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **Configuration** | Platform team sets up dynamic secret sources | Teams configure for their databases and services |
| **Lease duration** | Standardized TTLs based on use case | Teams determine appropriate durations |
| **Access control** | Restricted to specific roles | Available to authorized team members |
### Secret Referencing Within Projects
[Secret referencing](/documentation/platform/secret-references) and [imports](/documentation/platform/secret-references) allow secrets to be shared across environments and folders within the same project. This helps reduce duplication when the same secret is needed in multiple environments.
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **Reference patterns** | Standardized import structures across projects | Teams define their own reference hierarchies |
| **Base environment** | Platform team designates source environments for imports | Teams choose which environments to reference from |
<Note>
Projects in Infisical are isolated from one another. Secret referencing and imports work within a single project—you cannot reference secrets across different projects.
</Note>
## Integrations and Secret Delivery
Infisical offers multiple methods for delivering secrets to applications and infrastructure.
### Secret Syncs
[Secret Syncs](/integrations/secret-syncs/overview) push secrets to third-party platforms like AWS Secrets Manager, Azure Key Vault, GCP Secret Manager, and others. Syncs keep external secret stores updated when values change in Infisical.
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **Sync setup** | Platform team configures syncs to approved destinations | Teams configure syncs for their projects |
| **Target platforms** | Limited to approved platforms | Teams choose appropriate destinations |
| **Sync scope** | Standardized patterns (e.g., sync prod to AWS SM only) | Teams determine what to sync and where |
### Kubernetes Integration
For Kubernetes environments, two primary integration patterns are available:
- [Infisical Kubernetes Operator](/integrations/platforms/kubernetes/infisical-operator) — Syncs secrets to Kubernetes Secrets resources
- [Infisical Secrets Injector](/integrations/platforms/kubernetes-injector) — Injects secrets directly into pods at runtime
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **Operator deployment** | Single cluster-wide instance managed by platform team | Teams may deploy namespace-scoped instances |
| **Secret sync patterns** | Standardized CRD configurations | Teams define their own InfisicalSecret resources |
### Agent and CLI
The [Infisical Agent](/infisical-agent/overview) and [CLI](/cli/overview) provide flexible secret consumption patterns:
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **Agent deployment** | Managed by platform team as infrastructure | Teams deploy and configure their own agents |
| **CLI usage** | Standardized configurations provided | Teams use CLI as needed in their workflows |
### Gateways
[Gateways](/documentation/platform/gateways/overview) enable Infisical to securely access private resources—such as databases in isolated VPCs—without exposing them to the public internet. Gateways are lightweight components deployed within your private network that establish secure, outbound-only connections to Infisical.
Gateways are essential for features that require direct access to private infrastructure:
- [Dynamic secrets](/documentation/platform/dynamic-secrets/overview) for databases in private networks
- [Secret rotation](/documentation/platform/secret-rotation/overview) for credentials stored in isolated systems
- [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) token review for private clusters
#### Gateway Architecture
Gateways operate at two levels within Infisical:
1. **Organization-level registration**: Gateways are registered and visible in Organization Settings → Access Control → Gateways. This provides central visibility into all gateway infrastructure.
2. **Project-level linking**: When configuring features like dynamic secrets, teams select from available gateways to route requests through private networks.
This architecture naturally supports a hybrid governance model where infrastructure teams manage gateway deployment while application teams consume them.
#### Governance Considerations
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **Gateway deployment** | Platform/infrastructure team deploys gateways in shared network zones | Teams deploy gateways in their own VPCs or network segments |
| **Machine identity management** | Platform team creates and manages identities used by gateways | Teams create identities for gateways they deploy |
| **Network configuration** | Central team manages firewall rules and network connectivity | Teams responsible for their own network access |
| **Gateway selection** | Platform team links gateways to projects | Teams select from available gateways when configuring features |
<Info>
Each gateway requires a [machine identity](/documentation/platform/identities/machine-identities) for authentication. Your gateway governance model should align with your broader machine identity strategy.
</Info>
#### Common Patterns
**Shared Gateway Model** (Centralized)
A platform team deploys gateways in shared network zones that can reach common infrastructure (e.g., a central database cluster). Multiple projects link to these shared gateways, reducing deployment overhead and centralizing network management.
This pattern works well when:
- Multiple applications share common database infrastructure
- Network access is controlled by a central team
- You want to minimize the number of gateway deployments to manage
**Team-Owned Gateway Model** (Self-Service)
Each team deploys gateways within their own network boundaries (e.g., per-team VPCs or Kubernetes namespaces). Teams manage the full lifecycle of their gateways, including the machine identities that authenticate them.
This pattern works well when:
- Teams have isolated network environments
- Teams have infrastructure expertise to deploy and maintain gateways
- Strict network segmentation requires dedicated gateways per team
**Hybrid Model**
Platform team deploys and registers gateways, but application teams independently select which gateway to use when configuring dynamic secrets or rotation. This provides central oversight of infrastructure while giving teams flexibility in how they use it.
For Kubernetes environments, gateways can also serve as token reviewers for [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth), eliminating the need for long-lived service account tokens. In this scenario, the gateway deployment often aligns with whoever manages the Kubernetes cluster.
## Audit and Compliance
Visibility into secrets access and changes is critical for security and compliance. Infisical provides audit capabilities at both organization and project levels.
### Audit Logs
[Audit logs](/documentation/platform/audit-logs) capture all platform activity including secret access, modifications, and administrative actions.
| Level | Scope | Typical Access |
|-------|-------|----------------|
| **Organization** | All activity across all projects | Security team, compliance officers |
| **Project** | Activity within a specific project | Project admins, team leads |
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **Log access** | Security team has organization-wide visibility | Teams access only their project logs |
| **Log retention** | Centrally managed retention policies | Platform-defined retention |
| **Compliance reporting** | Platform team generates reports | Teams may generate their own project reports |
### Audit Log Streaming
[Audit log streaming](/documentation/platform/audit-log-streams/audit-log-streams) exports logs to external systems for long-term retention and analysis.
Supported destinations include:
- SIEM platforms (Splunk, Datadog, Elastic)
- Cloud storage (AWS S3, Azure Blob Storage)
- Log aggregators (Better Stack, generic HTTP endpoints)
| Approach | Centralized | Self-Service |
|----------|-------------|--------------|
| **Stream configuration** | Platform team manages all log streams | N/A (organization-level feature) |
| **SIEM integration** | Centralized security monitoring | Teams may not have direct SIEM access |
## Security Controls
Beyond access control, Infisical offers additional security settings.
### Security Policies
Organization-level [security policies](/documentation/platform/organization) allow you to enforce:
- MFA requirements for all users
- Session duration limits
- Login restrictions
### IP Access Controls
Restrict API and dashboard access to specific IP ranges, useful for:
- Limiting production access to corporate networks or VPNs
- Restricting machine identity authentication to known infrastructure IPs
### Encryption and Key Management
| Feature | Description | Governance Consideration |
|---------|-------------|-------------------------|
| **External KMS** | Integrate with AWS KMS, GCP KMS, or Azure Key Vault | Centralized key management |
| **BYOK** | Bring your own encryption keys | Enterprise key management policies |
| **KMIP** | Connect to KMIP-compatible HSMs | Hardware-backed security requirements |
These features are typically managed centrally regardless of overall governance model, as encryption infrastructure requires specialized expertise.
## Choosing Your Model
A few factors tend to push organizations toward one end of the spectrum or the other:
### Factors Favoring Centralized Control
- **Regulatory requirements**: SOC 2, HIPAA, PCI-DSS, and similar frameworks often require demonstrated control over secrets management
- **Limited security expertise**: When application teams lack security experience, central management reduces risk
- **Consistency requirements**: Large organizations benefit from standardized patterns across teams
- **High-risk environments**: Financial services, healthcare, and government contexts often require strict oversight
### Factors Favoring Self-Service
- **Mature DevOps culture**: Teams with strong security awareness can manage their own secrets responsibly
- **Speed of delivery**: Self-service reduces bottlenecks and accelerates development cycles
- **Diverse technology stacks**: Teams using different platforms benefit from flexibility in integration choices
- **Distributed organizations**: Global teams may need autonomy to operate across time zones
### The Hybrid Approach
Most organizations benefit from a hybrid model that combines central guardrails with team autonomy:
**Platform team responsibilities:**
- SSO and SCIM configuration
- Project template creation and maintenance
- Organization-wide security policies
- Audit log streaming and compliance reporting
- Approval workflow policies for production environments
- Shared infrastructure secrets (databases, external APIs)
**Application team responsibilities:**
- Project creation (from approved templates)
- Application-specific secret management
- Integration configuration within their projects
- Team-level access control within policy bounds
- Secret rotation for team-owned credentials
This balances compliance requirements with team velocity—central teams handle the infrastructure and guardrails, while application teams own their day-to-day secrets operations.
## Implementation Considerations
### Starting Centralized, Moving to Self-Service
Organizations often begin with centralized control and gradually extend autonomy as teams demonstrate security maturity:
1. **Phase 1**: Platform team manages all aspects; teams consume secrets via provided integrations
2. **Phase 2**: Teams gain ability to manage secrets within their projects; platform team controls project creation and policies
3. **Phase 3**: Teams can create projects from templates and configure integrations; platform team focuses on guardrails and compliance
### Starting Self-Service, Adding Controls
Organizations scaling from startup to enterprise may need to add centralization:
1. **Phase 1**: Establish SSO and basic security policies
2. **Phase 2**: Introduce project templates and approval workflows for production
3. **Phase 3**: Implement SCIM provisioning and comprehensive audit streaming
### Documentation and Training
Regardless of model, invest in:
- Clear documentation of secrets management standards and processes
- Training for teams on Infisical features and security best practices
- Runbooks for common operations (secret rotation, access requests, incident response)
## Summary
Here's a quick reference for how key Infisical features map to each governance model:
| Feature | Centralized Use | Self-Service Use |
|---------|-----------------|------------------|
| [Project Templates](/documentation/platform/project-templates) | Enforce standards | Provide starting points |
| [SCIM](/documentation/platform/scim/overview) | Automate user lifecycle | Supplement direct invitations |
| [Groups](/documentation/platform/groups) | IdP-synced membership | Local team management |
| [Custom Roles](/documentation/platform/access-controls/role-based-access-controls) | Define organization-wide | Create project-specific |
| [Approval Workflows](/documentation/platform/pr-workflows) | Require for all changes | Apply selectively |
| [App Connections](/integrations/app-connections/overview) | Org-level connections distributed to projects | Teams create project-level connections |
| [Secret Syncs](/integrations/secret-syncs/overview) | Platform-managed syncs to approved destinations | Teams configure their own syncs |
| [Gateways](/documentation/platform/gateways/overview) | Shared infrastructure for private access | Team-deployed per network zone |
| [Audit Logs](/documentation/platform/audit-logs) | Centralized monitoring | Project-level visibility |
Most organizations land somewhere in between—central control over identity, policies, and infrastructure with team ownership of secrets and integrations. You can start at either end of the spectrum and adjust as your needs change.

View File

@@ -1,29 +1,29 @@
---
title: "User Groups"
description: "Manage user groups in Infisical."
title: "Groups"
description: "Manage groups containing users and machine identities in Infisical."
---
<Info>
User Groups is a paid feature.
If you're using Infisical Cloud, then it is available under the **Enterprise Tier**. If you're self-hosting Infisical,
then you should contact team@infisical.com to purchase an enterprise license to use it.
Groups is a paid feature. If you're using Infisical Cloud, then it is
available under the **Enterprise Tier**. If you're self-hosting Infisical,
then you should contact team@infisical.com to purchase an enterprise license
to use it.
</Info>
## Concept
A (user) group is a collection of users that you can create in an Infisical organization to more efficiently manage permissions and access control for multiple users together. For example, you can have a group called `Developers` with the `Developer` role containing all the developers in your organization.
A group is a collection of identities (users and/or machine identities) that you can create in an Infisical organization to more efficiently manage permissions and access control for multiple identities together. For example, you can have a group called `Developers` with the `Developer` role containing all the developers in your organization, or a group called `CI/CD Identities` containing all the machine identities used in your CI/CD pipelines.
User groups have the following properties:
Groups have the following properties:
- If a group is added to a project under specific role(s), all users in the group will be provisioned access to the project with the role(s). Conversely, if a group is removed from a project, all users in the group will lose access to the project.
- If a user is added to a group, they will inherit the access control properties of the group including access to project(s) under the role(s) assigned to the group. Conversely, if a user is removed from a group, they will lose access to project(s) that the group has access to.
- If a user was previously added to a project under a role and is later added to a group that has access to the same project under a different role, then the user will now have access to the project under the composite permissions of the two roles. If the group is subsequently removed from the project, the user will not lose access to the project as they were previously added to the project separately.
- A user can be part of multiple groups. If a user is part of multiple groups, they will inherit the composite permissions of all the groups that they are part of.
- If a group is added to a project under specific role(s), all identities in the group will be provisioned access to the project with the role(s). Conversely, if a group is removed from a project, all identities in the group will lose access to the project.
- If an identity is added to a group, they will inherit the access control properties of the group including access to project(s) under the role(s) assigned to the group. Conversely, if an identity is removed from a group, they will lose access to project(s) that the group has access to.
- If an identity was previously added to a project under a role and is later added to a group that has access to the same project under a different role, then the identity will now have access to the project under the composite permissions of the two roles. If the group is subsequently removed from the project, the identity will not lose access to the project as they were previously added to the project separately.
- An identity can be part of multiple groups. If an identity is part of multiple groups, they will inherit the composite permissions of all the groups that they are part of.
## Workflow
In the following steps, we explore how to create and use user groups to provision user access to projects in Infisical.
In the following steps, we explore how to create and use groups to provision access to projects in Infisical. Groups can contain both users and machine identities, and the workflow is the same for both types of identities.
<Steps>
<Step title="Creating a group">
@@ -32,36 +32,38 @@ In the following steps, we explore how to create and use user groups to provisio
![groups org](/images/platform/groups/groups-org.png)
When creating a group, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles.
![groups org create](/images/platform/groups/groups-org-create.png)
Now input a few details for your new group. Heres some guidance for each field:
- Name (required): A friendly name for the group like `Engineering`.
- Slug (required): A unique identifier for the group like `engineering`.
- Role (required): A role from the Organization Roles tab for the group to assume. The organization role assigned will determine what organization level resources this group can have access to.
</Step>
<Step title="Adding users to the group">
Next, you'll want to assign users to the group. To do this, press on the users icon on the group and start assigning users to the group.
<Step title="Adding identities to the group">
Next, you'll want to assign identities (users and/or machine identities) to the group. To do this, click on the group row to open the group details page and click on the **+** button.
![groups org users](/images/platform/groups/groups-org-users.png)
![groups org users details](/images/platform/groups/group-details.png)
In this example, we're assigning **Alan Turing** and **Ada Lovelace** to the group **Engineering**.
In this example, we're assigning **Alan Turing** and **Ada Lovelace** (users) to the group **Engineering**. You can similarly add machine identities to the group by selecting them from the **Machine Identities** tab in the modal.
![groups org assign users](/images/platform/groups/groups-org-users-assign.png)
</Step>
<Step title="Adding the group to a project">
To enable the group to access project-level resources such as secrets within a specific project, you should add it to that project.
To do this, head over to the project you want to add the group to and go to Project Settings > Access Control > Groups and press **Add group**.
To do this, head over to the project you want to add the group to and go to Project Settings > Access Control > Groups and press **Add Group to Project**.
![groups project](/images/platform/groups/groups-project.png)
Next, select the group you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this group can have access to.
![groups project add](/images/platform/groups/groups-project-create.png)
That's it!
The users of the group now have access to the project under the role you assigned to the group.
All identities of the group now have access to the project under the role you assigned to the group.
</Step>
</Steps>
</Steps>

View File

@@ -1,401 +0,0 @@
---
title: "Certificates"
sidebarTitle: "Certificates"
description: "Learn how to issue X.509 certificates with Infisical."
---
## Concept
Assuming that you've created a Private CA hierarchy with a root CA and an intermediate CA, you can now issue/revoke X.509 certificates using the intermediate CA.
<div align="center">
```mermaid
graph TD
A[Root CA]
A --> B[Intermediate CA]
A --> C[Intermediate CA]
B --> D[Leaf Certificate]
C --> E[Leaf Certificate]
```
</div>
## Workflow
The typical workflow for managing certificates consists of the following steps:
1. Issuing a certificate under an intermediate CA with details like name and validity period. As part of certificate issuance, you can either issue a certificate directly from a CA or do it via a certificate template.
2. Managing certificate lifecycle events such as certificate renewal and revocation. As part of the certificate revocation flow,
you can also query for a Certificate Revocation List [CRL](https://en.wikipedia.org/wiki/Certificate_revocation_list), a time-stamped, signed
data structure issued by a CA containing a list of revoked certificates to check if a certificate has been revoked.
<Note>
Note that this workflow can be executed via the Infisical UI or manually such
as via API.
</Note>
## Guide to Issuing Certificates
In the following steps, we explore how to issue a X.509 certificate under a CA.
<Tabs>
<Tab title="Infisical UI">
<Steps>
<Step title="Creating a certificate template">
A certificate template is a set of policies for certificates issued under that template; each template is bound to a specific CA and can also be bound to a certificate collection for alerting such that any certificate issued under the template is automatically added to the collection.
With certificate templates, you can specify, for example, that issued certificates must have a common name (CN) adhering to a specific format like `.*.acme.com` or perhaps that the max TTL cannot be more than 1 year.
Head to your Project > Certificate Authorities > Your Issuing CA and create a certificate template.
![pki certificate template modal](/images/platform/pki/certificate/cert-template-modal.png)
Here's some guidance on each field:
- Template Name: A name for the certificate template.
- Issuing CA: The Certificate Authority (CA) that will issue certificates based on this template.
- Certificate Collection (Optional): The certificate collection that certificates should be added to when issued under the template.
- Common Name (CN): A regular expression used to validate the common name in certificate requests.
- Alternative Names (SANs): A regular expression used to validate subject alternative names in certificate requests.
- TTL: The maximum Time-to-Live (TTL) for certificates issued using this template.
- Key Usage: The key usage constraint or default value for certificates issued using this template.
- Extended Key Usage: The extended key usage constraint or default value for certificates issued using this template.
</Step>
<Step title="Creating a certificate">
To create a certificate, head to your Project > Internal PKI > Certificates and press **Issue** under the Certificates section.
![pki issue certificate](/images/platform/pki/certificate/cert-issue.png)
Here, set the **Certificate Template** to the template from step 1 and fill out the rest of the details for the certificate to be issued.
![pki issue certificate modal](/images/platform/pki/certificate/cert-issue-modal.png)
Here's some guidance on each field:
- Friendly Name: A friendly name for the certificate; this is only for display and defaults to the common name of the certificate if left empty.
- Common Name (CN): The common name for the certificate like `service.acme.com`.
- Alternative Names (SANs): A comma-delimited list of Subject Alternative Names (SANs) for the certificate; these can be hostnames or email addresses like `app1.acme.com, app2.acme.com`.
- TTL: The lifetime of the certificate in seconds.
- Key Usage: The key usage extension of the certificate.
- Extended Key Usage: The extended key usage extension of the certificate.
<Note>
Note that Infisical PKI supports issuing certificates without certificate templates as well. If this is desired, then you can set the **Certificate Template** field to **None**
and specify the **Issuing CA** and optional **Certificate Collection** fields; the rest of the fields for the issued certificate remain the same.
That said, we recommend using certificate templates to enforce policies and attach expiration monitoring on issued certificates.
</Note>
</Step>
<Step title="Copying the certificate details">
Once you have created the certificate from step 1, you'll be presented with the certificate details including the **Certificate Body**, **Certificate Chain**, and **Private Key**.
![pki certificate body](/images/platform/pki/certificate/cert-body.png)
<Note>
Make sure to download and store the **Private Key** in a secure location as it will only be displayed once at the time of certificate issuance.
The **Certificate Body** and **Certificate Chain** will remain accessible and can be copied at any time.
</Note>
</Step>
</Steps>
</Tab>
<Tab title="API">
<Steps>
<Step title="Creating a certificate template">
A certificate template is a set of policies for certificates issued under that template; each template is bound to a specific CA and can also be bound to a certificate collection for alerting such that any certificate issued under the template is automatically added to the collection.
With certificate templates, you can specify, for example, that issued certificates must have a common name (CN) adhering to a specific format like .*.acme.com or perhaps that the max TTL cannot be more than 1 year.
To create a certificate template, make an API request to the [Create Certificate Template](/api-reference/endpoints/certificate-templates-v2/create) API endpoint, specifying the issuing CA.
### Sample request
```bash Request
curl --request POST \
--url https://us.infisical.com/api/v2/certificate-templates \
--header 'Content-Type: application/json' \
--data '{
"projectId": "<string>",
"name": "<string>",
"description": "<string>",
"subject": [
{
"type": "common_name",
"allowed": [
"*.infisical.com"
]
}
],
"sans": [
{
"type": "dns_name",
"allowed": [
"*.sample.com"
]
}
],
"keyUsages": {
"allowed": [
"digital_signature"
]
},
"extendedKeyUsages": {
"allowed": [
"client_auth"
]
},
"algorithms": {
"signature": [
"SHA256-RSA"
],
"keyAlgorithm": [
"RSA-2048"
]
},
"validity": {
"max": "365d"
}
}'
```
### Sample response
```bash Response
{
"certificateTemplate": {
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"name": "<string>",
"description": "<string>",
"subject": [
{
"type": "common_name",
"allowed": [
"*.infisical.com"
]
}
],
"sans": [
{
"type": "dns_name",
"allowed": [
"*.sample.com"
]
}
],
"keyUsages": {
"allowed": [
"digital_signature"
]
},
"extendedKeyUsages": {
"allowed": [
"client_auth"
]
},
"algorithms": {
"signature": [
"SHA256-RSA"
],
"keyAlgorithm": [
"RSA-2048"
]
},
"validity": {
"max": "365d"
},
"createdAt": "2023-11-07T05:31:56Z",
"updatedAt": "2023-11-07T05:31:56Z"
}
}
```
</Step>
<Step title="Creating a certificate">
To create a certificate under the certificate template, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/issue-certificate) API endpoint,
specifying the issuing CA.
### Sample request
```bash Request
curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates/issue-certificate' \
--header 'Content-Type: application/json' \
--data-raw '{
"profileId": "<profile-id>",
"commonName": "service.acme.com",
"ttl": "1y",
"signatureAlgorithm": "RSA-SHA256",
"keyAlgorithm": "RSA_2048"
}'
```
### Sample response
```bash Response
{
certificate: "...",
certificateChain: "...",
issuingCaCertificate: "...",
privateKey: "...",
serialNumber: "..."
}
```
<Note>
Note that Infisical PKI supports issuing certificates without certificate templates as well. If this is desired, then you can set the **Certificate Template** field to **None**
and specify the **Issuing CA** and optional **Certificate Collection** fields; the rest of the fields for the issued certificate remain the same.
That said, we recommend using certificate templates to enforce policies and attach expiration monitoring on issued certificates.
</Note>
<Note>
Make sure to store the `privateKey` as it is only returned once here at the time of certificate issuance. The `certificate` and `certificateChain` will remain accessible and can be retrieved at any time.
</Note>
If you have an external private key, you can also create a certificate by making an API request containing a pem-encoded CSR (Certificate Signing Request) to the [Sign Certificate](/api-reference/endpoints/certificates/sign-certificate) API endpoint, specifying the issuing CA.
### Sample request
```bash Request
curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates/sign-certificate' \
--header 'Content-Type: application/json' \
--data-raw '{
"certificateTemplateId": "<certificate-template-id>",
"csr": "...",
"ttl": "1y",
}'
```
### Sample response
```bash Response
{
certificate: "...",
certificateChain: "...",
issuingCaCertificate: "...",
privateKey: "...",
serialNumber: "..."
}
```
</Step>
</Steps>
</Tab>
</Tabs>
## Guide to Revoking Certificates
In the following steps, we explore how to revoke a X.509 certificate under a CA and obtain a Certificate Revocation List (CRL) for a CA.
<Tabs>
<Tab title="Infisical UI">
<Steps>
<Step title="Revoking a Certificate">
Assuming that you've issued a certificate under a CA, you can revoke it by
selecting the **Revoke Certificate** option for it and specifying the reason
for revocation.
![pki revoke certificate](/images/platform/pki/certificate/cert-revoke.png)
![pki revoke certificate modal](/images/platform/pki/certificate/cert-revoke-modal.png)
</Step>
<Step title="Obtaining a CRL">
In order to check the revocation status of a certificate, you can check it
against the CRL of a CA by heading to its Issuing CA and downloading the CRL.
![pki view crl](/images/platform/pki/ca/ca-crl.png)
To verify a certificate against the
downloaded CRL with OpenSSL, you can use the following command:
```bash
openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem
```
Note that you can also obtain the CRL from the certificate itself by
referencing the CRL distribution point extension on the certificate.
To check a certificate against the CRL distribution point specified within it with OpenSSL, you can use the following command:
```bash
openssl verify -verbose -crl_check -crl_download -CAfile chain.pem cert.pem
```
</Step>
</Steps>
</Tab>
<Tab title="API">
<Steps>
<Step title="Revoking a certificate">
Assuming that you've issued a certificate under a CA, you can revoke it by making an API request to the [Revoke Certificate](/api-reference/endpoints/certificates/revoke) API endpoint,
specifying the serial number of the certificate and the reason for revocation.
### Sample request
```bash Request
curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates/<cert-id>/revoke' \
--header 'Authorization: Bearer <access-token>' \
--header 'Content-Type: application/json' \
--data-raw '{
"revocationReason": "UNSPECIFIED"
}'
```
### Sample response
```bash Response
{
message: "Successfully revoked certificate",
serialNumber: "...",
revokedAt: "..."
}
```
</Step>
<Step title="Obtaining a CRL">
In order to check the revocation status of a certificate, you can check it against the CRL of the issuing CA.
To obtain the CRLs of the CA, make an API request to the [List CRLs](/api-reference/endpoints/certificate-authorities/crl) API endpoint.
### Sample request
```bash Request
curl --location --request GET 'https://app.infisical.com/api/v1/cert-manager/ca/internal/<ca-id>/crls' \
--header 'Authorization: Bearer <access-token>'
```
### Sample response
```bash Response
[
{
id: "...",
crl: "..."
},
...
]
```
To verify a certificate against the CRL with OpenSSL, you can use the following command:
```bash
openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem
```
</Step>
</Steps>
</Tab>
</Tabs>
## FAQ
<AccordionGroup>
<Accordion title="What is the workflow for renewing a certificate?">
To renew a certificate, you have to issue a new certificate from the same CA
with the same common name as the old certificate. The original certificate
will continue to be valid through its original TTL unless explicitly
revoked.
</Accordion>
</AccordionGroup>

View File

@@ -29,13 +29,13 @@ Refer to the documentation for each [enrollment method](/documentation/platform/
## Guide to Renewing Certificates
To [renew a certificate](/documentation/platform/pki/concepts/certificate-lifecycle#renewal), you can either request a new certificate from a certificate profile or have the platform
automatically request a new one for you. Whether you pursue a client-driven or server-driven approach is totally dependent on the enrollment method configured on your certificate
automatically request a new one for you to be delivered downstream to a target destination. Whether you pursue a client-driven or server-driven approach is totally dependent on the enrollment method configured on your certificate
profile as well as your infrastructure use-case.
### Client-Driven Certificate Renewal
Client-driven certificate renewal is when renewal is initiated client-side by the end-entity consuming the certificate.
This is the most common approach to certificate renewal and is suitable for most use-cases.
More specifically, the client (e.g. [Infisical Agent](/integrations/platforms/certificate-agent), [ACME client](https://letsencrypt.org/docs/client-options/), etc.) monitors the certificate and makes a request for Infisical to issue a new certificate back to it when the existing certificate is nearing expiration. This is the most common approach to certificate renewal and is suitable for most use-cases.
### Server-Driven Certificate Renewal

View File

@@ -0,0 +1,30 @@
---
title: "Certificate Components"
description: "Learn the main components for managing certificates with Infisical."
---
## Core Components
The following resources define how certificates are issued, shaped, and governed in Infisical:
- [Certificate Authority (CA)](/documentation/platform/pki/ca/overview): The trusted entity that issues X.509 certificates. This can be an [Internal CA](/documentation/platform/pki/ca/private-ca) or an [External CA](/documentation/platform/pki/ca/external-ca) in Infisical.
The former represents a fully managed CA hierarchy within Infisical, while the latter represents an external CA (e.g. [DigiCert](/documentation/platform/pki/ca/digicert), [Let's Encrypt](/documentation/platform/pki/ca/lets-encrypt), [Microsoft AD CS](/documentation/platform/pki/ca/azure-adcs), etc.) that can be integrated with Infisical.
- [Certificate Template](/documentation/platform/pki/certificates/templates): A policy structure specifying permitted attributes for requested certificates. This includes constraints around subject naming conventions, SAN fields, key usages, and extended key usages.
- [Certificate Profile](/documentation/platform/pki/certificates/profiles): A configuration set specifying how leaf certificates should be issued for a group of end-entities including the issuing CA, a certificate template, and the enrollment method (e.g. [ACME](/documentation/platform/pki/enrollment-methods/acme), [EST](/documentation/platform/pki/enrollment-methods/est), [API](/documentation/platform/pki/enrollment-methods/api), etc.) used to enroll certificates.
- [Certificate](/documentation/platform/pki/certificates/certificates): The actual X.509 certificate issued for a profile. Once created, it is tracked in Infisicals certificate inventory for management, renewal, and lifecycle operations.
## Access Control
Access control defines who (or what) can manage certificate resources and who can issue certificates within a project. Without clear boundaries, [certificate authorities](/documentation/platform/pki/ca/overview) and issuance workflows can be misconfigured or misused.
To manage access to certificates, you assign role-based permissions at the project level. These permissions determine which certificate authorities, certificate templates, certificate profiles, and other related resources a user or machine identity can act on. For example,
you may want to:
- Have specific teams(s) manage your internal CA hierarchy or external CA integration configuration and have separate team(s) configure certificate profiles for requested certificates.
- Limit which teams can manage policies defined on certificate templates.
- Have specific end-entities (e.g. servers, devices, users) request certificates from specific certificate profiles.
This model follows the [principle of least privilege](https://en.wikipedia.org/wiki/Principle_of_least_privilege) so that each user or machine identity can manage or issue only the certificate resources it is responsible for and nothing more.

View File

@@ -9,7 +9,7 @@ A (digital) _certificate_ is a file that is tied to a cryptographic key pair and
For example, when you visit a website over HTTPS, your browser checks the TLS certificate deployed on the web server or load balancer to make sure its really the site it claims to be. If the certificate is valid, your browser establishes an encrypted connection with the server.
Certificates contain information about the subject (who it identifies), the public key, and a digital signature from the CA that issued the certificate. They also include additional fields such as key usages, validity periods, and extensions that define how and where the certificate can be used. When a certificate expires, the service presenting it is no longer trusted, and clients won't be able to establish a secure connection to the service.
Certificates contain information about the subject (who it identifies), the public key, and a digital signature from the Certificate Authority (CA) that issued the certificate. They also include additional fields such as key usages, validity periods, and extensions that define how and where the certificate can be used. When a certificate expires, the service presenting it is no longer trusted, and clients won't be able to establish a secure connection to the service.
## What is Certificate Management?

View File

@@ -6,7 +6,9 @@ sidebarTitle: "ACME"
## Concept
The ACME enrollment method allows Infisical to act as an ACME server. It lets you request and manage certificates against a specific [certificate profile](/documentation/platform/pki/certificates/profiles) using the [ACME protocol](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment).
This method is suitable for web servers, load balancers, and other general-purpose servers that can run an [ACME client](https://letsencrypt.org/docs/client-options/) for automated certificate management.
This method is suitable for web servers, load balancers, and other general-purpose servers that can run an [ACME client](https://letsencrypt.org/docs/client-options/) for automated certificate management;
it can also be used with [cert-manager](https://cert-manager.io/) to issue and renew certificates for Kubernetes workloads through the [ACME issuer type](https://cert-manager.io/docs/configuration/acme/).
Infisical's ACME enrollment method is based on [RFC 8555](https://datatracker.ietf.org/doc/html/rfc8555/).

View File

@@ -100,32 +100,34 @@ Here, select the certificate profile from step 1 that will be used to issue the
</Step>
<Step title="Issue a certificate">
To issue a certificate against the certificate profile, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/issue-certificate) API endpoint.
To issue a certificate against the certificate profile, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/create-certificate) API endpoint.
### Sample request
```bash Request
curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates/issue-certificate' \
curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates' \
--header 'Authorization: Bearer <access-token>' \
--header 'Content-Type: application/json' \
--data-raw '{
"profileId": "<certificate-profile-id>",
"commonName": "service.acme.com",
"ttl": "1y",
"signatureAlgorithm": "RSA-SHA256",
"keyAlgorithm": "RSA_2048",
"keyUsages": ["digital_signature", "key_encipherment"],
"extendedKeyUsages": ["server_auth"],
"altNames": [
{
"type": "DNS",
"value": "service.acme.com"
},
{
"type": "DNS",
"value": "www.service.acme.com"
}
]
"attributes": {
"commonName": "service.acme.com",
"ttl": "1y",
"signatureAlgorithm": "RSA-SHA256",
"keyAlgorithm": "RSA_2048",
"keyUsages": ["digital_signature", "key_encipherment"],
"extendedKeyUsages": ["server_auth"],
"altNames": [
{
"type": "DNS",
"value": "service.acme.com"
},
{
"type": "DNS",
"value": "www.service.acme.com"
}
]
}
}'
```
@@ -133,31 +135,36 @@ Here, select the certificate profile from step 1 that will be used to issue the
```bash Response
{
"certificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----",
"certificateChain": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----",
"issuingCaCertificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----",
"privateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n-----END PRIVATE KEY-----",
"serialNumber": "123456789012345678",
"certificateId": "880h3456-e29b-41d4-a716-446655440003"
"certificate": {
"certificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----",
"certificateChain": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----",
"issuingCaCertificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----",
"privateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n-----END PRIVATE KEY-----",
"serialNumber": "123456789012345678",
"certificateId": "880h3456-e29b-41d4-a716-446655440003"
},
"certificateRequestId": "..."
}
```
<Note>
Make sure to store the `privateKey` as it is only returned once here at the time of certificate issuance. The `certificate` and `certificateChain` will remain accessible and can be retrieved at any time.
Note: If the certificate is available to be issued immediately, the `certificate` field in the response will contain the certificate data. If issuance is delayed (for example, due to pending approval or additional processing), the `certificate` field will be `null` and you can use the `certificateRequestId` to poll for status or retrieve the certificate when it is ready using the [Get Certificate Request](/api-reference/endpoints/certificates/certificate-request) API endpoint.
</Note>
If you have an external private key, you can also issue a certificate by making an API request containing a pem-encoded CSR (Certificate Signing Request) to the [Sign Certificate](/api-reference/endpoints/certificates/sign-certificate) API endpoint.
If you have an external private key, you can also issue a certificate by making an API request containing a pem-encoded CSR (Certificate Signing Request) to the same [Issue Certificate](/api-reference/endpoints/certificates/create-certificate) API endpoint.
### Sample request
```bash Request
curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates/sign-certificate' \
curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates' \
--header 'Authorization: Bearer <access-token>' \
--header 'Content-Type: application/json' \
--data-raw '{
"profileId": "<certificate-profile-id>",
"csr": "-----BEGIN CERTIFICATE REQUEST-----\nMIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBE9oaW8...\n-----END CERTIFICATE REQUEST-----",
"ttl": "1y"
"attributes": {
"ttl": "1y"
}
}'
```
@@ -165,11 +172,14 @@ Here, select the certificate profile from step 1 that will be used to issue the
```bash Response
{
"certificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----",
"certificateChain": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----",
"issuingCaCertificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----",
"serialNumber": "123456789012345679",
"certificateId": "990i4567-e29b-41d4-a716-446655440004"
"certificate": {
"certificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----",
"certificateChain": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----",
"issuingCaCertificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----",
"serialNumber": "123456789012345679",
"certificateId": "990i4567-e29b-41d4-a716-446655440004"
},
"certificateRequestId": "..."
}
```

View File

@@ -0,0 +1,108 @@
---
title: "Obtain a Certificate via ACME"
---
import RequestCertSetup from "/snippets/documentation/platform/pki/guides/request-cert-setup.mdx";
The [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) lets any [ACME client](https://letsencrypt.org/docs/client-options/) obtain TLS certificates from Infisical using the [ACME protocol](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment).
This includes ACME clients like [Certbot](https://certbot.eff.org/), [cert-manager](https://cert-manager.io/) in Kubernetes using the [ACME issuer type](https://cert-manager.io/docs/configuration/acme/), and more.
Infisical currently supports the [HTTP-01 challenge type](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) for domain validation as part of the ACME enrollment method.
## Diagram
The following sequence diagram illustrates the certificate enrollment workflow for requesting a certificate via ACME from Infisical.
```mermaid
sequenceDiagram
autonumber
participant ACME as ACME Client
participant Infis as Infisical ACME Server
participant Authz as HTTP-01 Challenge<br/>Validation Endpoint
participant CA as CA<br/>(Internal or External)
Note over ACME: ACME Client discovers<br/>Infisical ACME Directory URL
ACME->>Infis: GET /directory
Infis-->>ACME: Directory + nonce + endpoints
ACME->>Infis: HEAD /new-nonce
Infis-->>ACME: Return nonce in Replay-Nonce header
ACME->>Infis: POST /new-account<br/>(contact, ToS agreed)
Infis-->>ACME: Return account object
Note over ACME,Infis: Requesting a certificate
ACME->>Infis: POST /new-order<br/>(identifiers: DNS names)
Infis-->>ACME: Return order<br/>with authorization URLs
loop For each authorization (one per DNS name)
ACME->>Infis: POST /authorizations/:authzId
Infis-->>ACME: Return HTTP-01 challenge<br/>(URL + token + keyAuth)
Note over ACME: Client must prove control<br/>over the domain via HTTP
ACME->>Authz: Provision challenge response<br/>at<br/>/.well-known/acme-challenge/<token>
ACME->>Infis: POST /authorizations/:authzId/challenges/:challengeId<br/>(trigger validation)
Infis->>Authz: HTTP GET /.well-known/acme-challenge/<token>
Authz-->>Infis: Return keyAuth
Infis-->>ACME: Authorization = valid
end
Note over Infis: All authorizations valid → ready to finalize
ACME->>ACME: Generate keypair locally<br/>and create CSR
ACME->>Infis: POST /orders/:orderId/finalize<br/>(CSR)
Infis->>CA: Request certificate issuance<br/>(CSR)
CA-->>Infis: Signed certificate (+ chain)
Infis-->>ACME: Return order with certificate URL<br/>(status: valid)
ACME->>Infis: POST /orders/:orderId/certificate
Infis-->>ACME: Return certificate<br/>and certificate chain
```
## Guide
In the following steps, we explore an end-to-end workflow for obtaining a certificate via ACME with Infisical.
<Steps>
<RequestCertSetup />
<Step title="Create a certificate profile">
Next, follow the guide [here](/documentation/platform/pki/certificates/profiles#guide-to-creating-a-certificate-profile) to create a [certificate profile](/documentation/platform/pki/certificates/profiles)
that will be referenced when requesting a certificate.
The certificate profile specifies which certificate template and issuing CA should be used to validate an incoming certificate request and issue a certificate;
it also specifies the [enrollment method](/documentation/platform/pki/enrollment-methods/overview) for how certificates can be requested against this profile
to begin with.
You should specify the certificate template from Step 2, the issuing CA from Step 1, and the **ACME** option in the **Enrollment Method** dropdown when creating the certificate profile.
</Step>
<Step title="Request a certificate">
Finally, follow the guide [here](/documentation/platform/pki/enrollment-methods/acme#guide-to-certificate-enrollment-via-acme) to request a certificate against the certificate profile
using an [ACME client](https://letsencrypt.org/docs/client-options/).
The ACME client will connect to Infisical's ACME server at the **ACME Directory URL** and authenticate using the **EAB Key Identifier (KID)** and **EAB Secret** credentials as part of the ACME protocol.
The typical ACME workflow looks likes this:
- The ACME client creates (or reuses) an ACME account with Infisical using EAB credentials.
- The ACME client creates an order for one or more DNS names.
- For each DNS name, the ACME client receives an `HTTP-01` challenge and provisions the corresponding token response at `/.well-known/acme-challenge/&lt;token&gt;`.
- Once all authorizations are valid, the ACME client finalizes the order by sending a CSR to Infisical.
- Infisical issues the certificate from the issuing CA on the certificate profile and returns it (plus the chain) back to the ACME client.
ACME clients typically handle renewal by tracking certificate expiration and completing the lifecycle once again to request a new certificate.
<Note>
We recommend reading more about the ACME protocol [here](https://letsencrypt.org/how-it-works/).
</Note>
</Step>
</Steps>

View File

@@ -0,0 +1,95 @@
---
title: "Request a Certificate via the Infisical Agent"
---
import RequestCertSetup from "/snippets/documentation/platform/pki/guides/request-cert-setup.mdx";
The [Infisical Agent](/integrations/platforms/certificate-agent) is an installable client daemon that can request TLS and other X.509 certificates from Infisical using the [API enrollment method](/documentation/platform/pki/enrollment-methods/api) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles), persist it to a specified path on the filesystem, and automatically monitor and renew it before expiration.
Instead of [manually requesting](/documentation/platform/pki/guides/request-cert-api) and renewing a certificate via the [Issue Certificate](/api-reference/endpoints/certificates/create-certificate) API endpoint, you can install and launch the Infisical Agent to have it perform these steps for you automatically.
## Diagram
The following sequence diagram illustrates the certificate enrollment workflow for requesting a certificate using the Infisical Agent from Infisical.
```mermaid
sequenceDiagram
autonumber
participant Agent as Infisical Agent
participant Infis as Infisical
participant CA as CA<br/>(Internal or External)
Agent->>Infis: Request certificate<br/>(profileId, conditional subject/SANs, ttl,<br/>key usages, conditional CSR, etc.)
Infis->>Infis: Look up certificate profile<br/>(by profileId)
Infis->>Infis: Validate request<br/>against profile constraints<br/>(CN/SAN rules, key usages, max TTL, etc.)
alt Issuer Type = Self-Signed
Infis->>Infis: Generate keypair<br/>and self-sign certificate
else Issuer Type = Internal CA
Infis->>CA: Request certificate issuance
CA-->>Infis: Signed certificate<br/>(+ chain)
end
Infis-->>Agent: Return certificate, certificate chain,<br/>(and private key if server-generated)
Note over Agent: Persist certificate and begin lifecycle monitoring
loop Periodic certificate status check
Agent->>Agent: Check certificate expiration<br/>against renew-before-expiry threshold
alt Renewal not required
Agent-->>Agent: Continue monitoring
else Renewal required
Agent->>Infis: Request new certificate<br/>(same profile and constraints)
Infis->>Infis: Validate renewal request<br/>against profile constraints
alt Issuer Type = Self-Signed
Infis->>Infis: Generate keypair<br/>and self-sign certificate
else Issuer Type = Internal CA
Infis->>CA: Request certificate issuance
CA-->>Infis: Signed certificate<br/>(+ chain)
end
Infis-->>Agent: Return renewed certificate, certificate chain, and private key
end
end
```
## Guide
In the following steps, we explore an end-to-end workflow for requesting and continuously renewing a certificate using the Infisical Agent.
<Steps>
<RequestCertSetup />
<Step title="Create a certificate profile">
Next, follow the guide [here](/documentation/platform/pki/certificates/profiles#guide-to-creating-a-certificate-profile) to create a [certificate profile](/documentation/platform/pki/certificates/profiles)
that will be referenced when requesting a certificate.
The certificate profile specifies which certificate template and issuing CA should be used to validate an incoming certificate request and issue a certificate;
it also specifies the [enrollment method](/documentation/platform/pki/enrollment-methods/overview) for how certificates can be requested against this profile
to begin with.
You should specify the certificate template from Step 2, the issuing CA from Step 1, and the **API** option in the **Enrollment Method** dropdown when creating the certificate profile.
<Note>
Note that if you're looking to issue self-signed certificates, you should select the **Self-Signed** option in the **Issuer Type** dropdown when creating the certificate profile.
</Note>
</Step>
<Step title="Request a certificate">
Next, [install the Infisical CLI](/cli/overview) on the target machine you wish to request the certificate on and follow the documentation [here](/integrations/platforms/certificate-agent#operating-the-agent) to set up the Infisical Agent on it.
As part of the setup, you must create an [agent configuration file](/integrations/platforms/certificate-agent#agent-configuration) that specifies how the agent should authenticate with Infisical using a [machine identity](/documentation/platform/identities/machine-identities), the certificate profile it should request against (from Step 3), what kind of certificate to request, where to persist the certificate, and how it should be managed in terms of auto-renewal.
Finally, start the agent with that configuration file so it can start requesting and continuously renewing the certificate on your behalf using the command below:
```bash
infisical cert-manager agent --config /path/to/your/agent-config.yaml
```
The certificate, certificate chain, and private key will be persisted to the filesystem at the paths specified in the `file-output` section of the agent configuration file.
</Step>
</Steps>

View File

@@ -0,0 +1,79 @@
---
title: "Request a Certificate via API"
---
import RequestCertSetup from "/snippets/documentation/platform/pki/guides/request-cert-setup.mdx";
The [API enrollment method](/documentation/platform/pki/enrollment-methods/api) lets you programmatically request TLS and other X.509 certificates from Infisical.
This is the most flexible way to request certificates from Infisical but requires you to implement certificate request and renewal logic on your own.
For a more automated way to request certificates, we highly recommend you check out the guide for requesting certificates using the [Infisical Agent](/integrations/platforms/certificate-agent) [here](/documentation/platform/pki/guides/request-cert-agent).
## Diagram
The following sequence diagram illustrates the certificate issuance workflow for requesting a certificate via API from Infisical.
```mermaid
sequenceDiagram
autonumber
participant Client as Client
participant Infis as Infisical
participant CA as CA<br/>(Internal or External)
Client->>Infis: POST /certificate<br/>(profileId, conditional subject/SANs, ttl,<br/>key usages, conditional CSR, etc.)
Infis->>Infis: Look up certificate profile<br/>(by profileId)
Infis->>Infis: Validate request or CSR<br/>against profile constraints<br/>(CN/SAN rules, key usages, max TTL, etc.)
alt Issuer Type = Self-Signed
Infis->>Infis: Generate keypair<br/>and self-sign certificate
else Issuer Type = CA
Infis->>CA: Request certificate issuance<br/>(CSR)
CA-->>Infis: Signed certificate<br/>(+ chain)
end
Infis-->>Client: Return certificate, certificate chain,<br/>issuing CA certificate, serial number,<br/>certificate ID<br/>(and private key if server-generated)<br /> OR certificate request ID if async
```
## Guide
In the following steps, we explore an end-to-end workflow for requesting a certificate via API from Infisical.
<Steps>
<RequestCertSetup />
<Step title="Create a certificate profile">
Next, follow the guide [here](/documentation/platform/pki/certificates/profiles#guide-to-creating-a-certificate-profile) to create a [certificate profile](/documentation/platform/pki/certificates/profiles)
that will be referenced when requesting a certificate.
The certificate profile specifies which certificate template and issuing CA should be used to validate an incoming certificate request and issue a certificate;
it also specifies the [enrollment method](/documentation/platform/pki/enrollment-methods/overview) for how certificates can be requested against this profile
to begin with.
You should specify the certificate template from Step 2, the issuing CA from Step 1, and the **API** option in the **Enrollment Method** dropdown when creating the certificate profile.
<Note>
Note that if you're looking to issue self-signed certificates, you should select the **Self-Signed** option in the **Issuer Type** dropdown when creating the certificate profile.
</Note>
</Step>
<Step title="Request a certificate">
Finally, follow the guide [here](/documentation/platform/pki/enrollment-methods/api#guide-to-certificate-enrollment-via-api) to request a certificate against the certificate profile
over the Web UI or by making an API request the [Issue Certificate](/api-reference/endpoints/certificates/create-certificate) API endpoint with or without a certificate signing request (CSR).
To renew a certificate on the client-side, you have two options:
- Make a request to issue a new certificate against the same [Issue Certificate](/api-reference/endpoints/certificates/create-certificate) API endpoint.
- Make a request to the [Renew Certificate](/api-reference/endpoints/certificates/renew) API endpoint with the ID of the certificate you wish to renew. Note that this endpoint only works if the original certificate was issued through the [Issue Certificate](/api-reference/endpoints/certificates/issue-certificate) API endpoint without a CSR.
<Note>
We recommend reading the guide [here](/documentation/platform/pki/certificates/certificates#guide-to-renewing-certificates) to learn more about all the ways to renew a certificate
with Infisical including [server-driven certificate renewal](/documentation/platform/pki/certificates/certificates#server-driven-certificate-renewal).
</Note>
</Step>
</Steps>
Note that depending on your environment and infrastructure use-case, you may wish to use a different [enrollment method](/documentation/platform/pki/enrollment-methods/overview) to request certificates.
For more automated certificate management, you may wish to request certificates using a client that can monitor expiring certificates and request renewals for you.
For example, you can install the Infisical Agent on a VM and have it request and renew certificates for you or use an [ACME client](https://letsencrypt.org/docs/client-options/) paired with Infisical's [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme).

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 626 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 615 KiB

After

Width:  |  Height:  |  Size: 203 KiB

View File

@@ -0,0 +1,552 @@
---
title: "Infisical Agent"
sidebarTitle: "Infisical Agent"
description: "Learn how to use Infisical CLI Agent to manage certificates automatically."
---
## Concept
The Infisical Agent is a client daemon that is packaged into the [Infisical CLI](/cli/overview).
It can be used to request a certificate from Infisical using the [API enrollment method](/documentation/platform/pki/enrollment-methods/api) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles), persist it to a specified path on the filesystem, and automatically monitor and renew it before expiration.
The Infisical Agent is notable:
- Automating certificate management: The agent can request, persist, monitor, and renew certificates from Infisical automatically without manual intervention. It also supports post-event hooks to execute custom commands after certificate issuance, renewal, or failure events.
- Leveraging workload identity: The agent can authenticate with Infisical as a [machine identity](/documentation/platform/identities/machine-identities) using an infrastructure-native authentication method such as [AWS Auth](/docs/documentation/platform/identities/aws-auth), [Azure Auth](/docs/documentation/platform/identities/azure-auth), [GCP Auth](/docs/documentation/platform/identities/gcp-auth), [Kubernetes Auth](/docs/documentation/platform/identities/kubernetes-auth), etc.
The typical workflow for using the agent involves installing the Infisical CLI on the target machine, creating a configuration file defining the certificate to request and how it should be managed, and then starting the agent with that configuration so it can request, persist, monitor, and renew the certificate before it expires.
This follows a [client-driven approach](/documentation/platform/pki/certificates/certificates#client-driven-certificate-renewal) to certificate renewal.
## Workflow
A typical workflow for using the Infisical Agent to request certificates from Infisical consists of the following steps:
1. Create a [certificate profile](/documentation/platform/pki/certificates/profiles) in Infisical with the [API enrollment method](/documentation/platform/pki/enrollment-methods/api) configured on it.
2. Install the [Infisical CLI](/cli/overview) on the target machine.
3. Create an agent [configuration file](/integrations/platforms/certificate-agent#agent-configuration) containing details about the certificate to request and how it should be managed such as renewal thresholds, post-event hooks, etc.
4. Start the agent with that configuration so it can request, persist, monitor, and going forward automatically renew the certificate before it expires on the target machine.
## Operating the Agent
This section describes how to use the Infisical Agent to request certificates from Infisical. It covers how the agent authenticates with Infisical,
and how to configure it to start requesting certificates from Infisical.
### Authentication
The Infisical Agent can authenticate with Infisical as a [machine identity](/documentation/platform/identities/machine-identities) using one of its supported authentication methods.
Upon successful authentication, the agent receives a short-lived access token that it uses to make subsequent authenticated requests to obtain and renew certificates from Infisical;
the agent automatically handles token renewal as documented [here](/integrations/platforms/infisical-agent#token-renewal).
<AccordionGroup>
<Accordion title="Universal Auth">
The Universal Auth method uses a client ID and secret for authentication.
<Steps>
<Step title="Create a universal auth machine identity">
To create a universal auth machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/universal-auth).
</Step>
<Step title="Configure the agent">
Update the agent configuration file with the auth method and credentials:
```yaml
auth:
type: "universal-auth"
config:
client-id: "./client-id" # Path to file containing client ID
client-secret: "./client-secret" # Path to file containing client secret
remove-client-secret-on-read: false # Optional: remove secret file after reading
```
You can also provide credentials directly:
```yaml
auth:
type: "universal-auth"
config:
client-id: "your-client-id"
client-secret: "your-client-secret"
```
</Step>
</Steps>
</Accordion>
<Accordion title="Kubernetes Auth">
The Kubernetes Auth method is used when running the agent in a Kubernetes environment.
<Steps>
<Step title="Create a Kubernetes machine identity">
To create a Kubernetes machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/kubernetes-auth).
</Step>
<Step title="Configure the agent">
Configure the agent to use Kubernetes service account authentication:
```yaml
auth:
type: "kubernetes-auth"
config:
identity-id: "your-kubernetes-identity-id"
service-account-token-path: "/var/run/secrets/kubernetes.io/serviceaccount/token"
```
</Step>
</Steps>
</Accordion>
<Accordion title="Azure Auth">
The Azure Auth method is used when running the agent in an Azure environment.
<Steps>
<Step title="Create an Azure machine identity">
To create an Azure machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/azure-auth).
</Step>
<Step title="Configure the agent">
Configure the agent to use Azure managed identity authentication:
```yaml
auth:
type: "azure-auth"
config:
identity-id: "your-azure-identity-id"
```
</Step>
</Steps>
</Accordion>
<Accordion title="Native GCP ID Token">
The Native GCP ID Token method is used to authenticate with Infisical when running in a GCP environment.
<Steps>
<Step title="Create a GCP machine identity">
To create a GCP machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/gcp-auth).
</Step>
<Step title="Configure the agent">
Update the agent configuration file with the specified auth method and identity ID:
```yaml
auth:
type: "gcp-id-token"
config:
identity-id: "your-gcp-identity-id"
```
</Step>
</Steps>
</Accordion>
<Accordion title="GCP IAM">
The GCP IAM method is used to authenticate with Infisical with a GCP service account key.
<Steps>
<Step title="Create a GCP machine identity">
To create a GCP machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/gcp-auth).
</Step>
<Step title="Configure the agent">
Update the agent configuration file with the specified auth method, identity ID, and service account key:
```yaml
auth:
type: "gcp-iam"
config:
identity-id: "your-gcp-identity-id"
service-account-key: "/path/to/service-account-key.json"
```
</Step>
</Steps>
</Accordion>
<Accordion title="Native AWS IAM">
The AWS IAM method is used to authenticate with Infisical with an AWS IAM role while running in an AWS environment.
<Steps>
<Step title="Create an AWS machine identity">
To create an AWS machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/aws-auth).
</Step>
<Step title="Configure the agent">
Update the agent configuration file with the specified auth method and identity ID:
```yaml
auth:
type: "aws-iam"
config:
identity-id: "your-aws-identity-id"
```
</Step>
</Steps>
</Accordion>
</AccordionGroup>
### Agent Configuration
The Infisical Agent relies on a YAML configuration file to define its behavior, including how it should authenticate with Infisical, the certificate it should request, and how that certificate should be managed including auto-renewal.
The code snippet below shows an example configuration file that instructs the agent to request and continuously renew a certificate from Infisical.
Note that not all configuration options in this file are required but this example includes all of the available options.
```yaml example-cert-agent-config.yaml
version: v1
# Infisical server configuration
infisical:
address: "https://app.infisical.com" # The URL of the Infisical instance (e.g. https://app.infisical.com, https://eu.infisical.com, https://your-self-hosted-instance.com)
retry-strategy:
max-retries: 3
max-delay: "5s"
base-delay: "200ms"
# Infisical authentication configuration
auth:
type: "universal-auth" # The authentication method to use (e.g. universal-auth, kubernetes-auth, azure-auth, gcp-id-token, gcp-iam, aws-iam)
config:
client-id: "your-client-id"
client-secret: "your-client-secret"
# Certificate configuration
certificates:
- profile-name: "prof-web-server-12345"
project-slug: "my-project-slug"
attributes:
common-name: "api.example.com"
alt-names: ["api.example.com", "api-v2.example.com"]
ttl: "90d"
key-algorithm: "RSA_2048"
signature-algorithm: "RSA-SHA256"
key-usages:
- "digital_signature"
- "key_encipherment"
extended-key-usages:
- "server_auth"
# Enable automatic certificate renewal
lifecycle:
renew-before-expiry: "30d"
status-check-interval: "6h"
# Configure where to store the issued certificate and its associated private key and certificate chain
file-output:
private-key:
path: "/etc/ssl/private/web.key"
permission: "0600" # Read/write for owner only
certificate:
path: "/etc/ssl/certs/web.crt"
permission: "0644" # Read for all, write for owner
chain:
path: "/etc/ssl/certs/web-chain.crt"
permission: "0644" # Read for all, write for owner
omit-root: true # Exclude the root CA certificate in chain
# Configure custom commands to execute after certificate issuance, renewal, or failure events
post-hooks:
on-issuance:
command: |
echo "Certificate issued for ${CERT_COMMON_NAME}"
systemctl reload nginx
timeout: 30
on-renewal:
command: |
echo "Certificate renewed for ${CERT_COMMON_NAME}"
systemctl reload nginx
timeout: 30
on-failure:
command: |
echo "Certificate operation failed: ${ERROR_MESSAGE}"
mail -s "Certificate Alert" admin@company.com < /dev/null
timeout: 30
```
To be more specific, the configuration file instructs the agent to:
- Authenticate with Infisical using the [Universal Auth](/integrations/platforms/certificate-agent#universal-auth) authentication method.
- Request a 90-day certificate against the [certificate profile](/documentation/platform/pki/certificates/profiles) named `prof-web-server-12345` with the common name `web.company.com` and the subject alternative names `web.company.com` and `www.company.com`.
- Automatically renew the certificate 30 days before expiration by checking the certificate status every 6 hours and retrying up to 3 times with a base delay of 200ms and a maximum delay of 5s if the certificate status check fails.
- Store the certificate and its associated private key and certificate chain (excluding the root CA certificate) in the filesystem at the specified paths with the specified permissions.
- Execute custom commands after certificate issuance, renewal, or failure events such as reloading an `nginx` service or sending an email notification.
### Agent Execution
After creating the configuration file, you can run the command below with the `--config` flag pointing to the path where the agent configuration file is located.
```bash
infisical cert-manager agent --config /path/to/your/agent-config.yaml
```
This will start the agent as a daemon process, continuously monitoring and managing certificates according to your configuration. You can also run it in the foreground for debugging:
```bash
infisical cert-manager agent --config /path/to/your/agent-config.yaml --verbose
```
For production deployments, you may consider running the agent as a system service to ensure it starts automatically and runs continuously.
### Agent Certificate Configuration Parameters
The table below provides a complete list of parameters that can be configured in the **certificate configuration** section of the agent configuration file:
| Parameter | Required | Description |
| ------------------------------------ | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `profile-name` | Yes | The name of the [certificate profile](/documentation/platform/pki/certificates/profiles) to request a certificate against (e.g., `web-server-12345`) |
| `project-slug` | Yes | The slug of the project to request a certificate against (e.g., `my-project-slug`) |
| `common-name` | Optional | The common name for the certificate (e.g. `www.example.com`) |
| `alt-names` | Optional | The list of subject alternative names for the certificate (e.g., `["www.example.com", "api.example.com"]`) |
| `ttl` | Optional (uses profile default if not specified) | The time-to-live duration for the certificate, specified as a duration string (e.g. `72h`, `90d`, `1y`, etc.) |
| `key-algorithm` | Optional | The algorithm for the certificate key pair. One of: `RSA_2048`, `RSA_3072`, `RSA_4096`, `EC_prime256v1`, `EC_secp384r1`, `EC_secp521r1`. |
| `signature-algorithm` | Optional | The algorithm used to sign the certificate. One of: `RSA-SHA256`, `RSA-SHA384`, `RSA-SHA512`, `ECDSA-SHA256`, `ECDSA-SHA384`, `ECDSA-SHA512`. |
| `key-usages` | Optional | The list of key usage values for the certificate. One or more of: `digital_signature`, `key_encipherment`, `non_repudiation`, `data_encipherment`, `key_agreement`, `key_cert_sign`, `crl_sign`, `encipher_only`, `decipher_only`. |
| `extended-key-usages` | Optional | The list of extended key usage values for the certificate. One or more of: `server_auth`, `client_auth`, `code_signing`, `email_protection`, `timestamping`, `ocsp_signing`. |
| `csr-path` | Conditional | The path to a certificate signing request (CSR) file (e.g., `./csr/webserver.csr`, `/etc/ssl/csr.pem`). This is required if using a pre-generated CSR. |
| `file-output.private-key.path` | Optional (required if the `csr-path` is not specified) | The path to store the private key (required if not using a CSR) |
| `file-output.private-key.permission` | Optional (defaults to `0600`) | The octal file permissions for the private key file (e.g. `0600`) |
| `file-output.certificate.path` | Yes | The path to store the issued certificate in the filesystem |
| `file-output.certificate.permission` | Optional (defaults to `0600`) | The octal file permissions for the certificate file (e.g. `0644`) |
| `file-output.chain.path` | Optional | The path to store the certificate chain in the filesystem. |
| `file-output.chain.permission` | Optional (defaults to `0600`) | The octal permissions for the chain file (e.g. `0644`) |
| `file-output.chain.omit-root` | Optional (defaults to `true`) | Whether to exclude the root CA certificate from the returned certificate chain |
| `lifecycle.renew-before-expiry` | Optional (auto-renewal is disabled if not set) | Duration before certificate expiration when renewal checks should begin, specified as a duration string (e.g. `72h`, `90d`, `1y`, etc.) |
| `lifecycle.status-check-interval` | Optional (defaults to `10s`) | How frequently the agent checks certificate status and renewal needs, specified as a duration string (e.g. `10s`, `30m`, `1d`, etc.) |
| `post-hooks.on-issuance.command` | Optional | The shell command to execute after a certificate is successfully issued for the first time (e.g., `systemctl reload nginx`, `/usr/local/bin/reload-service.sh`) |
| `post-hooks.on-issuance.timeout` | Optional (defaults to `30`) | Maximum execution time in seconds for the on-issuance post-hook command before it is terminated (e.g., `30`, `60`, `120`) |
| `post-hooks.on-renewal.command` | Optional | The shell command to execute after a certificate is successfully renewed (e.g., `systemctl reload nginx`, `docker restart web-server`) |
| `post-hooks.on-renewal.timeout` | Optional (defaults to `30`) | Maximum execution time in seconds for the on-renewal post-hook command before it is terminated (e.g., `30`, `60`, `120`) |
| `post-hooks.on-failure.command` | Optional | The shell command to execute when certificate issuance or renewal fails (e.g., `logger 'Certificate renewal failed'`, `/usr/local/bin/alert.sh`) |
| `post-hooks.on-failure.timeout` | Optional (defaults to `30`) | Maximum execution time in seconds for the on-failure post-hook command before it is terminated (e.g., `10`, `30`, `60`) |
### Post-Event Hooks
The Infisical Agent supports running custom commands in response to certificate lifecycle events such as issuance, renewal, and failure through the `post-hooks` configuration
in the agent configuration file.
<Tabs>
<Tab title="Issuance Hook">
Runs when a new certificate is successfully issued:
```yaml
post-hooks:
on-issuance:
command: |
echo "New certificate issued for ${CERT_COMMON_NAME}"
chown nginx:nginx ${CERT_FILE_PATH}
chmod 644 ${CERT_FILE_PATH}
systemctl reload nginx
timeout: 30
```
</Tab>
<Tab title="Renewal Hook">
Runs when a certificate is successfully renewed:
```yaml
post-hooks:
on-renewal:
command: |
echo "Certificate renewed for ${CERT_COMMON_NAME}"
# Reload services that use the certificate
systemctl reload nginx
systemctl reload haproxy
# Send notification
curl -X POST https://hooks.slack.com/... \
-d "{'text': 'Certificate for ${CERT_COMMON_NAME} renewed successfully'}"
timeout: 60
```
</Tab>
<Tab title="Failure Hook">
Runs when certificate operations fail:
```yaml
post-hooks:
on-failure:
command: |
echo "Certificate operation failed for ${CERT_COMMON_NAME}: ${ERROR_MESSAGE}"
# Send alert
mail -s "Certificate Failure Alert" admin@company.com < /dev/null
# Log to syslog
logger -p daemon.error "Certificate agent failure: ${ERROR_MESSAGE}"
timeout: 30
```
</Tab>
</Tabs>
### Retrying mechanism
The Infisical Agent will automatically attempt to retry any failed API requests including authentication, certificate issuance, and renewal operations.
By default, the agent will retry up to 3 times with a base delay of 200ms and a maximum delay of 5s.
You can configure the retrying mechanism through the agent configuration file:
```yaml
infisical:
address: "https://app.infisical.com"
retry-strategy:
max-retries: 3
max-delay: "5s"
base-delay: "200ms"
# ... rest of the agent configuration file
```
## Example Agent Configuration Files
Since there are several ways you might want to use the Infisical Agent to request certificates from Infisical,
we provide a few example configuration files for common use cases below to help you get started.
### One-Time Certificate Issuance
The code snippet below shows a configuration file that instructs the agent to request a certificate from Infisical
once without performing any subsequent auto-renewal.
```yaml
version: v1
# Infisical server configuration
infisical:
address: "https://app.infisical.com" # The URL of the Infisical instance (e.g. https://app.infisical.com, https://eu.infisical.com, https://your-self-hosted-instance.com)
retry-strategy:
max-retries: 3
max-delay: "5s"
base-delay: "200ms"
# Infisical authentication configuration
auth:
type: "universal-auth" # The authentication method to use (e.g. universal-auth, kubernetes-auth, azure-auth, gcp-id-token, gcp-iam, aws-iam)
config:
client-id: "your-client-id"
client-secret: "your-client-secret"
# Certificate configuration
certificates:
- profile-name: "prof-web-server-12345"
project-slug: "my-project-slug"
attributes:
common-name: "api.example.com"
alt-names:
- "api.example.com"
- "api-v2.example.com"
key-algorithm: "RSA_2048"
signature-algorithm: "RSA-SHA256"
key-usages:
- "digital_signature"
- "key_encipherment"
extended-key-usages:
- "server_auth"
ttl: "30d"
file-output:
private-key:
path: "/etc/ssl/private/api.example.com.key"
permission: "0600"
certificate:
path: "/etc/ssl/certs/api.example.com.crt"
permission: "0644"
chain:
path: "/etc/ssl/certs/api.example.com.chain.crt"
permission: "0644"
omit-root: true
```
### One-Time Certificate Issuance using a Pre-Generated CSR
The code snippet below shows a configuration file that instructs the agent to request a certificate from Infisical
once using a pre-generated CSR.
Note that when `csr-path` is specified:
- The `private-key` is omitted from the configuration file because we assume that it is pre-generated and managed externally, with only the CSR being submitted to Infisical for signing.
- The agent will not be able to perform any auto-renewal operations, as it is assumed to not have access to the private key required to generate a new CSR.
```yaml
version: v1
# Infisical server configuration
infisical:
address: "https://app.infisical.com" # The URL of the Infisical instance (e.g. https://app.infisical.com, https://eu.infisical.com, https://your-self-hosted-instance.com)
retry-strategy:
max-retries: 3
max-delay: "5s"
base-delay: "200ms"
# Infisical authentication configuration
auth:
type: "universal-auth" # The authentication method to use (e.g. universal-auth, kubernetes-auth, azure-auth, gcp-id-token, gcp-iam, aws-iam)
config:
client-id: "your-client-id"
client-secret: "your-client-secret"
# Certificate configuration
certificates:
- profile-name: "prof-web-server-12345"
project-slug: "my-project-slug"
csr-path: "/etc/ssl/requests/api.csr"
file-output:
certificate:
path: "/etc/ssl/certs/api.example.com.crt"
permission: "0644"
chain:
path: "/etc/ssl/certs/api.example.com.chain.crt"
permission: "0644"
omit-root: true
```
### Certificate Issuance with Automatic Renewal
The code snippet below shows a configuration file that instructs the agent to request a certificate from Infisical and continuously renew it 14 days before expiration, checking the certificate status every 6 hours.
```yaml
version: v1
# Infisical server configuration
infisical:
address: "https://app.infisical.com" # The URL of the Infisical instance (e.g. https://app.infisical.com, https://eu.infisical.com, https://your-self-hosted-instance.com)
retry-strategy:
max-retries: 3
max-delay: "5s"
base-delay: "200ms"
# Infisical authentication configuration
auth:
type: "universal-auth" # The authentication method to use (e.g. universal-auth, kubernetes-auth, azure-auth, gcp-id-token, gcp-iam, aws-iam)
config:
client-id: "your-client-id"
client-secret: "your-client-secret"
# Certificate configuration
certificates:
- profile-name: "prof-web-server-12345"
project-slug: "my-project-slug"
attributes:
common-name: "api.example.com"
alt-names:
- "api.example.com"
- "api-v2.example.com"
key-algorithm: "RSA_2048"
signature-algorithm: "RSA-SHA256"
key-usages:
- "digital_signature"
- "key_encipherment"
extended-key-usages:
- "server_auth"
ttl: "30d"
lifecycle:
renew-before-expiry: "14d" # Renew 14 days before expiration
status-check-interval: "6h" # Check certificate status every 6 hours
file-output:
private-key:
path: "/etc/ssl/private/api.example.com.key"
permission: "0600"
certificate:
path: "/etc/ssl/certs/api.example.com.crt"
permission: "0644"
chain:
path: "/etc/ssl/certs/api.example.com.chain.crt"
permission: "0644"
post-hooks:
on-issuance:
command: "systemctl reload nginx"
timeout: 30
on-renewal:
command: "systemctl reload nginx && logger 'Certificate renewed'"
timeout: 30
```

View File

@@ -247,9 +247,9 @@ curl -X POST \
"projectDescription": "A project created via API",
"slug": "new-project-slug",
"template": "default",
"type": "SECRET_MANAGER"
"type": "secret-manager"
}' \
https://your-infisical-instance.com/api/v2/projects
https://your-infisical-instance.com/api/v1/projects
```
## Important Notes

View File

@@ -0,0 +1,27 @@
<Step title="Configure a Certificate Authority">
Before you can issue any certificate, you must first configure a [Certificate Authority (CA)](/documentation/platform/pki/ca/overview).
The CA you configure will be used to issue the certificate back to your client; it can be either Internal or External:
- [Internal CA](/documentation/platform/pki/ca/private-ca): If you're building your own PKI and wish to issue certificates for internal use, you should
follow the guide [here](/documentation/platform/pki/ca/private-ca#guide-to-creating-a-ca-hierarchy) to create at minimum a root CA and an intermediate/issuing CA
within Infisical.
- [External CA](/documentation/platform/pki/ca/external-ca): If you have existing PKI infrastructure or wish to connect to a public CA (e.g. [Let's Encrypt](/documentation/platform/pki/ca/lets-encrypt), [DigiCert](/documentation/platform/pki/ca/digicert), etc.) to issue TLS certificates,
you should follow the documentation [here](/documentation/platform/pki/ca/external-ca) to configure an External CA.
<Note>
Note that if you're looking to issue self-signed certificates, you can skip this step and proceed to Step 3.
</Note>
</Step>
<Step title="Create a certificate template">
Next, follow the guide [here](/documentation/platform/pki/certificates/templates#guide-to-creating-a-certificate-template) to create a [certificate template](/documentation/platform/pki/certificates/templates).
The certificate template will constrain what attributes may or may not be allowed in the request to issue a certificate.
For example, you can specify that the requested common name must adhere to a specific format like `*.acme.com` and
that the maximum TTL cannot exceed 1 year.
If you're looking to issue TLS server certificates, you should select the **TLS Server Certificate** option under the **Template Preset** dropdown.
</Step>

View File

@@ -8,15 +8,15 @@
http-equiv="Content-Security-Policy"
content="
default-src 'self';
connect-src 'self' https://d1zwf0dwl0k2ky.cloudfront.net https://*.posthog.com http://127.0.0.1:* https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.38.2/dist/dotlottie-player.wasm;
script-src 'self' https://d1zwf0dwl0k2ky.cloudfront.net https://*.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.38.2/dist/dotlottie-player.wasm;
style-src 'self' https://d1zwf0dwl0k2ky.cloudfront.net 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com;
connect-src 'self' __INFISICAL_CDN_HOST__ https://*.posthog.com http://127.0.0.1:* https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.38.2/dist/dotlottie-player.wasm;
script-src 'self' __INFISICAL_CDN_HOST__ https://*.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.38.2/dist/dotlottie-player.wasm;
style-src 'self' __INFISICAL_CDN_HOST__ 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com;
child-src https://api.stripe.com;
frame-src https://js.stripe.com/ https://api.stripe.com https://www.youtube.com/ https://hcaptcha.com https://*.hcaptcha.com;
connect-src 'self' https://d1zwf0dwl0k2ky.cloudfront.net wss://nexus-websocket-a.intercom.io https://api-iam.intercom.io https://api.heroku.com/ https://id.heroku.com/oauth/authorize https://id.heroku.com/oauth/token https://checkout.stripe.com https://app.posthog.com https://api.stripe.com https://api.pwnedpasswords.com http://127.0.0.1:* https://hcaptcha.com https://*.hcaptcha.com;
img-src 'self' https://d1zwf0dwl0k2ky.cloudfront.net https://static.intercomassets.com https://js.intercomcdn.com https://downloads.intercomcdn.com https://*.stripe.com https://i.ytimg.com/ data:;
media-src https://d1zwf0dwl0k2ky.cloudfront.net https://js.intercomcdn.com;
font-src 'self' https://d1zwf0dwl0k2ky.cloudfront.net https://fonts.intercomcdn.com/ https://fonts.gstatic.com;
connect-src 'self' __INFISICAL_CDN_HOST__ wss://nexus-websocket-a.intercom.io https://api-iam.intercom.io https://api.heroku.com/ https://id.heroku.com/oauth/authorize https://id.heroku.com/oauth/token https://checkout.stripe.com https://app.posthog.com https://api.stripe.com https://api.pwnedpasswords.com http://127.0.0.1:* https://hcaptcha.com https://*.hcaptcha.com;
img-src 'self' https://static.intercomassets.com https://js.intercomcdn.com https://downloads.intercomcdn.com https://*.stripe.com https://i.ytimg.com/ data:;
media-src __INFISICAL_CDN_HOST__ https://js.intercomcdn.com;
font-src 'self' __INFISICAL_CDN_HOST__ https://fonts.intercomcdn.com/ https://fonts.gstatic.com;
"
/>
<title>Infisical</title>

View File

@@ -1,4 +1,5 @@
export {
useCheckPolicyMatch,
useCreateApprovalPolicy,
useDeleteApprovalPolicy,
useUpdateApprovalPolicy
@@ -11,6 +12,8 @@ export {
type PamAccessPolicyConditions,
type PamAccessPolicyConstraints,
type TApprovalPolicy,
type TCheckPolicyMatchDTO,
type TCheckPolicyMatchResult,
type TCreateApprovalPolicyDTO,
type TDeleteApprovalPolicyDTO,
type TGetApprovalPolicyByIdDTO,

View File

@@ -5,6 +5,8 @@ import { apiRequest } from "@app/config/request";
import { approvalPolicyQuery } from "./queries";
import {
TApprovalPolicy,
TCheckPolicyMatchDTO,
TCheckPolicyMatchResult,
TCreateApprovalPolicyDTO,
TDeleteApprovalPolicyDTO,
TUpdateApprovalPolicyDTO
@@ -56,3 +58,15 @@ export const useDeleteApprovalPolicy = () => {
}
});
};
export const useCheckPolicyMatch = () => {
return useMutation({
mutationFn: async ({ policyType, projectId, inputs }: TCheckPolicyMatchDTO) => {
const { data } = await apiRequest.post<TCheckPolicyMatchResult>(
`/api/v1/approval-policies/${policyType}/check-policy-match`,
{ projectId, inputs }
);
return data;
}
});
};

View File

@@ -81,3 +81,14 @@ export type TDeleteApprovalPolicyDTO = {
policyType: ApprovalPolicyType;
policyId: string;
};
export type TCheckPolicyMatchDTO = {
policyType: ApprovalPolicyType;
projectId: string;
inputs: { accountPath: string };
};
export type TCheckPolicyMatchResult = {
requiresApproval: boolean;
hasActiveGrant: boolean;
};

View File

@@ -1,8 +1,15 @@
export {
useAddIdentityToGroup,
useAddUserToGroup,
useCreateGroup,
useDeleteGroup,
useRemoveIdentityFromGroup,
useRemoveUserFromGroup,
useUpdateGroup
} from "./mutations";
export { useGetGroupById, useListGroupProjects, useListGroupUsers } from "./queries";
export {
useGetGroupById,
useListGroupMachineIdentities,
useListGroupProjects,
useListGroupUsers
} from "./queries";

View File

@@ -5,7 +5,7 @@ import { apiRequest } from "@app/config/request";
import { organizationKeys } from "../organization/queries";
import { userKeys } from "../users/query-keys";
import { groupKeys } from "./queries";
import { TGroup } from "./types";
import { TGroup, TGroupMachineIdentity } from "./types";
export const useCreateGroup = () => {
const queryClient = useQueryClient();
@@ -95,6 +95,7 @@ export const useAddUserToGroup = () => {
},
onSuccess: (_, { slug }) => {
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupUserMemberships(slug) });
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupMembers(slug) });
}
});
};
@@ -119,6 +120,55 @@ export const useRemoveUserFromGroup = () => {
onSuccess: (_, { slug, username }) => {
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupUserMemberships(slug) });
queryClient.invalidateQueries({ queryKey: userKeys.listUserGroupMemberships(username) });
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupMembers(slug) });
}
});
};
export const useAddIdentityToGroup = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
groupId,
identityId
}: {
groupId: string;
identityId: string;
slug: string;
}) => {
const { data } = await apiRequest.post<Pick<TGroupMachineIdentity, "id" | "name">>(
`/api/v1/groups/${groupId}/machine-identities/${identityId}`
);
return data;
},
onSuccess: (_, { slug }) => {
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupIdentitiesMemberships(slug) });
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupMembers(slug) });
}
});
};
export const useRemoveIdentityFromGroup = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
groupId,
identityId
}: {
groupId: string;
identityId: string;
slug: string;
}) => {
const { data } = await apiRequest.delete<Pick<TGroupMachineIdentity, "id" | "name">>(
`/api/v1/groups/${groupId}/machine-identities/${identityId}`
);
return data;
},
onSuccess: (_, { slug }) => {
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupIdentitiesMemberships(slug) });
queryClient.invalidateQueries({ queryKey: groupKeys.forGroupMembers(slug) });
}
});
};

View File

@@ -4,9 +4,14 @@ import { apiRequest } from "@app/config/request";
import { OrderByDirection } from "../generic/types";
import {
EFilterReturnedProjects,
EFilterReturnedUsers,
FilterMemberType,
FilterReturnedMachineIdentities,
FilterReturnedProjects,
FilterReturnedUsers,
GroupMembersOrderBy,
TGroup,
TGroupMachineIdentity,
TGroupMember,
TGroupProject,
TGroupUser
} from "./types";
@@ -27,10 +32,12 @@ export const groupKeys = {
offset: number;
limit: number;
search: string;
filter?: EFilterReturnedUsers;
filter?: FilterReturnedUsers;
}) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search, filter }] as const,
specificProjectGroupUserMemberships: ({
projectId,
allGroupIdentitiesMemberships: () => ["group-identities-memberships"] as const,
forGroupIdentitiesMemberships: (slug: string) =>
[...groupKeys.allGroupIdentitiesMemberships(), slug] as const,
specificGroupIdentitiesMemberships: ({
slug,
offset,
limit,
@@ -38,16 +45,34 @@ export const groupKeys = {
filter
}: {
slug: string;
projectId: string;
offset: number;
limit: number;
search: string;
filter?: EFilterReturnedUsers;
filter?: FilterReturnedMachineIdentities;
}) =>
[...groupKeys.forGroupIdentitiesMemberships(slug), { offset, limit, search, filter }] as const,
allGroupMembers: () => ["group-members"] as const,
forGroupMembers: (slug: string) => [...groupKeys.allGroupMembers(), slug] as const,
specificGroupMembers: ({
slug,
offset,
limit,
search,
orderBy,
orderDirection,
memberTypeFilter
}: {
slug: string;
offset: number;
limit: number;
search: string;
orderBy?: GroupMembersOrderBy;
orderDirection?: OrderByDirection;
memberTypeFilter?: FilterMemberType[];
}) =>
[
...groupKeys.forGroupUserMemberships(slug),
projectId,
{ offset, limit, search, filter }
...groupKeys.forGroupMembers(slug),
{ offset, limit, search, orderBy, orderDirection, memberTypeFilter }
] as const,
allGroupProjects: () => ["group-projects"] as const,
forGroupProjects: (groupId: string) => [...groupKeys.allGroupProjects(), groupId] as const,
@@ -64,7 +89,7 @@ export const groupKeys = {
offset: number;
limit: number;
search: string;
filter?: EFilterReturnedProjects;
filter?: FilterReturnedProjects;
orderBy?: string;
orderDirection?: OrderByDirection;
}) =>
@@ -99,7 +124,7 @@ export const useListGroupUsers = ({
offset: number;
limit: number;
search: string;
filter?: EFilterReturnedUsers;
filter?: FilterReturnedUsers;
}) => {
return useQuery({
queryKey: groupKeys.specificGroupUserMemberships({
@@ -115,7 +140,7 @@ export const useListGroupUsers = ({
const params = new URLSearchParams({
offset: String(offset),
limit: String(limit),
search,
...(search && { search }),
...(filter && { filter })
});
@@ -131,9 +156,66 @@ export const useListGroupUsers = ({
});
};
export const useListProjectGroupUsers = ({
export const useListGroupMembers = ({
id,
groupSlug,
offset = 0,
limit = 10,
search,
orderBy,
orderDirection,
memberTypeFilter
}: {
id: string;
groupSlug: string;
offset: number;
limit: number;
search: string;
orderBy?: GroupMembersOrderBy;
orderDirection?: OrderByDirection;
memberTypeFilter?: FilterMemberType[];
}) => {
return useQuery({
queryKey: groupKeys.specificGroupMembers({
slug: groupSlug,
offset,
limit,
search,
orderBy,
orderDirection,
memberTypeFilter
}),
enabled: Boolean(groupSlug),
placeholderData: (previousData) => previousData,
queryFn: async () => {
const params = new URLSearchParams({
offset: String(offset),
limit: String(limit),
...(search && { search }),
...(orderBy && { orderBy: orderBy.toString() }),
...(orderDirection && { orderDirection })
});
if (memberTypeFilter && memberTypeFilter.length > 0) {
memberTypeFilter.forEach((filter) => {
params.append("memberTypeFilter", filter);
});
}
const { data } = await apiRequest.get<{ members: TGroupMember[]; totalCount: number }>(
`/api/v1/groups/${id}/members`,
{
params
}
);
return data;
}
});
};
export const useListGroupMachineIdentities = ({
id,
projectId,
groupSlug,
offset = 0,
limit = 10,
@@ -142,16 +224,14 @@ export const useListProjectGroupUsers = ({
}: {
id: string;
groupSlug: string;
projectId: string;
offset: number;
limit: number;
search: string;
filter?: EFilterReturnedUsers;
filter?: FilterReturnedMachineIdentities;
}) => {
return useQuery({
queryKey: groupKeys.specificProjectGroupUserMemberships({
queryKey: groupKeys.specificGroupIdentitiesMemberships({
slug: groupSlug,
projectId,
offset,
limit,
search,
@@ -163,16 +243,16 @@ export const useListProjectGroupUsers = ({
const params = new URLSearchParams({
offset: String(offset),
limit: String(limit),
search,
...(search && { search }),
...(filter && { filter })
});
const { data } = await apiRequest.get<{ users: TGroupUser[]; totalCount: number }>(
`/api/v1/projects/${projectId}/groups/${id}/users`,
{
params
}
);
const { data } = await apiRequest.get<{
machineIdentities: TGroupMachineIdentity[];
totalCount: number;
}>(`/api/v1/groups/${id}/machine-identities`, {
params
});
return data;
}
@@ -194,7 +274,7 @@ export const useListGroupProjects = ({
search: string;
orderBy?: string;
orderDirection?: OrderByDirection;
filter?: EFilterReturnedProjects;
filter?: FilterReturnedProjects;
}) => {
return useQuery({
queryKey: groupKeys.specificGroupProjects({
@@ -212,7 +292,7 @@ export const useListGroupProjects = ({
const params = new URLSearchParams({
offset: String(offset),
limit: String(limit),
search,
...(search && { search }),
...(filter && { filter }),
...(orderBy && { orderBy }),
...(orderDirection && { orderDirection })

View File

@@ -42,16 +42,59 @@ export type TGroupWithProjectMemberships = {
orgId: string;
};
export enum GroupMemberType {
USER = "user",
MACHINE_IDENTITY = "machineIdentity"
}
export type TGroupUser = {
id: string;
email: string;
username: string;
firstName: string;
lastName: string;
isPartOfGroup: boolean;
joinedGroupAt: Date;
};
export type TGroupMachineIdentity = {
id: string;
name: string;
joinedGroupAt: Date;
};
export type TGroupMemberUser = {
id: string;
joinedGroupAt: Date;
type: GroupMemberType.USER;
user: {
email: string;
username: string;
firstName: string;
lastName: string;
};
};
export type TGroupMemberMachineIdentity = {
id: string;
joinedGroupAt: Date;
type: GroupMemberType.MACHINE_IDENTITY;
machineIdentity: {
id: string;
name: string;
};
};
export type TGroupMember = TGroupMemberUser | TGroupMemberMachineIdentity;
export enum GroupMembersOrderBy {
Name = "name"
}
export enum FilterMemberType {
USERS = "users",
MACHINE_IDENTITIES = "machineIdentities"
}
export type TGroupProject = {
id: string;
name: string;
@@ -61,12 +104,17 @@ export type TGroupProject = {
joinedGroupAt: Date;
};
export enum EFilterReturnedUsers {
export enum FilterReturnedUsers {
EXISTING_MEMBERS = "existingMembers",
NON_MEMBERS = "nonMembers"
}
export enum EFilterReturnedProjects {
export enum FilterReturnedMachineIdentities {
ASSIGNED_MACHINE_IDENTITIES = "assignedMachineIdentities",
NON_ASSIGNED_MACHINE_IDENTITIES = "nonAssignedMachineIdentities"
}
export enum FilterReturnedProjects {
ASSIGNED_PROJECTS = "assignedProjects",
UNASSIGNED_PROJECTS = "unassignedProjects"
}

View File

@@ -62,7 +62,13 @@ export const SignUpPage = () => {
navigate({ to: "/admin" });
};
if (config?.initialized) return <ContentLoader text="Redirecting to admin page..." />;
if (config?.initialized) {
return (
<div className="flex min-h-screen flex-col justify-center bg-linear-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700">
<ContentLoader text="Redirecting to admin page..." />
</div>
);
}
return (
<div className="flex max-h-screen min-h-screen flex-col justify-center overflow-y-auto bg-linear-to-tr from-mineshaft-600 via-mineshaft-800 to-bunker-700 px-6">

View File

@@ -1,39 +1,35 @@
import { useState } from "react";
import { faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons";
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { HardDriveIcon, UserIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
EmptyState,
Input,
Modal,
ModalContent,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context";
import { useDebounce, useResetPageHelper } from "@app/hooks";
import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api";
import { EFilterReturnedUsers } from "@app/hooks/api/groups/types";
import { Button, Input, Modal, ModalContent, Tooltip } from "@app/components/v2";
import { useDebounce } from "@app/hooks";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { AddGroupIdentitiesTab, AddGroupUsersTab } from "./AddGroupMemberModalTabs";
enum AddMemberType {
Users = "users",
MachineIdentities = "machineIdentities"
}
type Props = {
popUp: UsePopUpState<["addGroupMembers"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["addGroupMembers"]>, state?: boolean) => void;
isOidcManageGroupMembershipsEnabled: boolean;
};
export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
export const AddGroupMembersModal = ({
popUp,
handlePopUpToggle,
isOidcManageGroupMembershipsEnabled
}: Props) => {
const [addMemberType, setAddMemberType] = useState<AddMemberType>(
isOidcManageGroupMembershipsEnabled ? AddMemberType.MachineIdentities : AddMemberType.Users
);
const [searchMemberFilter, setSearchMemberFilter] = useState("");
const [debouncedSearch] = useDebounce(searchMemberFilter);
@@ -42,47 +38,6 @@ export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
slug: string;
};
const offset = (page - 1) * perPage;
const { data, isPending } = useListGroupUsers({
id: popUpData?.groupId,
groupSlug: popUpData?.slug,
offset,
limit: perPage,
search: debouncedSearch,
filter: EFilterReturnedUsers.NON_MEMBERS
});
const { totalCount = 0 } = data ?? {};
useResetPageHelper({
totalCount,
offset,
setPage
});
const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup();
const handleAddMember = async (username: string) => {
if (!popUpData?.slug) {
createNotification({
text: "Some data is missing, please refresh the page and try again",
type: "error"
});
return;
}
await addUserToGroupMutateAsync({
groupId: popUpData.groupId,
username,
slug: popUpData.slug
});
createNotification({
text: "Successfully assigned user to the group",
type: "success"
});
};
return (
<Modal
isOpen={popUp?.addGroupMembers?.isOpen}
@@ -91,74 +46,78 @@ export const AddGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
}}
>
<ModalContent title="Add Group Members">
<Input
value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>User</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={2} innerKey="group-users" />}
{!isPending &&
data?.users?.map(({ id, firstName, lastName, username }) => {
return (
<Tr className="items-center" key={`group-user-${id}`}>
<Td>
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
<p>{username}</p>
</Td>
<Td className="flex justify-end">
<OrgPermissionCan
I={OrgPermissionGroupActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => {
return (
<Button
isLoading={isPending}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="submit"
onClick={() => handleAddMember(username)}
>
Assign
</Button>
);
}}
</OrgPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
<div className="mx-auto flex w-3/4 gap-x-0.5 rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
<Tooltip
className="text-center"
content={
isOidcManageGroupMembershipsEnabled
? "OIDC Group Membership Mapping Enabled. Assign users to this group in your OIDC provider."
: undefined
}
>
<div className="flex-1">
<Button
variant="outline_bg"
onClick={() => {
setAddMemberType(AddMemberType.Users);
}}
size="xs"
isDisabled={isOidcManageGroupMembershipsEnabled}
className={twMerge(
"w-full min-w-[2.4rem] rounded border-none hover:bg-mineshaft-600",
addMemberType === AddMemberType.Users ? "bg-mineshaft-500" : "bg-transparent"
)}
>
<div className="flex items-center gap-2">
<UserIcon size={16} />
Users
</div>
</Button>
</div>
</Tooltip>
<Button
variant="outline_bg"
onClick={() => {
setAddMemberType(AddMemberType.MachineIdentities);
}}
size="xs"
className={twMerge(
"min-w-[2.4rem] flex-1 rounded border-none hover:bg-mineshaft-600",
addMemberType === AddMemberType.MachineIdentities
? "bg-mineshaft-500"
: "bg-transparent"
)}
>
<div className="flex items-center gap-2">
<HardDriveIcon size={16} />
Machine Identities
</div>
</Button>
</div>
<div className="mt-4 mb-4 flex items-center justify-center gap-x-2">
<Input
value={searchMemberFilter}
onChange={(e) => setSearchMemberFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
</div>
{addMemberType === AddMemberType.Users &&
popUpData &&
!isOidcManageGroupMembershipsEnabled && (
<AddGroupUsersTab
groupId={popUpData.groupId}
groupSlug={popUpData.slug}
search={debouncedSearch}
/>
)}
{!isPending && !data?.users?.length && (
<EmptyState
title={
debouncedSearch ? "No users match search" : "All users are already in the group"
}
icon={faUsers}
/>
)}
</TableContainer>
{addMemberType === AddMemberType.MachineIdentities && popUpData && (
<AddGroupIdentitiesTab
groupId={popUpData.groupId}
groupSlug={popUpData.slug}
search={debouncedSearch}
/>
)}
</ModalContent>
</Modal>
);

View File

@@ -0,0 +1,143 @@
import { useState } from "react";
import { faServer } from "@fortawesome/free-solid-svg-icons";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
EmptyState,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context";
import { useResetPageHelper } from "@app/hooks";
import { useAddIdentityToGroup, useListGroupMachineIdentities } from "@app/hooks/api";
import {
FilterReturnedMachineIdentities,
TGroupMachineIdentity
} from "@app/hooks/api/groups/types";
type Props = {
groupId: string;
groupSlug: string;
search: string;
};
export const AddGroupIdentitiesTab = ({ groupId, groupSlug, search }: Props) => {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const offset = (page - 1) * perPage;
const { data, isPending } = useListGroupMachineIdentities({
id: groupId,
groupSlug,
offset,
limit: perPage,
search,
filter: FilterReturnedMachineIdentities.NON_ASSIGNED_MACHINE_IDENTITIES
});
const { totalCount = 0 } = data ?? {};
useResetPageHelper({
totalCount,
offset,
setPage
});
const { mutateAsync: addIdentityToGroupMutateAsync } = useAddIdentityToGroup();
const handleAddIdentity = async (identityId: string) => {
if (!groupSlug) {
createNotification({
text: "Some data is missing, please refresh the page and try again",
type: "error"
});
return;
}
await addIdentityToGroupMutateAsync({
groupId,
identityId,
slug: groupSlug
});
createNotification({
text: "Successfully assigned machine identity to the group",
type: "success"
});
};
return (
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>Machine Identity</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={2} innerKey="group-identities" />}
{!isPending &&
data?.machineIdentities?.map((identity: TGroupMachineIdentity) => {
return (
<Tr className="items-center" key={`group-identity-${identity.id}`}>
<Td>
<p>{identity.name}</p>
</Td>
<Td className="flex justify-end">
<OrgPermissionCan
I={OrgPermissionGroupActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => {
return (
<Button
isLoading={isPending}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="submit"
onClick={() => handleAddIdentity(identity.id)}
>
Assign
</Button>
);
}}
</OrgPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isPending && !data?.machineIdentities?.length && (
<EmptyState
title={
search
? "No machine identities match search"
: "All machine identities are already in the group"
}
icon={faServer}
/>
)}
</TableContainer>
);
};

View File

@@ -0,0 +1,137 @@
import { useState } from "react";
import { faUsers } from "@fortawesome/free-solid-svg-icons";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
EmptyState,
Pagination,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context";
import { useResetPageHelper } from "@app/hooks";
import { useAddUserToGroup, useListGroupUsers } from "@app/hooks/api";
import { FilterReturnedUsers } from "@app/hooks/api/groups/types";
type Props = {
groupId: string;
groupSlug: string;
search: string;
};
export const AddGroupUsersTab = ({ groupId, groupSlug, search }: Props) => {
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(10);
const offset = (page - 1) * perPage;
const { data, isPending } = useListGroupUsers({
id: groupId,
groupSlug,
offset,
limit: perPage,
search,
filter: FilterReturnedUsers.NON_MEMBERS
});
const { totalCount = 0 } = data ?? {};
useResetPageHelper({
totalCount,
offset,
setPage
});
const { mutateAsync: addUserToGroupMutateAsync } = useAddUserToGroup();
const handleAddUser = async (username: string) => {
if (!groupSlug) {
createNotification({
text: "Some data is missing, please refresh the page and try again",
type: "error"
});
return;
}
await addUserToGroupMutateAsync({
groupId,
username,
slug: groupSlug
});
createNotification({
text: "Successfully assigned user to the group",
type: "success"
});
};
return (
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th>User</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={2} innerKey="group-users" />}
{!isPending &&
data?.users?.map(({ id, firstName, lastName, username }) => {
return (
<Tr className="items-center" key={`group-user-${id}`}>
<Td>
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
<p>{username}</p>
</Td>
<Td className="flex justify-end">
<OrgPermissionCan
I={OrgPermissionGroupActions.Edit}
a={OrgPermissionSubjects.Groups}
>
{(isAllowed) => {
return (
<Button
isLoading={isPending}
isDisabled={!isAllowed}
colorSchema="primary"
variant="outline_bg"
type="submit"
onClick={() => handleAddUser(username)}
>
Assign
</Button>
);
}}
</OrgPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isPending && !data?.users?.length && (
<EmptyState
title={search ? "No users match search" : "All users are already in the group"}
icon={faUsers}
/>
)}
</TableContainer>
);
};

View File

@@ -0,0 +1,2 @@
export { AddGroupIdentitiesTab } from "./AddGroupIdentitiesTab";
export { AddGroupUsersTab } from "./AddGroupUsersTab";

View File

@@ -27,7 +27,7 @@ import {
useAddGroupToWorkspace as useAddProjectToGroup,
useListGroupProjects
} from "@app/hooks/api";
import { EFilterReturnedProjects } from "@app/hooks/api/groups/types";
import { FilterReturnedProjects } from "@app/hooks/api/groups/types";
import { ProjectType } from "@app/hooks/api/projects/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -57,7 +57,7 @@ export const AddGroupProjectModal = ({ popUp, handlePopUpToggle }: Props) => {
offset,
limit: perPage,
search: debouncedSearch,
filter: EFilterReturnedProjects.UNASSIGNED_PROJECTS
filter: FilterReturnedProjects.UNASSIGNED_PROJECTS
});
const { totalCount = 0 } = data ?? {};

View File

@@ -3,9 +3,14 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { DeleteActionModal, IconButton, Tooltip } from "@app/components/v2";
import { DeleteActionModal, IconButton } from "@app/components/v2";
import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useOidcManageGroupMembershipsEnabled, useRemoveUserFromGroup } from "@app/hooks/api";
import {
useOidcManageGroupMembershipsEnabled,
useRemoveIdentityFromGroup,
useRemoveUserFromGroup
} from "@app/hooks/api";
import { GroupMemberType } from "@app/hooks/api/groups/types";
import { usePopUp } from "@app/hooks/usePopUp";
import { AddGroupMembersModal } from "../AddGroupMemberModal";
@@ -16,6 +21,10 @@ type Props = {
groupSlug: string;
};
type RemoveMemberData =
| { memberType: GroupMemberType.USER; username: string }
| { memberType: GroupMemberType.MACHINE_IDENTITY; identityId: string; name: string };
export const GroupMembersSection = ({ groupId, groupSlug }: Props) => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"addGroupMembers",
@@ -28,52 +37,66 @@ export const GroupMembersSection = ({ groupId, groupSlug }: Props) => {
useOidcManageGroupMembershipsEnabled(currentOrg.id);
const { mutateAsync: removeUserFromGroupMutateAsync } = useRemoveUserFromGroup();
const handleRemoveUserFromGroup = async (username: string) => {
await removeUserFromGroupMutateAsync({
groupId,
username,
slug: groupSlug
});
const { mutateAsync: removeIdentityFromGroupMutateAsync } = useRemoveIdentityFromGroup();
createNotification({
text: `Successfully removed user ${username} from the group`,
type: "success"
});
const handleRemoveMemberFromGroup = async (memberData: RemoveMemberData) => {
if (memberData.memberType === GroupMemberType.USER) {
await removeUserFromGroupMutateAsync({
groupId,
username: memberData.username,
slug: groupSlug
});
createNotification({
text: `Successfully removed user ${memberData.username} from the group`,
type: "success"
});
} else {
await removeIdentityFromGroupMutateAsync({
groupId,
identityId: memberData.identityId,
slug: groupSlug
});
createNotification({
text: `Successfully removed identity ${memberData.name} from the group`,
type: "success"
});
}
handlePopUpToggle("removeMemberFromGroup", false);
};
const getMemberName = (memberData: RemoveMemberData) => {
if (!memberData) return "";
if (memberData.memberType === GroupMemberType.USER) {
return memberData.username;
}
return memberData.name;
};
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">Members</h3>
<h3 className="text-lg font-medium text-mineshaft-100">Group Members</h3>
<OrgPermissionCan I={OrgPermissionGroupActions.Edit} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => (
<Tooltip
className="text-center"
content={
isOidcManageGroupMembershipsEnabled
? "OIDC Group Membership Mapping Enabled. Assign users to this group in your OIDC provider."
: undefined
}
>
<div className="mb-4 flex items-center justify-center">
<IconButton
isDisabled={isOidcManageGroupMembershipsEnabled || !isAllowed}
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("addGroupMembers", {
groupId,
slug: groupSlug
});
}}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
</div>
</Tooltip>
<div className="mb-4 flex items-center justify-center">
<IconButton
isDisabled={!isAllowed}
ariaLabel="copy icon"
variant="plain"
className="group relative"
onClick={() => {
handlePopUpOpen("addGroupMembers", {
groupId,
slug: groupSlug
});
}}
>
<FontAwesomeIcon icon={faPlus} />
</IconButton>
</div>
)}
</OrgPermissionCan>
</div>
@@ -84,21 +107,19 @@ export const GroupMembersSection = ({ groupId, groupSlug }: Props) => {
handlePopUpOpen={handlePopUpOpen}
/>
</div>
<AddGroupMembersModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<AddGroupMembersModal
popUp={popUp}
handlePopUpToggle={handlePopUpToggle}
isOidcManageGroupMembershipsEnabled={isOidcManageGroupMembershipsEnabled}
/>
<DeleteActionModal
isOpen={popUp.removeMemberFromGroup.isOpen}
title={`Are you sure you want to remove ${
(popUp?.removeMemberFromGroup?.data as { username: string })?.username || ""
} from the group?`}
title={`Are you sure you want to remove ${getMemberName(popUp?.removeMemberFromGroup?.data)} from the group?`}
onChange={(isOpen) => handlePopUpToggle("removeMemberFromGroup", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => {
const userData = popUp?.removeMemberFromGroup?.data as {
username: string;
id: string;
};
return handleRemoveUserFromGroup(userData.username);
const memberData = popUp?.removeMemberFromGroup?.data as RemoveMemberData;
return handleRemoveMemberFromGroup(memberData);
}}
/>
</div>

View File

@@ -1,16 +1,25 @@
import { useMemo } from "react";
import { useState } from "react";
import {
faArrowDown,
faArrowUp,
faCheckCircle,
faFilter,
faFolder,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { HardDriveIcon, UserIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { OrgPermissionCan } from "@app/components/permissions";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
@@ -31,12 +40,18 @@ import {
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useListGroupUsers, useOidcManageGroupMembershipsEnabled } from "@app/hooks/api";
import { useOidcManageGroupMembershipsEnabled } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { EFilterReturnedUsers } from "@app/hooks/api/groups/types";
import { useListGroupMembers } from "@app/hooks/api/groups/queries";
import {
FilterMemberType,
GroupMembersOrderBy,
GroupMemberType
} from "@app/hooks/api/groups/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { GroupMembershipRow } from "./GroupMembershipRow";
import { GroupMembershipIdentityRow } from "./GroupMembershipIdentityRow";
import { GroupMembershipUserRow } from "./GroupMembershipUserRow";
type Props = {
groupId: string;
@@ -47,10 +62,6 @@ type Props = {
) => void;
};
enum GroupMembersOrderBy {
Name = "name"
}
export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props) => {
const {
search,
@@ -61,11 +72,14 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props
setPerPage,
offset,
orderDirection,
toggleOrderDirection
toggleOrderDirection,
orderBy
} = usePagination(GroupMembersOrderBy.Name, {
initPerPage: getUserTablePreference("groupMembersTable", PreferenceKey.PerPage, 20)
});
const [memberTypeFilter, setMemberTypeFilter] = useState<FilterMemberType[]>([]);
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("groupMembersTable", PreferenceKey.PerPage, newPerPage);
@@ -76,66 +90,103 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props
const { data: isOidcManageGroupMembershipsEnabled = false } =
useOidcManageGroupMembershipsEnabled(currentOrg.id);
const { data: groupMemberships, isPending } = useListGroupUsers({
const { data: groupMemberships, isPending } = useListGroupMembers({
id: groupId,
groupSlug,
offset,
limit: perPage,
search,
filter: EFilterReturnedUsers.EXISTING_MEMBERS
orderBy,
orderDirection,
memberTypeFilter: memberTypeFilter.length > 0 ? memberTypeFilter : undefined
});
const filteredGroupMemberships = useMemo(() => {
return groupMemberships && groupMemberships?.users
? groupMemberships?.users
?.filter((membership) => {
const userSearchString = `${membership.firstName && membership.firstName} ${
membership.lastName && membership.lastName
} ${membership.email && membership.email} ${
membership.username && membership.username
}`;
return userSearchString.toLowerCase().includes(search.trim().toLowerCase());
})
.sort((a, b) => {
const [membershipOne, membershipTwo] =
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
const membershipOneComparisonString = membershipOne.firstName
? membershipOne.firstName
: membershipOne.email;
const membershipTwoComparisonString = membershipTwo.firstName
? membershipTwo.firstName
: membershipTwo.email;
const comparison = membershipOneComparisonString
.toLowerCase()
.localeCompare(membershipTwoComparisonString.toLowerCase());
return comparison;
})
: [];
}, [groupMemberships, orderDirection, search]);
const { members = [], totalCount = 0 } = groupMemberships ?? {};
useResetPageHelper({
totalCount: filteredGroupMemberships?.length,
totalCount,
offset,
setPage
});
const filterOptions = [
{
icon: <UserIcon size={16} />,
label: "Users",
value: FilterMemberType.USERS
},
{
icon: <HardDriveIcon size={16} />,
label: "Machine Identities",
value: FilterMemberType.MACHINE_IDENTITIES
}
];
return (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search users..."
/>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter Members"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
memberTypeFilter.length > 0 && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent
sideOffset={2}
className="max-h-[70vh] thin-scrollbar overflow-y-auto"
align="end"
>
<DropdownMenuLabel>Filter by Member Type</DropdownMenuLabel>
{filterOptions.map((option) => (
<DropdownMenuItem
key={option.value}
className="flex items-center gap-2"
iconPos="right"
onClick={(e) => {
e.preventDefault();
setMemberTypeFilter((prev) => {
if (prev.includes(option.value)) {
return prev.filter((f) => f !== option.value);
}
return [...prev, option.value];
});
setPage(1);
}}
icon={
memberTypeFilter.includes(option.value) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
>
<div className="flex items-center gap-2">
{option.icon}
{option.label}
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/3">
<Th className="w-5" />
<Th className="w-1/2 pl-2">
<div className="flex items-center">
Name
<IconButton
@@ -150,7 +201,6 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props
</IconButton>
</div>
</Th>
<Th>Email</Th>
<Th>Added On</Th>
<Th className="w-5" />
</Tr>
@@ -158,37 +208,43 @@ export const GroupMembersTable = ({ groupId, groupSlug, handlePopUpOpen }: Props
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="group-user-memberships" />}
{!isPending &&
filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => {
return (
<GroupMembershipRow
groupMemberships?.members?.map((userGroupMembership) => {
return userGroupMembership.type === GroupMemberType.USER ? (
<GroupMembershipUserRow
key={`user-group-membership-${userGroupMembership.id}`}
user={userGroupMembership}
handlePopUpOpen={handlePopUpOpen}
/>
) : (
<GroupMembershipIdentityRow
key={`identity-group-membership-${userGroupMembership.id}`}
identity={userGroupMembership}
handlePopUpOpen={handlePopUpOpen}
/>
);
})}
</TBody>
</Table>
{Boolean(filteredGroupMemberships.length) && (
{Boolean(totalCount) && (
<Pagination
count={filteredGroupMemberships.length}
count={totalCount}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredGroupMemberships?.length && (
{!isPending && !members.length && (
<EmptyState
title={
groupMemberships?.users.length
? "No users match this search..."
groupMemberships?.members.length
? "No members match this search..."
: "This group does not have any members yet"
}
icon={groupMemberships?.users.length ? faSearch : faFolder}
icon={groupMemberships?.members.length ? faSearch : faFolder}
/>
)}
{!groupMemberships?.users.length && (
{!groupMemberships?.members.length && (
<OrgPermissionCan I={OrgPermissionGroupActions.Edit} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => (
<Tooltip

View File

@@ -0,0 +1,90 @@
import { faEllipsisV, faUserMinus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { HardDriveIcon } from "lucide-react";
import { OrgPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context";
import { GroupMemberType, TGroupMemberMachineIdentity } from "@app/hooks/api/groups/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
identity: TGroupMemberMachineIdentity;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeMemberFromGroup"]>,
data?: object
) => void;
};
export const GroupMembershipIdentityRow = ({
identity: {
machineIdentity: { name },
joinedGroupAt,
id
},
handlePopUpOpen
}: Props) => {
return (
<Tr className="items-center" key={`group-user-${id}`}>
<Td className="pr-0">
<HardDriveIcon size={20} />
</Td>
<Td className="pl-2">
<p>{name}</p>
</Td>
<Td>
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
<p className="inline-block">{new Date(joinedGroupAt).toLocaleDateString()}</p>
</Tooltip>
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<OrgPermissionCan I={OrgPermissionGroupActions.Edit} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => {
return (
<div>
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faUserMinus} />}
onClick={() =>
handlePopUpOpen("removeMemberFromGroup", {
memberType: GroupMemberType.MACHINE_IDENTITY,
identityId: id,
name
})
}
isDisabled={!isAllowed}
>
Remove Identity From Group
</DropdownMenuItem>
</div>
);
}}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
};

View File

@@ -1,5 +1,6 @@
import { faEllipsisV, faUserMinus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UserIcon } from "lucide-react";
import { OrgPermissionCan } from "@app/components/permissions";
import {
@@ -14,19 +15,23 @@ import {
} from "@app/components/v2";
import { OrgPermissionGroupActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useOidcManageGroupMembershipsEnabled } from "@app/hooks/api";
import { TGroupUser } from "@app/hooks/api/groups/types";
import { GroupMemberType, TGroupMemberUser } from "@app/hooks/api/groups/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
user: TGroupUser;
user: TGroupMemberUser;
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["removeMemberFromGroup"]>,
data?: object
) => void;
};
export const GroupMembershipRow = ({
user: { firstName, lastName, username, joinedGroupAt, email, id },
export const GroupMembershipUserRow = ({
user: {
user: { firstName, lastName, email, username },
joinedGroupAt,
id
},
handlePopUpOpen
}: Props) => {
const { currentOrg } = useOrganization();
@@ -36,15 +41,18 @@ export const GroupMembershipRow = ({
return (
<Tr className="items-center" key={`group-user-${id}`}>
<Td>
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
<Td className="pr-0">
<UserIcon size={20} />
</Td>
<Td>
<p>{email}</p>
<Td className="pl-2">
<p>
{`${firstName ?? "-"} ${lastName ?? ""}`}{" "}
<span className="text-mineshaft-400">({email})</span>
</p>
</Td>
<Td>
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
<p>{new Date(joinedGroupAt).toLocaleDateString()}</p>
<p className="inline-block">{new Date(joinedGroupAt).toLocaleDateString()}</p>
</Tooltip>
</Td>
<Td>
@@ -75,7 +83,12 @@ export const GroupMembershipRow = ({
<div>
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faUserMinus} />}
onClick={() => handlePopUpOpen("removeMemberFromGroup", { username })}
onClick={() =>
handlePopUpOpen("removeMemberFromGroup", {
memberType: GroupMemberType.USER,
username
})
}
isDisabled={!isAllowed || isOidcManageGroupMembershipsEnabled}
>
Remove User From Group

View File

@@ -41,7 +41,7 @@ export const GroupProjectsSection = ({ groupId, groupSlug }: Props) => {
return (
<div className="w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-medium text-mineshaft-100">Projects</h3>
<h3 className="text-lg font-medium text-mineshaft-100">Group Projects</h3>
<OrgPermissionCan I={OrgPermissionGroupActions.Edit} a={OrgPermissionSubjects.Groups}>
{(isAllowed) => (
<IconButton

View File

@@ -31,7 +31,7 @@ import {
import { usePagination, useResetPageHelper } from "@app/hooks";
import { useListGroupProjects } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { EFilterReturnedProjects } from "@app/hooks/api/groups/types";
import { FilterReturnedProjects } from "@app/hooks/api/groups/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { GroupProjectRow } from "./GroupProjectRow";
@@ -78,7 +78,7 @@ export const GroupProjectsTable = ({ groupId, groupSlug, handlePopUpOpen }: Prop
search: debouncedSearch,
orderBy,
orderDirection,
filter: EFilterReturnedProjects.ASSIGNED_PROJECTS
filter: FilterReturnedProjects.ASSIGNED_PROJECTS
});
const totalCount = groupMemberships?.totalCount ?? 0;

View File

@@ -16,6 +16,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
@@ -48,6 +49,7 @@ import {
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { ApprovalPolicyType, useCheckPolicyMatch } from "@app/hooks/api/approvalPolicies";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import {
PAM_RESOURCE_TYPE_MAP,
@@ -85,6 +87,7 @@ type Props = {
export const PamAccountsTable = ({ projectId }: Props) => {
const navigate = useNavigate({ from: ROUTE_PATHS.Pam.AccountsPage.path });
const { accessAwsIam, loadingAccountId } = useAccessAwsIamAccount();
const { mutateAsync: checkPolicyMatch } = useCheckPolicyMatch();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"misc",
@@ -232,21 +235,39 @@ export const PamAccountsTable = ({ projectId }: Props) => {
const resources = resourcesData?.resources || [];
function accessAccount(account: TPamAccount) {
const accessAccount = async (account: TPamAccount) => {
let fullAccountPath = account.name;
const folderPath = account.folderId ? folderPaths[account.folderId] : undefined;
if (folderPath) {
fullAccountPath = `${folderPath}/${account.name}`;
}
const { requiresApproval } = await checkPolicyMatch({
policyType: ApprovalPolicyType.PamAccess,
projectId,
inputs: {
accountPath: fullAccountPath
}
});
if (requiresApproval) {
createNotification({
text: "This account is protected by an approval policy, you must request access",
type: "info"
});
// Open request access modal with pre-populated path
handlePopUpOpen("requestAccount", { accountPath: fullAccountPath });
return;
}
// For AWS IAM, directly open console without modal
if (account.resource.resourceType === PamResourceType.AwsIam) {
let fullAccountPath = account?.name;
const folderPath = account.folderId ? folderPaths[account.folderId] : undefined;
if (folderPath) {
const path = folderPath.replace(/^\/+|\/+$/g, "");
fullAccountPath = `${path}/${account?.name}`;
}
accessAwsIam(account, fullAccountPath);
} else {
handlePopUpOpen("accessAccount", account);
}
}
};
return (
<div className="flex flex-col gap-4">
@@ -538,6 +559,7 @@ export const PamAccountsTable = ({ projectId }: Props) => {
<PamRequestAccountAccessModal
isOpen={popUp.requestAccount.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("requestAccount", isOpen)}
accountPath={popUp.requestAccount.data?.accountPath}
/>
<PamDeleteAccountModal
isOpen={popUp.deleteAccount.isOpen}

View File

@@ -1,4 +1,3 @@
import { useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
@@ -18,10 +17,8 @@ import {
import { useProject } from "@app/context";
import { ApprovalPolicyType } from "@app/hooks/api/approvalPolicies";
import { useCreateApprovalRequest } from "@app/hooks/api/approvalRequests/mutations";
import { TPamAccount } from "@app/hooks/api/pam";
type Props = {
account?: TPamAccount;
accountPath?: string;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
@@ -48,24 +45,15 @@ const formSchema = z.object({
type FormData = z.infer<typeof formSchema>;
const Content = ({ onOpenChange, account, accountPath }: Props) => {
const Content = ({ onOpenChange, accountPath }: Props) => {
const { projectId } = useProject();
const { mutateAsync: createApprovalRequest, isPending: isSubmitting } =
useCreateApprovalRequest();
const fullAccountPath = useMemo(() => {
const accountName = account?.name ?? "";
if (accountPath) {
const path = accountPath.replace(/^\/+|\/+$/g, "");
return `${path}/${accountName}`;
}
return accountName;
}, [account, accountPath]);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
accountPath: fullAccountPath,
accountPath,
accessDuration: "4h",
justification: ""
}
@@ -74,7 +62,7 @@ const Content = ({ onOpenChange, account, accountPath }: Props) => {
const {
control,
handleSubmit,
formState: { isDirty }
formState: { isValid }
} = form;
const onSubmit = async (formData: FormData) => {
@@ -155,7 +143,7 @@ const Content = ({ onOpenChange, account, accountPath }: Props) => {
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
isDisabled={isSubmitting || !isValid}
>
Request Access
</Button>

View File

@@ -1,17 +1,26 @@
import { useEffect, useMemo } from "react";
import { useEffect, useState } from "react";
import {
faArrowDown,
faArrowUp,
faCheckCircle,
faFilter,
faFolder,
faMagnifyingGlass,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { HardDriveIcon, UserIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import {
ConfirmActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
@@ -35,19 +44,21 @@ import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { useAssumeProjectPrivileges } from "@app/hooks/api";
import { ActorType } from "@app/hooks/api/auditLogs/enums";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { useListProjectGroupUsers } from "@app/hooks/api/groups/queries";
import { EFilterReturnedUsers, TGroupMembership } from "@app/hooks/api/groups/types";
import { useListGroupMembers } from "@app/hooks/api/groups/queries";
import {
FilterMemberType,
GroupMembersOrderBy,
GroupMemberType,
TGroupMembership
} from "@app/hooks/api/groups/types";
import { GroupMembershipRow } from "./GroupMembershipRow";
import { GroupMembershipIdentityRow } from "./GroupMembershipIdentityRow";
import { GroupMembershipUserRow } from "./GroupMembershipUserRow";
type Props = {
groupMembership: TGroupMembership;
};
enum GroupMembersOrderBy {
Name = "name"
}
export const GroupMembersTable = ({ groupMembership }: Props) => {
const navigate = useNavigate();
const {
@@ -59,7 +70,8 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
setPerPage,
offset,
orderDirection,
toggleOrderDirection
toggleOrderDirection,
orderBy
} = usePagination(GroupMembersOrderBy.Name, {
initPerPage: getUserTablePreference("projectGroupMembersTable", PreferenceKey.PerPage, 20)
});
@@ -79,6 +91,8 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
}
}, [username]);
const [memberTypeFilter, setMemberTypeFilter] = useState<FilterMemberType[]>([]);
const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["assumePrivileges"] as const);
const handlePerPageChange = (newPerPage: number) => {
@@ -89,50 +103,21 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
const { currentOrg } = useOrganization();
const { currentProject } = useProject();
const { data: groupMemberships, isPending } = useListProjectGroupUsers({
const { data: groupMemberships, isPending } = useListGroupMembers({
id: groupMembership.group.id,
groupSlug: groupMembership.group.slug,
projectId: currentProject.id,
offset,
limit: perPage,
search,
filter: EFilterReturnedUsers.EXISTING_MEMBERS
orderBy,
orderDirection,
memberTypeFilter: memberTypeFilter.length > 0 ? memberTypeFilter : undefined
});
const filteredGroupMemberships = useMemo(() => {
return groupMemberships && groupMemberships?.users
? groupMemberships?.users
?.filter((membership) => {
const userSearchString = `${membership.firstName && membership.firstName} ${
membership.lastName && membership.lastName
} ${membership.email && membership.email} ${
membership.username && membership.username
}`;
return userSearchString.toLowerCase().includes(search.trim().toLowerCase());
})
.sort((a, b) => {
const [membershipOne, membershipTwo] =
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
const membershipOneComparisonString = membershipOne.firstName
? membershipOne.firstName
: membershipOne.email;
const membershipTwoComparisonString = membershipTwo.firstName
? membershipTwo.firstName
: membershipTwo.email;
const comparison = membershipOneComparisonString
.toLowerCase()
.localeCompare(membershipTwoComparisonString.toLowerCase());
return comparison;
})
: [];
}, [groupMemberships, orderDirection, search]);
const { members = [], totalCount = 0 } = groupMemberships ?? {};
useResetPageHelper({
totalCount: filteredGroupMemberships?.length,
totalCount,
offset,
setPage
});
@@ -140,18 +125,24 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
const assumePrivileges = useAssumeProjectPrivileges();
const handleAssumePrivileges = async () => {
const { userId } = popUp?.assumePrivileges?.data as { userId: string };
const { actorId, actorType } = popUp?.assumePrivileges?.data as {
actorId: string;
actorType: ActorType;
};
assumePrivileges.mutate(
{
actorId: userId,
actorType: ActorType.USER,
actorId,
actorType,
projectId: currentProject.id
},
{
onSuccess: () => {
createNotification({
type: "success",
text: "User privilege assumption has started"
text:
actorType === ActorType.IDENTITY
? "Machine identity privilege assumption has started"
: "User privilege assumption has started"
});
const url = getProjectHomePage(currentProject.type, currentProject.environments);
@@ -163,19 +154,84 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
);
};
const filterOptions = [
{
icon: <UserIcon size={16} />,
label: "Users",
value: FilterMemberType.USERS
},
{
icon: <HardDriveIcon size={16} />,
label: "Machine Identities",
value: FilterMemberType.MACHINE_IDENTITIES
}
];
return (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search users..."
/>
<div className="flex gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..."
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Filter Members"
variant="plain"
size="sm"
className={twMerge(
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
memberTypeFilter.length > 0 && "border-primary/50 text-primary"
)}
>
<FontAwesomeIcon icon={faFilter} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent
sideOffset={2}
className="max-h-[70vh] thin-scrollbar overflow-y-auto"
align="end"
>
<DropdownMenuLabel>Filter by Member Type</DropdownMenuLabel>
{filterOptions.map((option) => (
<DropdownMenuItem
key={option.value}
className="flex items-center gap-2"
iconPos="right"
onClick={(e) => {
e.preventDefault();
setMemberTypeFilter((prev) => {
if (prev.includes(option.value)) {
return prev.filter((f) => f !== option.value);
}
return [...prev, option.value];
});
setPage(1);
}}
icon={
memberTypeFilter.includes(option.value) && (
<FontAwesomeIcon className="text-primary" icon={faCheckCircle} />
)
}
>
<div className="flex items-center gap-2">
{option.icon}
{option.label}
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<TableContainer className="mt-4">
<Table>
<THead>
<Tr>
<Th className="w-1/3">
<Th className="w-5" />
<Th className="w-1/2 pl-2">
<div className="flex items-center">
Name
<IconButton
@@ -190,7 +246,6 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
</IconButton>
</div>
</Th>
<Th>Email</Th>
<Th>Added On</Th>
<Th className="w-5" />
</Tr>
@@ -198,42 +253,58 @@ export const GroupMembersTable = ({ groupMembership }: Props) => {
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="group-user-memberships" />}
{!isPending &&
filteredGroupMemberships.slice(offset, perPage * page).map((userGroupMembership) => {
return (
<GroupMembershipRow
groupMemberships?.members?.map((userGroupMembership) => {
return userGroupMembership.type === GroupMemberType.USER ? (
<GroupMembershipUserRow
key={`user-group-membership-${userGroupMembership.id}`}
user={userGroupMembership}
onAssumePrivileges={(userId) => handlePopUpOpen("assumePrivileges", { userId })}
onAssumePrivileges={(userId) =>
handlePopUpOpen("assumePrivileges", {
actorId: userId,
actorType: ActorType.USER
})
}
/>
) : (
<GroupMembershipIdentityRow
key={`identity-group-membership-${userGroupMembership.id}`}
identity={userGroupMembership}
onAssumePrivileges={(identityId) =>
handlePopUpOpen("assumePrivileges", {
actorId: identityId,
actorType: ActorType.IDENTITY
})
}
/>
);
})}
</TBody>
</Table>
{Boolean(filteredGroupMemberships.length) && (
{Boolean(totalCount) && (
<Pagination
count={filteredGroupMemberships.length}
count={totalCount}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && !filteredGroupMemberships?.length && (
{!isPending && !members.length && (
<EmptyState
title={
groupMemberships?.users.length
? "No users match this search..."
groupMemberships?.members.length
? "No members match this search..."
: "This group does not have any members yet"
}
icon={groupMemberships?.users.length ? faSearch : faFolder}
icon={groupMemberships?.members.length ? faSearch : faFolder}
/>
)}
</TableContainer>
<ConfirmActionModal
isOpen={popUp.assumePrivileges.isOpen}
confirmKey="assume"
title="Do you want to assume privileges of this user?"
subTitle="This will set your privileges to those of the user for the next hour."
title={`Do you want to assume privileges of this ${popUp.assumePrivileges?.data?.actorType === ActorType.IDENTITY ? "machine identity" : "user"}?`}
subTitle={`This will set your privileges to those of the ${popUp.assumePrivileges?.data?.actorType === ActorType.IDENTITY ? "machine identity" : "user"} for the next hour.`}
onChange={(isOpen) => handlePopUpToggle("assumePrivileges", isOpen)}
onConfirmed={handleAssumePrivileges}
buttonText="Confirm"

View File

@@ -0,0 +1,81 @@
import { faEllipsisV, faUser } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { HardDriveIcon } from "lucide-react";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/context";
import { TGroupMemberMachineIdentity } from "@app/hooks/api/groups/types";
type Props = {
identity: TGroupMemberMachineIdentity;
onAssumePrivileges: (identityId: string) => void;
};
export const GroupMembershipIdentityRow = ({
identity: {
machineIdentity: { name },
joinedGroupAt,
id
},
onAssumePrivileges
}: Props) => {
return (
<Tr className="items-center" key={`group-identity-${id}`}>
<Td className="pr-0">
<HardDriveIcon size={20} />
</Td>
<Td className="pl-2">
<p>{name}</p>
</Td>
<Td>
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
<p className="inline-block">{new Date(joinedGroupAt).toLocaleDateString()}</p>
</Tooltip>
</Td>
<Td>
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionIdentityActions.AssumePrivileges}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => {
return (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faUser} />}
onClick={() => onAssumePrivileges(id)}
isDisabled={!isAllowed}
>
Assume Privileges
</DropdownMenuItem>
);
}}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
};

View File

@@ -1,5 +1,6 @@
import { faEllipsisV, faUser } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UserIcon } from "lucide-react";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
@@ -13,28 +14,35 @@ import {
Tr
} from "@app/components/v2";
import { ProjectPermissionMemberActions, ProjectPermissionSub } from "@app/context";
import { TGroupUser } from "@app/hooks/api/groups/types";
import { TGroupMemberUser } from "@app/hooks/api/groups/types";
type Props = {
user: TGroupUser;
user: TGroupMemberUser;
onAssumePrivileges: (userId: string) => void;
};
export const GroupMembershipRow = ({
user: { firstName, lastName, joinedGroupAt, email, id },
export const GroupMembershipUserRow = ({
user: {
user: { firstName, lastName, email },
joinedGroupAt,
id
},
onAssumePrivileges
}: Props) => {
return (
<Tr className="items-center" key={`group-user-${id}`}>
<Td>
<p>{`${firstName ?? "-"} ${lastName ?? ""}`}</p>
<Td className="pr-0">
<UserIcon size={20} />
</Td>
<Td>
<p>{email}</p>
<Td className="pl-2">
<p>
{`${firstName ?? "-"} ${lastName ?? ""}`}{" "}
<span className="text-mineshaft-400">({email})</span>
</p>
</Td>
<Td>
<Tooltip content={new Date(joinedGroupAt).toLocaleString()}>
<p>{new Date(joinedGroupAt).toLocaleDateString()}</p>
<p className="inline-block">{new Date(joinedGroupAt).toLocaleDateString()}</p>
</Tooltip>
</Td>
<Td>

View File

@@ -52,6 +52,15 @@ export default defineConfig(({ mode }) => {
}
}
},
experimental: {
renderBuiltUrl(filename, { hostType }) {
if (hostType === "js") {
return { runtime: `window.__toCdnUrl(${JSON.stringify(filename)})` };
}
return { relative: true };
}
},
plugins: [
tsconfigPaths(),
nodePolyfills({