Compare commits
74 Commits
daniel/nod
...
misc/terra
Author | SHA1 | Date | |
---|---|---|---|
|
839f0c7e1c | ||
|
2352e29902 | ||
|
4f5c49a529 | ||
|
7107089ad3 | ||
|
967818f57d | ||
|
02111c2dc2 | ||
|
bb4a16cf7c | ||
|
309db49f1b | ||
|
62a582ef17 | ||
|
d6b389760d | ||
|
bd4deb02b0 | ||
|
449e7672f9 | ||
|
31ff6d3c17 | ||
|
cfcc32271f | ||
|
e2ea84f28a | ||
|
6885ef2e54 | ||
|
8fa9f476e3 | ||
|
1cf8d1e3fa | ||
|
9f61177b62 | ||
|
59b8e83476 | ||
|
eee4d00a08 | ||
|
51c0598b50 | ||
|
69311f058b | ||
|
0f70c3ea9a | ||
|
b5660c87a0 | ||
|
2a686e65cd | ||
|
2bb0386220 | ||
|
526605a0bb | ||
|
5b9903a226 | ||
|
3fc60bf596 | ||
|
7815d6538f | ||
|
4c4d525655 | ||
|
e44213a8a9 | ||
|
e87656631c | ||
|
e102ccf9f0 | ||
|
8a10af9b62 | ||
|
18308950d1 | ||
|
86a9676a9c | ||
|
aa12a71ff3 | ||
|
aee46d1902 | ||
|
279a1791f6 | ||
|
8d71b295ea | ||
|
f72cedae10 | ||
|
864cf23416 | ||
|
10574bfe26 | ||
|
02085ce902 | ||
|
4eeea0b27c | ||
|
93b7f56337 | ||
|
0fa9fa20bc | ||
|
0a1f25a659 | ||
|
bc74c44f97 | ||
|
c50e325f53 | ||
|
0225e6fabb | ||
|
3caa46ade8 | ||
|
998bbe92f7 | ||
|
c9f6207e32 | ||
|
0564d06923 | ||
|
d0656358a2 | ||
|
040fa511f6 | ||
|
75099f159f | ||
|
e4a83ad2e2 | ||
|
760f9d487c | ||
|
a02e73e2a4 | ||
|
fbebeaf38f | ||
|
97245c740e | ||
|
5a40b5a1cf | ||
|
19e4a6de4d | ||
|
0daca059c7 | ||
|
0fd193f8e0 | ||
|
342c713805 | ||
|
613b97c93d | ||
|
335f3f7d37 | ||
|
b3f0d36ddc | ||
|
dbb8617180 |
@@ -77,6 +77,39 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/entra-id/users",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
body: z.object({
|
||||
tenantId: z.string().min(1).describe("The tenant ID of the Azure Entra ID"),
|
||||
applicationId: z.string().min(1).describe("The application ID of the Azure Entra ID App Registration"),
|
||||
clientSecret: z.string().min(1).describe("The client secret of the Azure Entra ID App Registration")
|
||||
}),
|
||||
response: {
|
||||
200: z
|
||||
.object({
|
||||
name: z.string().min(1).describe("The name of the user"),
|
||||
id: z.string().min(1).describe("The ID of the user"),
|
||||
email: z.string().min(1).describe("The email of the user")
|
||||
})
|
||||
.array()
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const data = await server.services.dynamicSecret.fetchAzureEntraIdUsers({
|
||||
tenantId: req.body.tenantId,
|
||||
applicationId: req.body.applicationId,
|
||||
clientSecret: req.body.clientSecret
|
||||
});
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/:name",
|
||||
@@ -237,7 +270,7 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const dynamicSecretCfgs = await server.services.dynamicSecret.list({
|
||||
const dynamicSecretCfgs = await server.services.dynamicSecret.listDynamicSecretsByEnv({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
|
@@ -10,7 +10,7 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
url: "/",
|
||||
method: "POST",
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
body: z.object({
|
||||
name: z.string().trim().min(1).max(50).describe(GROUPS.CREATE.name),
|
||||
@@ -43,12 +43,59 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/:currentSlug",
|
||||
method: "PATCH",
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
url: "/:id",
|
||||
method: "GET",
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
params: z.object({
|
||||
currentSlug: z.string().trim().describe(GROUPS.UPDATE.currentSlug)
|
||||
id: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: GroupsSchema
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const group = await server.services.group.getGroupById({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
id: req.params.id
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/",
|
||||
method: "GET",
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
response: {
|
||||
200: GroupsSchema.array()
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const groups = await server.services.org.getOrgGroups({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
orgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/:id",
|
||||
method: "PATCH",
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
params: z.object({
|
||||
id: z.string().trim().describe(GROUPS.UPDATE.id)
|
||||
}),
|
||||
body: z
|
||||
.object({
|
||||
@@ -70,7 +117,7 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
handler: async (req) => {
|
||||
const group = await server.services.group.updateGroup({
|
||||
currentSlug: req.params.currentSlug,
|
||||
id: req.params.id,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
@@ -83,12 +130,12 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
});
|
||||
|
||||
server.route({
|
||||
url: "/:slug",
|
||||
url: "/:id",
|
||||
method: "DELETE",
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
params: z.object({
|
||||
slug: z.string().trim().describe(GROUPS.DELETE.slug)
|
||||
id: z.string().trim().describe(GROUPS.DELETE.id)
|
||||
}),
|
||||
response: {
|
||||
200: GroupsSchema
|
||||
@@ -96,7 +143,7 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
handler: async (req) => {
|
||||
const group = await server.services.group.deleteGroup({
|
||||
groupSlug: req.params.slug,
|
||||
id: req.params.id,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
@@ -109,11 +156,11 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:slug/users",
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
url: "/:id/users",
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
params: z.object({
|
||||
slug: z.string().trim().describe(GROUPS.LIST_USERS.slug)
|
||||
id: z.string().trim().describe(GROUPS.LIST_USERS.id)
|
||||
}),
|
||||
querystring: z.object({
|
||||
offset: z.coerce.number().min(0).max(100).default(0).describe(GROUPS.LIST_USERS.offset),
|
||||
@@ -141,24 +188,25 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
handler: async (req) => {
|
||||
const { users, totalCount } = await server.services.group.listGroupUsers({
|
||||
groupSlug: req.params.slug,
|
||||
id: req.params.id,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
...req.query
|
||||
});
|
||||
|
||||
return { users, totalCount };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:slug/users/:username",
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
url: "/:id/users/:username",
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
params: z.object({
|
||||
slug: z.string().trim().describe(GROUPS.ADD_USER.slug),
|
||||
id: z.string().trim().describe(GROUPS.ADD_USER.id),
|
||||
username: z.string().trim().describe(GROUPS.ADD_USER.username)
|
||||
}),
|
||||
response: {
|
||||
@@ -173,7 +221,7 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
handler: async (req) => {
|
||||
const user = await server.services.group.addUserToGroup({
|
||||
groupSlug: req.params.slug,
|
||||
id: req.params.id,
|
||||
username: req.params.username,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
@@ -187,11 +235,11 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/:slug/users/:username",
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
url: "/:id/users/:username",
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
params: z.object({
|
||||
slug: z.string().trim().describe(GROUPS.DELETE_USER.slug),
|
||||
id: z.string().trim().describe(GROUPS.DELETE_USER.id),
|
||||
username: z.string().trim().describe(GROUPS.DELETE_USER.username)
|
||||
}),
|
||||
response: {
|
||||
@@ -206,7 +254,7 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
handler: async (req) => {
|
||||
const user = await server.services.group.removeUserFromGroup({
|
||||
groupSlug: req.params.slug,
|
||||
id: req.params.id,
|
||||
username: req.params.username,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
|
@@ -87,6 +87,12 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* Daniel: This endpoint is no longer is use.
|
||||
* We are keeping it for now because it has been exposed in our public api docs for a while, so by removing it we are likely to break users workflows.
|
||||
*
|
||||
* Please refer to the new endpoint, GET /api/v1/organization/audit-logs, for the same (and more) functionality.
|
||||
*/
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:workspaceId/audit-logs",
|
||||
@@ -101,7 +107,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
workspaceId: z.string().trim().describe(AUDIT_LOGS.EXPORT.workspaceId)
|
||||
workspaceId: z.string().trim().describe(AUDIT_LOGS.EXPORT.projectId)
|
||||
}),
|
||||
querystring: z.object({
|
||||
eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType),
|
||||
@@ -122,10 +128,12 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
})
|
||||
.merge(
|
||||
z.object({
|
||||
project: z.object({
|
||||
name: z.string(),
|
||||
slug: z.string()
|
||||
}),
|
||||
project: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
slug: z.string()
|
||||
})
|
||||
.optional(),
|
||||
event: z.object({
|
||||
type: z.string(),
|
||||
metadata: z.any()
|
||||
|
@@ -3,7 +3,7 @@ import { Knex } from "knex";
|
||||
import { TDbClient } from "@app/db";
|
||||
import { AuditLogsSchema, TableName } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols, stripUndefinedInWhere } from "@app/lib/knex";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueName } from "@app/queue";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
@@ -48,47 +48,61 @@ export const auditLogDALFactory = (db: TDbClient) => {
|
||||
},
|
||||
tx?: Knex
|
||||
) => {
|
||||
if (!orgId && !projectId) {
|
||||
throw new Error("Either orgId or projectId must be provided");
|
||||
}
|
||||
|
||||
try {
|
||||
// Find statements
|
||||
const sqlQuery = (tx || db.replicaNode())(TableName.AuditLog)
|
||||
.where(
|
||||
stripUndefinedInWhere({
|
||||
projectId,
|
||||
[`${TableName.AuditLog}.orgId`]: orgId,
|
||||
userAgentType
|
||||
})
|
||||
)
|
||||
|
||||
.leftJoin(TableName.Project, `${TableName.AuditLog}.projectId`, `${TableName.Project}.id`)
|
||||
// eslint-disable-next-line func-names
|
||||
.where(function () {
|
||||
if (orgId) {
|
||||
void this.where(`${TableName.Project}.orgId`, orgId).orWhere(`${TableName.AuditLog}.orgId`, orgId);
|
||||
} else if (projectId) {
|
||||
void this.where(`${TableName.AuditLog}.projectId`, projectId);
|
||||
}
|
||||
});
|
||||
|
||||
if (userAgentType) {
|
||||
void sqlQuery.where("userAgentType", userAgentType);
|
||||
}
|
||||
|
||||
// Select statements
|
||||
void sqlQuery
|
||||
.select(selectAllTableCols(TableName.AuditLog))
|
||||
|
||||
.select(
|
||||
db.ref("name").withSchema(TableName.Project).as("projectName"),
|
||||
db.ref("slug").withSchema(TableName.Project).as("projectSlug")
|
||||
)
|
||||
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.orderBy(`${TableName.AuditLog}.createdAt`, "desc");
|
||||
|
||||
// Special case: Filter by actor ID
|
||||
if (actorId) {
|
||||
void sqlQuery.whereRaw(`"actorMetadata"->>'userId' = ?`, [actorId]);
|
||||
}
|
||||
|
||||
// Special case: Filter by key/value pairs in eventMetadata field
|
||||
if (eventMetadata && Object.keys(eventMetadata).length) {
|
||||
Object.entries(eventMetadata).forEach(([key, value]) => {
|
||||
void sqlQuery.whereRaw(`"eventMetadata"->>'${key}' = ?`, [value]);
|
||||
});
|
||||
}
|
||||
|
||||
// Filter by actor type
|
||||
if (actorType) {
|
||||
void sqlQuery.where("actor", actorType);
|
||||
}
|
||||
|
||||
// Filter by event types
|
||||
if (eventType?.length) {
|
||||
void sqlQuery.whereIn("eventType", eventType);
|
||||
}
|
||||
|
||||
// Filter by date range
|
||||
if (startDate) {
|
||||
void sqlQuery.where(`${TableName.AuditLog}.createdAt`, ">=", startDate);
|
||||
}
|
||||
@@ -97,13 +111,21 @@ export const auditLogDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
const docs = await sqlQuery;
|
||||
|
||||
return docs.map((doc) => ({
|
||||
...AuditLogsSchema.parse(doc),
|
||||
project: {
|
||||
name: doc.projectName,
|
||||
slug: doc.projectSlug
|
||||
}
|
||||
}));
|
||||
return docs.map((doc) => {
|
||||
// Our type system refuses to acknowledge that the project name and slug are present in the doc, due to the disjointed query structure above.
|
||||
// This is a quick and dirty way to get around the types.
|
||||
const projectDoc = doc as unknown as { projectName: string; projectSlug: string };
|
||||
|
||||
return {
|
||||
...AuditLogsSchema.parse(doc),
|
||||
...(projectDoc?.projectSlug && {
|
||||
project: {
|
||||
name: projectDoc.projectName,
|
||||
slug: projectDoc.projectSlug
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error });
|
||||
}
|
||||
|
@@ -24,6 +24,7 @@ export const auditLogServiceFactory = ({
|
||||
permissionService
|
||||
}: TAuditLogServiceFactoryDep) => {
|
||||
const listAuditLogs = async ({ actorAuthMethod, actorId, actorOrgId, actor, filter }: TListProjectAuditLogDTO) => {
|
||||
// Filter logs for specific project
|
||||
if (filter.projectId) {
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
@@ -34,6 +35,7 @@ export const auditLogServiceFactory = ({
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs);
|
||||
} else {
|
||||
// Organization-wide logs
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
@@ -44,13 +46,12 @@ export const auditLogServiceFactory = ({
|
||||
|
||||
/**
|
||||
* NOTE (dangtony98): Update this to organization-level audit log permission check once audit logs are moved
|
||||
* to the organization level
|
||||
* to the organization level ✅
|
||||
*/
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Member);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs);
|
||||
}
|
||||
|
||||
// If project ID is not provided, then we need to return all the audit logs for the organization itself.
|
||||
|
||||
const auditLogs = await auditLogDAL.find({
|
||||
startDate: filter.startDate,
|
||||
endDate: filter.endDate,
|
||||
|
@@ -1,10 +1,70 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
import { OrderByDirection } from "@app/lib/types";
|
||||
import { SecretsOrderBy } from "@app/services/secret/secret-types";
|
||||
|
||||
export type TDynamicSecretDALFactory = ReturnType<typeof dynamicSecretDALFactory>;
|
||||
|
||||
export const dynamicSecretDALFactory = (db: TDbClient) => {
|
||||
const orm = ormify(db, TableName.DynamicSecret);
|
||||
return orm;
|
||||
|
||||
// find dynamic secrets for multiple environments (folder IDs are cross env, thus need to rank for pagination)
|
||||
const listDynamicSecretsByFolderIds = async (
|
||||
{
|
||||
folderIds,
|
||||
search,
|
||||
limit,
|
||||
offset = 0,
|
||||
orderBy = SecretsOrderBy.Name,
|
||||
orderDirection = OrderByDirection.ASC
|
||||
}: {
|
||||
folderIds: string[];
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: SecretsOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
},
|
||||
tx?: Knex
|
||||
) => {
|
||||
try {
|
||||
const query = (tx || db.replicaNode())(TableName.DynamicSecret)
|
||||
.whereIn("folderId", folderIds)
|
||||
.where((bd) => {
|
||||
if (search) {
|
||||
void bd.whereILike(`${TableName.DynamicSecret}.name`, `%${search}%`);
|
||||
}
|
||||
})
|
||||
.leftJoin(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.DynamicSecret}.folderId`)
|
||||
.leftJoin(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`)
|
||||
.select(
|
||||
selectAllTableCols(TableName.DynamicSecret),
|
||||
db.ref("slug").withSchema(TableName.Environment).as("environment"),
|
||||
db.raw(`DENSE_RANK() OVER (ORDER BY ${TableName.DynamicSecret}."name" ${orderDirection}) as rank`)
|
||||
)
|
||||
.orderBy(`${TableName.DynamicSecret}.${orderBy}`, orderDirection);
|
||||
|
||||
if (limit) {
|
||||
const rankOffset = offset + 1;
|
||||
return await (tx || db)
|
||||
.with("w", query)
|
||||
.select("*")
|
||||
.from<Awaited<typeof query>[number]>("w")
|
||||
.where("w.rank", ">=", rankOffset)
|
||||
.andWhere("w.rank", "<", rankOffset + limit);
|
||||
}
|
||||
|
||||
const dynamicSecrets = await query;
|
||||
|
||||
return dynamicSecrets;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "List dynamic secret multi env" });
|
||||
}
|
||||
};
|
||||
|
||||
return { ...orm, listDynamicSecretsByFolderIds };
|
||||
};
|
||||
|
@@ -6,6 +6,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { OrderByDirection } from "@app/lib/types";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||
|
||||
@@ -17,9 +18,12 @@ import {
|
||||
TCreateDynamicSecretDTO,
|
||||
TDeleteDynamicSecretDTO,
|
||||
TDetailsDynamicSecretDTO,
|
||||
TGetDynamicSecretsCountDTO,
|
||||
TListDynamicSecretsDTO,
|
||||
TListDynamicSecretsMultiEnvDTO,
|
||||
TUpdateDynamicSecretDTO
|
||||
} from "./dynamic-secret-types";
|
||||
import { AzureEntraIDProvider } from "./providers/azure-entra-id";
|
||||
import { DynamicSecretProviders, TDynamicProviderFns } from "./providers/models";
|
||||
|
||||
type TDynamicSecretServiceFactoryDep = {
|
||||
@@ -31,7 +35,7 @@ type TDynamicSecretServiceFactoryDep = {
|
||||
"pruneDynamicSecret" | "unsetLeaseRevocation"
|
||||
>;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath">;
|
||||
folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath" | "findBySecretPathMultiEnv">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
};
|
||||
@@ -300,19 +304,55 @@ export const dynamicSecretServiceFactory = ({
|
||||
return { ...dynamicSecretCfg, inputs: providerInputs };
|
||||
};
|
||||
|
||||
const list = async ({
|
||||
// get unique dynamic secret count across multiple envs
|
||||
const getCountMultiEnv = async ({
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actorId,
|
||||
actor,
|
||||
projectSlug,
|
||||
projectId,
|
||||
path,
|
||||
environmentSlug
|
||||
}: TListDynamicSecretsDTO) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||
environmentSlugs,
|
||||
search
|
||||
}: TListDynamicSecretsMultiEnvDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
const projectId = project.id;
|
||||
// verify user has access to each env in request
|
||||
environmentSlugs.forEach((environmentSlug) =>
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
|
||||
)
|
||||
);
|
||||
|
||||
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environmentSlugs, path);
|
||||
if (!folders.length) throw new BadRequestError({ message: "Folders not found" });
|
||||
|
||||
const dynamicSecretCfg = await dynamicSecretDAL.find(
|
||||
{ $in: { folderId: folders.map((folder) => folder.id) }, $search: search ? { name: `%${search}%` } : undefined },
|
||||
{ countDistinct: "name" }
|
||||
);
|
||||
|
||||
return Number(dynamicSecretCfg[0]?.count ?? 0);
|
||||
};
|
||||
|
||||
// get dynamic secret count for a single env
|
||||
const getDynamicSecretCount = async ({
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actorId,
|
||||
actor,
|
||||
path,
|
||||
environmentSlug,
|
||||
search,
|
||||
projectId
|
||||
}: TGetDynamicSecretsCountDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
@@ -328,15 +368,127 @@ export const dynamicSecretServiceFactory = ({
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
|
||||
if (!folder) throw new BadRequestError({ message: "Folder not found" });
|
||||
|
||||
const dynamicSecretCfg = await dynamicSecretDAL.find({ folderId: folder.id });
|
||||
const dynamicSecretCfg = await dynamicSecretDAL.find(
|
||||
{ folderId: folder.id, $search: search ? { name: `%${search}%` } : undefined },
|
||||
{ count: true }
|
||||
);
|
||||
return Number(dynamicSecretCfg[0]?.count ?? 0);
|
||||
};
|
||||
|
||||
const listDynamicSecretsByEnv = async ({
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actorId,
|
||||
actor,
|
||||
projectSlug,
|
||||
path,
|
||||
environmentSlug,
|
||||
limit,
|
||||
offset,
|
||||
orderBy,
|
||||
orderDirection = OrderByDirection.ASC,
|
||||
search,
|
||||
...params
|
||||
}: TListDynamicSecretsDTO) => {
|
||||
let { projectId } = params;
|
||||
|
||||
if (!projectId) {
|
||||
if (!projectSlug) throw new BadRequestError({ message: "Project ID or slug required" });
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new BadRequestError({ message: "Project not found" });
|
||||
projectId = project.id;
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
|
||||
);
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, path);
|
||||
if (!folder) throw new BadRequestError({ message: "Folder not found" });
|
||||
|
||||
const dynamicSecretCfg = await dynamicSecretDAL.find(
|
||||
{ folderId: folder.id, $search: search ? { name: `%${search}%` } : undefined },
|
||||
{
|
||||
limit,
|
||||
offset,
|
||||
sort: orderBy ? [[orderBy, orderDirection]] : undefined
|
||||
}
|
||||
);
|
||||
return dynamicSecretCfg;
|
||||
};
|
||||
|
||||
// get dynamic secrets for multiple envs
|
||||
const listDynamicSecretsByFolderIds = async ({
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actorId,
|
||||
actor,
|
||||
path,
|
||||
environmentSlugs,
|
||||
projectId,
|
||||
...params
|
||||
}: TListDynamicSecretsMultiEnvDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
// verify user has access to each env in request
|
||||
environmentSlugs.forEach((environmentSlug) =>
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment: environmentSlug, secretPath: path })
|
||||
)
|
||||
);
|
||||
|
||||
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environmentSlugs, path);
|
||||
if (!folders.length) throw new BadRequestError({ message: "Folders not found" });
|
||||
|
||||
const dynamicSecretCfg = await dynamicSecretDAL.listDynamicSecretsByFolderIds({
|
||||
folderIds: folders.map((folder) => folder.id),
|
||||
...params
|
||||
});
|
||||
|
||||
return dynamicSecretCfg;
|
||||
};
|
||||
|
||||
const fetchAzureEntraIdUsers = async ({
|
||||
tenantId,
|
||||
applicationId,
|
||||
clientSecret
|
||||
}: {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
clientSecret: string;
|
||||
}) => {
|
||||
const azureEntraIdUsers = await AzureEntraIDProvider().fetchAzureEntraIdUsers(
|
||||
tenantId,
|
||||
applicationId,
|
||||
clientSecret
|
||||
);
|
||||
return azureEntraIdUsers;
|
||||
};
|
||||
|
||||
return {
|
||||
create,
|
||||
updateByName,
|
||||
deleteByName,
|
||||
getDetails,
|
||||
list
|
||||
listDynamicSecretsByEnv,
|
||||
listDynamicSecretsByFolderIds,
|
||||
getDynamicSecretCount,
|
||||
getCountMultiEnv,
|
||||
fetchAzureEntraIdUsers
|
||||
};
|
||||
};
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
|
||||
import { SecretsOrderBy } from "@app/services/secret/secret-types";
|
||||
|
||||
import { DynamicSecretProviderSchema } from "./providers/models";
|
||||
|
||||
@@ -50,5 +51,20 @@ export type TDetailsDynamicSecretDTO = {
|
||||
export type TListDynamicSecretsDTO = {
|
||||
path: string;
|
||||
environmentSlug: string;
|
||||
projectSlug: string;
|
||||
projectSlug?: string;
|
||||
projectId?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
orderBy?: SecretsOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
search?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TListDynamicSecretsMultiEnvDTO = Omit<
|
||||
TListDynamicSecretsDTO,
|
||||
"projectId" | "environmentSlug" | "projectSlug"
|
||||
> & { projectId: string; environmentSlugs: string[] };
|
||||
|
||||
export type TGetDynamicSecretsCountDTO = Omit<TListDynamicSecretsDTO, "projectSlug" | "projectId"> & {
|
||||
projectId: string;
|
||||
};
|
||||
|
@@ -0,0 +1,138 @@
|
||||
import axios from "axios";
|
||||
import { customAlphabet } from "nanoid";
|
||||
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
|
||||
import { AzureEntraIDSchema, TDynamicProviderFns } from "./models";
|
||||
|
||||
const MSFT_GRAPH_API_URL = "https://graph.microsoft.com/v1.0/";
|
||||
const MSFT_LOGIN_URL = "https://login.microsoftonline.com";
|
||||
|
||||
const generatePassword = () => {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
|
||||
return customAlphabet(charset, 64)();
|
||||
};
|
||||
|
||||
type User = { name: string; id: string; email: string };
|
||||
|
||||
export const AzureEntraIDProvider = (): TDynamicProviderFns & {
|
||||
fetchAzureEntraIdUsers: (tenantId: string, applicationId: string, clientSecret: string) => Promise<User[]>;
|
||||
} => {
|
||||
const validateProviderInputs = async (inputs: unknown) => {
|
||||
const providerInputs = await AzureEntraIDSchema.parseAsync(inputs);
|
||||
return providerInputs;
|
||||
};
|
||||
|
||||
const getToken = async (
|
||||
tenantId: string,
|
||||
applicationId: string,
|
||||
clientSecret: string
|
||||
): Promise<{ token?: string; success: boolean }> => {
|
||||
const response = await axios.post<{ access_token: string }>(
|
||||
`${MSFT_LOGIN_URL}/${tenantId}/oauth2/v2.0/token`,
|
||||
{
|
||||
grant_type: "client_credentials",
|
||||
client_id: applicationId,
|
||||
client_secret: clientSecret,
|
||||
scope: "https://graph.microsoft.com/.default"
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
return { token: response.data.access_token, success: true };
|
||||
}
|
||||
return { success: false };
|
||||
};
|
||||
|
||||
const validateConnection = async (inputs: unknown) => {
|
||||
const providerInputs = await validateProviderInputs(inputs);
|
||||
const data = await getToken(providerInputs.tenantId, providerInputs.applicationId, providerInputs.clientSecret);
|
||||
return data.success;
|
||||
};
|
||||
|
||||
const renew = async (inputs: unknown, entityId: string) => {
|
||||
// Do nothing
|
||||
return { entityId };
|
||||
};
|
||||
|
||||
const create = async (inputs: unknown) => {
|
||||
const providerInputs = await validateProviderInputs(inputs);
|
||||
const data = await getToken(providerInputs.tenantId, providerInputs.applicationId, providerInputs.clientSecret);
|
||||
if (!data.success) {
|
||||
throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" });
|
||||
}
|
||||
|
||||
const password = generatePassword();
|
||||
|
||||
const response = await axios.patch(
|
||||
`${MSFT_GRAPH_API_URL}/users/${providerInputs.userId}`,
|
||||
{
|
||||
passwordProfile: {
|
||||
forceChangePasswordNextSignIn: false,
|
||||
password
|
||||
}
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${data.token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
if (response.status !== 204) {
|
||||
throw new BadRequestError({ message: "Failed to update password" });
|
||||
}
|
||||
|
||||
return { entityId: providerInputs.userId, data: { email: providerInputs.email, password } };
|
||||
};
|
||||
|
||||
const revoke = async (inputs: unknown, entityId: string) => {
|
||||
// Creates a new password
|
||||
await create(inputs);
|
||||
return { entityId };
|
||||
};
|
||||
|
||||
const fetchAzureEntraIdUsers = async (tenantId: string, applicationId: string, clientSecret: string) => {
|
||||
const data = await getToken(tenantId, applicationId, clientSecret);
|
||||
if (!data.success) {
|
||||
throw new BadRequestError({ message: "Failed to authorize to Microsoft Entra ID" });
|
||||
}
|
||||
|
||||
const response = await axios.get<{ value: [{ id: string; displayName: string; userPrincipalName: string }] }>(
|
||||
`${MSFT_GRAPH_API_URL}/users`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Bearer ${data.token}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new BadRequestError({ message: "Failed to fetch users" });
|
||||
}
|
||||
|
||||
const users = response.data.value.map((user) => {
|
||||
return {
|
||||
name: user.displayName,
|
||||
id: user.id,
|
||||
email: user.userPrincipalName
|
||||
};
|
||||
});
|
||||
return users;
|
||||
};
|
||||
|
||||
return {
|
||||
validateProviderInputs,
|
||||
validateConnection,
|
||||
create,
|
||||
revoke,
|
||||
renew,
|
||||
fetchAzureEntraIdUsers
|
||||
};
|
||||
};
|
@@ -1,5 +1,6 @@
|
||||
import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache";
|
||||
import { AwsIamProvider } from "./aws-iam";
|
||||
import { AzureEntraIDProvider } from "./azure-entra-id";
|
||||
import { CassandraProvider } from "./cassandra";
|
||||
import { ElasticSearchProvider } from "./elastic-search";
|
||||
import { DynamicSecretProviders } from "./models";
|
||||
@@ -18,5 +19,6 @@ export const buildDynamicSecretProviders = () => ({
|
||||
[DynamicSecretProviders.MongoAtlas]: MongoAtlasProvider(),
|
||||
[DynamicSecretProviders.MongoDB]: MongoDBProvider(),
|
||||
[DynamicSecretProviders.ElasticSearch]: ElasticSearchProvider(),
|
||||
[DynamicSecretProviders.RabbitMq]: RabbitMqProvider()
|
||||
[DynamicSecretProviders.RabbitMq]: RabbitMqProvider(),
|
||||
[DynamicSecretProviders.AzureEntraID]: AzureEntraIDProvider()
|
||||
});
|
||||
|
@@ -166,6 +166,14 @@ export const DynamicSecretMongoDBSchema = z.object({
|
||||
)
|
||||
});
|
||||
|
||||
export const AzureEntraIDSchema = z.object({
|
||||
tenantId: z.string().trim().min(1),
|
||||
userId: z.string().trim().min(1),
|
||||
email: z.string().trim().min(1),
|
||||
applicationId: z.string().trim().min(1),
|
||||
clientSecret: z.string().trim().min(1)
|
||||
});
|
||||
|
||||
export enum DynamicSecretProviders {
|
||||
SqlDatabase = "sql-database",
|
||||
Cassandra = "cassandra",
|
||||
@@ -175,7 +183,8 @@ export enum DynamicSecretProviders {
|
||||
MongoAtlas = "mongo-db-atlas",
|
||||
ElasticSearch = "elastic-search",
|
||||
MongoDB = "mongo-db",
|
||||
RabbitMq = "rabbit-mq"
|
||||
RabbitMq = "rabbit-mq",
|
||||
AzureEntraID = "azure-entra-id"
|
||||
}
|
||||
|
||||
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
|
||||
@@ -187,7 +196,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal(DynamicSecretProviders.MongoAtlas), inputs: DynamicSecretMongoAtlasSchema }),
|
||||
z.object({ type: z.literal(DynamicSecretProviders.ElasticSearch), inputs: DynamicSecretElasticSearchSchema }),
|
||||
z.object({ type: z.literal(DynamicSecretProviders.MongoDB), inputs: DynamicSecretMongoDBSchema }),
|
||||
z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema })
|
||||
z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema }),
|
||||
z.object({ type: z.literal(DynamicSecretProviders.AzureEntraID), inputs: AzureEntraIDSchema })
|
||||
]);
|
||||
|
||||
export type TDynamicProviderFns = {
|
||||
|
@@ -3,7 +3,7 @@ import slugify from "@sindresorhus/slugify";
|
||||
|
||||
import { OrgMembershipRole, TOrgRoles } from "@app/db/schemas";
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
|
||||
import { TOrgDALFactory } from "@app/services/org/org-dal";
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
TAddUserToGroupDTO,
|
||||
TCreateGroupDTO,
|
||||
TDeleteGroupDTO,
|
||||
TGetGroupByIdDTO,
|
||||
TListGroupUsersDTO,
|
||||
TRemoveUserFromGroupDTO,
|
||||
TUpdateGroupDTO
|
||||
@@ -29,7 +30,7 @@ import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal";
|
||||
|
||||
type TGroupServiceFactoryDep = {
|
||||
userDAL: Pick<TUserDALFactory, "find" | "findUserEncKeyByUserIdsBatch" | "transaction" | "findOne">;
|
||||
groupDAL: Pick<TGroupDALFactory, "create" | "findOne" | "update" | "delete" | "findAllGroupMembers">;
|
||||
groupDAL: Pick<TGroupDALFactory, "create" | "findOne" | "update" | "delete" | "findAllGroupMembers" | "findById">;
|
||||
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
|
||||
orgDAL: Pick<TOrgDALFactory, "findMembership" | "countAllOrgMembers">;
|
||||
userGroupMembershipDAL: Pick<
|
||||
@@ -95,7 +96,7 @@ export const groupServiceFactory = ({
|
||||
};
|
||||
|
||||
const updateGroup = async ({
|
||||
currentSlug,
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
role,
|
||||
@@ -121,8 +122,10 @@ export const groupServiceFactory = ({
|
||||
message: "Failed to update group due to plan restrictio Upgrade plan to update group."
|
||||
});
|
||||
|
||||
const group = await groupDAL.findOne({ orgId: actorOrgId, slug: currentSlug });
|
||||
if (!group) throw new BadRequestError({ message: `Failed to find group with slug ${currentSlug}` });
|
||||
const group = await groupDAL.findOne({ orgId: actorOrgId, id });
|
||||
if (!group) {
|
||||
throw new BadRequestError({ message: `Failed to find group with ID ${id}` });
|
||||
}
|
||||
|
||||
let customRole: TOrgRoles | undefined;
|
||||
if (role) {
|
||||
@@ -140,8 +143,7 @@ export const groupServiceFactory = ({
|
||||
|
||||
const [updatedGroup] = await groupDAL.update(
|
||||
{
|
||||
orgId: actorOrgId,
|
||||
slug: currentSlug
|
||||
id: group.id
|
||||
},
|
||||
{
|
||||
name,
|
||||
@@ -158,7 +160,7 @@ export const groupServiceFactory = ({
|
||||
return updatedGroup;
|
||||
};
|
||||
|
||||
const deleteGroup = async ({ groupSlug, actor, actorId, actorAuthMethod, actorOrgId }: TDeleteGroupDTO) => {
|
||||
const deleteGroup = async ({ id, actor, actorId, actorAuthMethod, actorOrgId }: TDeleteGroupDTO) => {
|
||||
if (!actorOrgId) throw new BadRequestError({ message: "Failed to create group without organization" });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
@@ -178,15 +180,39 @@ export const groupServiceFactory = ({
|
||||
});
|
||||
|
||||
const [group] = await groupDAL.delete({
|
||||
orgId: actorOrgId,
|
||||
slug: groupSlug
|
||||
id,
|
||||
orgId: actorOrgId
|
||||
});
|
||||
|
||||
return group;
|
||||
};
|
||||
|
||||
const getGroupById = async ({ id, actor, actorId, actorAuthMethod, actorOrgId }: TGetGroupByIdDTO) => {
|
||||
if (!actorOrgId) {
|
||||
throw new BadRequestError({ message: "Failed to read group without organization" });
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Groups);
|
||||
|
||||
const group = await groupDAL.findById(id);
|
||||
if (!group) {
|
||||
throw new NotFoundError({
|
||||
message: `Cannot find group with ID ${id}`
|
||||
});
|
||||
}
|
||||
|
||||
return group;
|
||||
};
|
||||
|
||||
const listGroupUsers = async ({
|
||||
groupSlug,
|
||||
id,
|
||||
offset,
|
||||
limit,
|
||||
username,
|
||||
@@ -208,12 +234,12 @@ export const groupServiceFactory = ({
|
||||
|
||||
const group = await groupDAL.findOne({
|
||||
orgId: actorOrgId,
|
||||
slug: groupSlug
|
||||
id
|
||||
});
|
||||
|
||||
if (!group)
|
||||
throw new BadRequestError({
|
||||
message: `Failed to find group with slug ${groupSlug}`
|
||||
message: `Failed to find group with ID ${id}`
|
||||
});
|
||||
|
||||
const users = await groupDAL.findAllGroupMembers({
|
||||
@@ -229,14 +255,7 @@ export const groupServiceFactory = ({
|
||||
return { users, totalCount: count };
|
||||
};
|
||||
|
||||
const addUserToGroup = async ({
|
||||
groupSlug,
|
||||
username,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TAddUserToGroupDTO) => {
|
||||
const addUserToGroup = async ({ id, username, actor, actorId, actorAuthMethod, actorOrgId }: TAddUserToGroupDTO) => {
|
||||
if (!actorOrgId) throw new BadRequestError({ message: "Failed to create group without organization" });
|
||||
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
@@ -251,12 +270,12 @@ export const groupServiceFactory = ({
|
||||
// check if group with slug exists
|
||||
const group = await groupDAL.findOne({
|
||||
orgId: actorOrgId,
|
||||
slug: groupSlug
|
||||
id
|
||||
});
|
||||
|
||||
if (!group)
|
||||
throw new BadRequestError({
|
||||
message: `Failed to find group with slug ${groupSlug}`
|
||||
message: `Failed to find group with ID ${id}`
|
||||
});
|
||||
|
||||
const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId);
|
||||
@@ -285,7 +304,7 @@ export const groupServiceFactory = ({
|
||||
};
|
||||
|
||||
const removeUserFromGroup = async ({
|
||||
groupSlug,
|
||||
id,
|
||||
username,
|
||||
actor,
|
||||
actorId,
|
||||
@@ -306,12 +325,12 @@ export const groupServiceFactory = ({
|
||||
// check if group with slug exists
|
||||
const group = await groupDAL.findOne({
|
||||
orgId: actorOrgId,
|
||||
slug: groupSlug
|
||||
id
|
||||
});
|
||||
|
||||
if (!group)
|
||||
throw new BadRequestError({
|
||||
message: `Failed to find group with slug ${groupSlug}`
|
||||
message: `Failed to find group with ID ${id}`
|
||||
});
|
||||
|
||||
const { permission: groupRolePermission } = await permissionService.getOrgPermissionByRole(group.role, actorOrgId);
|
||||
@@ -342,6 +361,7 @@ export const groupServiceFactory = ({
|
||||
deleteGroup,
|
||||
listGroupUsers,
|
||||
addUserToGroup,
|
||||
removeUserFromGroup
|
||||
removeUserFromGroup,
|
||||
getGroupById
|
||||
};
|
||||
};
|
||||
|
@@ -17,7 +17,7 @@ export type TCreateGroupDTO = {
|
||||
} & TGenericPermission;
|
||||
|
||||
export type TUpdateGroupDTO = {
|
||||
currentSlug: string;
|
||||
id: string;
|
||||
} & Partial<{
|
||||
name: string;
|
||||
slug: string;
|
||||
@@ -26,23 +26,27 @@ export type TUpdateGroupDTO = {
|
||||
TGenericPermission;
|
||||
|
||||
export type TDeleteGroupDTO = {
|
||||
groupSlug: string;
|
||||
id: string;
|
||||
} & TGenericPermission;
|
||||
|
||||
export type TGetGroupByIdDTO = {
|
||||
id: string;
|
||||
} & TGenericPermission;
|
||||
|
||||
export type TListGroupUsersDTO = {
|
||||
groupSlug: string;
|
||||
id: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
username?: string;
|
||||
} & TGenericPermission;
|
||||
|
||||
export type TAddUserToGroupDTO = {
|
||||
groupSlug: string;
|
||||
id: string;
|
||||
username: string;
|
||||
} & TGenericPermission;
|
||||
|
||||
export type TRemoveUserFromGroupDTO = {
|
||||
groupSlug: string;
|
||||
id: string;
|
||||
username: string;
|
||||
} & TGenericPermission;
|
||||
|
||||
|
@@ -25,7 +25,8 @@ export enum OrgPermissionSubjects {
|
||||
SecretScanning = "secret-scanning",
|
||||
Identity = "identity",
|
||||
Kms = "kms",
|
||||
AdminConsole = "organization-admin-console"
|
||||
AdminConsole = "organization-admin-console",
|
||||
AuditLogs = "audit-logs"
|
||||
}
|
||||
|
||||
export type OrgPermissionSet =
|
||||
@@ -43,6 +44,7 @@ export type OrgPermissionSet =
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Identity]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs]
|
||||
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole];
|
||||
|
||||
const buildAdminPermission = () => {
|
||||
@@ -111,6 +113,11 @@ const buildAdminPermission = () => {
|
||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Kms);
|
||||
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Kms);
|
||||
|
||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs);
|
||||
can(OrgPermissionActions.Create, OrgPermissionSubjects.AuditLogs);
|
||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.AuditLogs);
|
||||
can(OrgPermissionActions.Delete, OrgPermissionSubjects.AuditLogs);
|
||||
|
||||
can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole);
|
||||
|
||||
return rules;
|
||||
@@ -140,6 +147,8 @@ const buildMemberPermission = () => {
|
||||
can(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity);
|
||||
can(OrgPermissionActions.Delete, OrgPermissionSubjects.Identity);
|
||||
|
||||
can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs);
|
||||
|
||||
return rules;
|
||||
};
|
||||
|
||||
|
@@ -346,7 +346,7 @@ export const permissionServiceFactory = ({
|
||||
const isCustomRole = !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole);
|
||||
if (isCustomRole) {
|
||||
const projectRole = await projectRoleDAL.findOne({ slug: role, projectId });
|
||||
if (!projectRole) throw new BadRequestError({ message: "Role not found" });
|
||||
if (!projectRole) throw new BadRequestError({ message: `Role not found: ${role}` });
|
||||
return {
|
||||
permission: buildProjectPermission([
|
||||
{ role: ProjectMembershipRole.Custom, permissions: projectRole.permissions }
|
||||
|
@@ -145,6 +145,8 @@ export const fullProjectPermissionSet: [ProjectPermissionActions, ProjectPermiss
|
||||
[ProjectPermissionActions.Edit, ProjectPermissionSub.Tags],
|
||||
[ProjectPermissionActions.Delete, ProjectPermissionSub.Tags],
|
||||
|
||||
// TODO(Daniel): Remove the audit logs permissions from project-level permissions.
|
||||
// TODO: We haven't done this yet because it might break existing roles, since those roles will become "invalid" since the audit log permission defined on those roles, no longer exist in the project-level defined permissions.
|
||||
[ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs],
|
||||
[ProjectPermissionActions.Create, ProjectPermissionSub.AuditLogs],
|
||||
[ProjectPermissionActions.Edit, ProjectPermissionSub.AuditLogs],
|
||||
|
@@ -5,26 +5,27 @@ export const GROUPS = {
|
||||
role: "The role of the group to create."
|
||||
},
|
||||
UPDATE: {
|
||||
currentSlug: "The current slug of the group to update.",
|
||||
id: "The id of the group to update",
|
||||
name: "The new name of the group to update to.",
|
||||
slug: "The new slug of the group to update to.",
|
||||
role: "The new role of the group to update to."
|
||||
},
|
||||
DELETE: {
|
||||
id: "The id of the group to delete",
|
||||
slug: "The slug of the group to delete"
|
||||
},
|
||||
LIST_USERS: {
|
||||
slug: "The slug of the group to list users for",
|
||||
id: "The id of the group to list users for",
|
||||
offset: "The offset to start from. If you enter 10, it will start from the 10th user.",
|
||||
limit: "The number of users to return.",
|
||||
username: "The username to search for."
|
||||
},
|
||||
ADD_USER: {
|
||||
slug: "The slug of the group to add the user to.",
|
||||
id: "The id of the group to add the user to.",
|
||||
username: "The username of the user to add to the group."
|
||||
},
|
||||
DELETE_USER: {
|
||||
slug: "The slug of the group to remove the user from.",
|
||||
id: "The id of the group to remove the user from.",
|
||||
username: "The username of the user to remove from the group."
|
||||
}
|
||||
} as const;
|
||||
@@ -409,21 +410,21 @@ export const PROJECTS = {
|
||||
secretSnapshotId: "The ID of the snapshot to rollback to."
|
||||
},
|
||||
ADD_GROUP_TO_PROJECT: {
|
||||
projectSlug: "The slug of the project to add the group to.",
|
||||
groupSlug: "The slug of the group to add to the project.",
|
||||
projectId: "The ID of the project to add the group to.",
|
||||
groupId: "The ID of the group to add to the project.",
|
||||
role: "The role for the group to assume in the project."
|
||||
},
|
||||
UPDATE_GROUP_IN_PROJECT: {
|
||||
projectSlug: "The slug of the project to update the group in.",
|
||||
groupSlug: "The slug of the group to update in the project.",
|
||||
projectId: "The ID of the project to update the group in.",
|
||||
groupId: "The ID of the group to update in the project.",
|
||||
roles: "A list of roles to update the group to."
|
||||
},
|
||||
REMOVE_GROUP_FROM_PROJECT: {
|
||||
projectSlug: "The slug of the project to delete the group from.",
|
||||
groupSlug: "The slug of the group to delete from the project."
|
||||
projectId: "The ID of the project to delete the group from.",
|
||||
groupId: "The ID of the group to delete from the project."
|
||||
},
|
||||
LIST_GROUPS_IN_PROJECT: {
|
||||
projectSlug: "The slug of the project to list groups for."
|
||||
projectId: "The ID of the project to list groups for."
|
||||
},
|
||||
LIST_INTEGRATION: {
|
||||
workspaceId: "The ID of the project to list integrations for."
|
||||
@@ -697,11 +698,46 @@ export const SECRET_IMPORTS = {
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const DASHBOARD = {
|
||||
SECRET_OVERVIEW_LIST: {
|
||||
projectId: "The ID of the project to list secrets/folders from.",
|
||||
environments:
|
||||
"The slugs of the environments to list secrets/folders from (comma separated, ie 'environments=dev,staging,prod').",
|
||||
secretPath: "The secret path to list secrets/folders from.",
|
||||
offset: "The offset to start from. If you enter 10, it will start from the 10th secret/folder.",
|
||||
limit: "The number of secrets/folders to return.",
|
||||
orderBy: "The column to order secrets/folders by.",
|
||||
orderDirection: "The direction to order secrets/folders in.",
|
||||
search: "The text string to filter secret keys and folder names by.",
|
||||
includeSecrets: "Whether to include project secrets in the response.",
|
||||
includeFolders: "Whether to include project folders in the response.",
|
||||
includeDynamicSecrets: "Whether to include dynamic project secrets in the response."
|
||||
},
|
||||
SECRET_DETAILS_LIST: {
|
||||
projectId: "The ID of the project to list secrets/folders from.",
|
||||
environment: "The slug of the environment to list secrets/folders from.",
|
||||
secretPath: "The secret path to list secrets/folders from.",
|
||||
offset: "The offset to start from. If you enter 10, it will start from the 10th secret/folder.",
|
||||
limit: "The number of secrets/folders to return.",
|
||||
orderBy: "The column to order secrets/folders by.",
|
||||
orderDirection: "The direction to order secrets/folders in.",
|
||||
search: "The text string to filter secret keys and folder names by.",
|
||||
tags: "The tags to filter secrets by (comma separated, ie 'tags=billing,engineering').",
|
||||
includeSecrets: "Whether to include project secrets in the response.",
|
||||
includeFolders: "Whether to include project folders in the response.",
|
||||
includeImports: "Whether to include project secret imports in the response.",
|
||||
includeDynamicSecrets: "Whether to include dynamic project secrets in the response."
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const AUDIT_LOGS = {
|
||||
EXPORT: {
|
||||
workspaceId: "The ID of the project to export audit logs from.",
|
||||
projectId:
|
||||
"Optionally filter logs by project ID. If not provided, logs from the entire organization will be returned.",
|
||||
eventType: "The type of the event to export.",
|
||||
userAgentType: "Choose which consuming application to export audit logs for.",
|
||||
eventMetadata:
|
||||
"Filter by event metadata key-value pairs. Formatted as `key1=value1,key2=value2`, with comma-separation.",
|
||||
startDate: "The date to start the export from.",
|
||||
endDate: "The date to end the export at.",
|
||||
offset: "The offset to start from. If you enter 10, it will start from the 10th audit log.",
|
||||
|
@@ -51,11 +51,17 @@ export type TFindReturn<TQuery extends Knex.QueryBuilder, TCount extends boolean
|
||||
: unknown)
|
||||
>;
|
||||
|
||||
export type TFindOpt<R extends object = object, TCount extends boolean = boolean> = {
|
||||
export type TFindOpt<
|
||||
R extends object = object,
|
||||
TCount extends boolean = boolean,
|
||||
TCountDistinct extends keyof R | undefined = undefined
|
||||
> = {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: Array<[keyof R, "asc" | "desc"] | [keyof R, "asc" | "desc", "first" | "last"]>;
|
||||
groupBy?: keyof R;
|
||||
count?: TCount;
|
||||
countDistinct?: TCountDistinct;
|
||||
tx?: Knex;
|
||||
};
|
||||
|
||||
@@ -86,13 +92,18 @@ export const ormify = <DbOps extends object, Tname extends keyof Tables>(db: Kne
|
||||
throw new DatabaseError({ error, name: "Find one" });
|
||||
}
|
||||
},
|
||||
find: async <TCount extends boolean = false>(
|
||||
find: async <
|
||||
TCount extends boolean = false,
|
||||
TCountDistinct extends keyof Tables[Tname]["base"] | undefined = undefined
|
||||
>(
|
||||
filter: TFindFilter<Tables[Tname]["base"]>,
|
||||
{ offset, limit, sort, count, tx }: TFindOpt<Tables[Tname]["base"], TCount> = {}
|
||||
{ offset, limit, sort, count, tx, countDistinct }: TFindOpt<Tables[Tname]["base"], TCount, TCountDistinct> = {}
|
||||
) => {
|
||||
try {
|
||||
const query = (tx || db.replicaNode())(tableName).where(buildFindFilter(filter));
|
||||
if (count) {
|
||||
if (countDistinct) {
|
||||
void query.countDistinct(countDistinct);
|
||||
} else if (count) {
|
||||
void query.select(db.raw("COUNT(*) OVER() AS count"));
|
||||
void query.select("*");
|
||||
}
|
||||
@@ -101,7 +112,8 @@ export const ormify = <DbOps extends object, Tname extends keyof Tables>(db: Kne
|
||||
if (sort) {
|
||||
void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls })));
|
||||
}
|
||||
const res = (await query) as TFindReturn<typeof query, TCount>;
|
||||
|
||||
const res = (await query) as TFindReturn<typeof query, TCountDistinct extends undefined ? TCount : true>;
|
||||
return res;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find one" });
|
||||
|
@@ -7,7 +7,11 @@ import {
|
||||
TScanFullRepoEventPayload,
|
||||
TScanPushEventPayload
|
||||
} from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types";
|
||||
import { TSyncSecretsDTO } from "@app/services/secret/secret-types";
|
||||
import {
|
||||
TFailedIntegrationSyncEmailsPayload,
|
||||
TIntegrationSyncPayload,
|
||||
TSyncSecretsDTO
|
||||
} from "@app/services/secret/secret-types";
|
||||
|
||||
export enum QueueName {
|
||||
SecretRotation = "secret-rotation",
|
||||
@@ -42,6 +46,7 @@ export enum QueueJobs {
|
||||
SecWebhook = "secret-webhook-trigger",
|
||||
TelemetryInstanceStats = "telemetry-self-hosted-stats",
|
||||
IntegrationSync = "secret-integration-pull",
|
||||
SendFailedIntegrationSyncEmails = "send-failed-integration-sync-emails",
|
||||
SecretScan = "secret-scan",
|
||||
UpgradeProjectToGhost = "upgrade-project-to-ghost-job",
|
||||
DynamicSecretRevocation = "dynamic-secret-revocation",
|
||||
@@ -88,18 +93,26 @@ export type TQueueJobTypes = {
|
||||
name: QueueJobs.SecWebhook;
|
||||
payload: { projectId: string; environment: string; secretPath: string; depth?: number };
|
||||
};
|
||||
[QueueName.IntegrationSync]: {
|
||||
name: QueueJobs.IntegrationSync;
|
||||
payload: {
|
||||
isManual?: boolean;
|
||||
actorId?: string;
|
||||
projectId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
depth?: number;
|
||||
deDupeQueue?: Record<string, boolean>;
|
||||
};
|
||||
};
|
||||
|
||||
[QueueName.AccessTokenStatusUpdate]:
|
||||
| {
|
||||
name: QueueJobs.IdentityAccessTokenStatusUpdate;
|
||||
payload: { identityAccessTokenId: string; numberOfUses: number };
|
||||
}
|
||||
| {
|
||||
name: QueueJobs.ServiceTokenStatusUpdate;
|
||||
payload: { serviceTokenId: string };
|
||||
};
|
||||
|
||||
[QueueName.IntegrationSync]:
|
||||
| {
|
||||
name: QueueJobs.IntegrationSync;
|
||||
payload: TIntegrationSyncPayload;
|
||||
}
|
||||
| {
|
||||
name: QueueJobs.SendFailedIntegrationSyncEmails;
|
||||
payload: TFailedIntegrationSyncEmailsPayload;
|
||||
};
|
||||
[QueueName.SecretFullRepoScan]: {
|
||||
name: QueueJobs.SecretScan;
|
||||
payload: TScanFullRepoEventPayload;
|
||||
@@ -153,15 +166,6 @@ export type TQueueJobTypes = {
|
||||
name: QueueJobs.ProjectV3Migration;
|
||||
payload: { projectId: string };
|
||||
};
|
||||
[QueueName.AccessTokenStatusUpdate]:
|
||||
| {
|
||||
name: QueueJobs.IdentityAccessTokenStatusUpdate;
|
||||
payload: { identityAccessTokenId: string; numberOfUses: number };
|
||||
}
|
||||
| {
|
||||
name: QueueJobs.ServiceTokenStatusUpdate;
|
||||
payload: { serviceTokenId: string };
|
||||
};
|
||||
};
|
||||
|
||||
export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>;
|
||||
|
@@ -74,7 +74,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
||||
schema: {
|
||||
description: "Get all audit logs for an organization",
|
||||
querystring: z.object({
|
||||
projectId: z.string().optional(),
|
||||
projectId: z.string().optional().describe(AUDIT_LOGS.EXPORT.projectId),
|
||||
actorType: z.nativeEnum(ActorType).optional(),
|
||||
// eventType is split with , for multiple values, we need to transform it to array
|
||||
eventType: z
|
||||
@@ -102,7 +102,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
}),
|
||||
})
|
||||
.describe(AUDIT_LOGS.EXPORT.eventMetadata),
|
||||
startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate),
|
||||
endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate),
|
||||
offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset),
|
||||
@@ -120,10 +121,12 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
||||
})
|
||||
.merge(
|
||||
z.object({
|
||||
project: z.object({
|
||||
name: z.string(),
|
||||
slug: z.string()
|
||||
}),
|
||||
project: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
slug: z.string()
|
||||
})
|
||||
.optional(),
|
||||
event: z.object({
|
||||
type: z.string(),
|
||||
metadata: z.any()
|
||||
|
@@ -8,6 +8,7 @@ import {
|
||||
ProjectUserMembershipRolesSchema
|
||||
} from "@app/db/schemas";
|
||||
import { PROJECTS } from "@app/lib/api-docs";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { ProjectUserMembershipTemporaryMode } from "@app/services/project-membership/project-membership-types";
|
||||
@@ -15,8 +16,11 @@ import { ProjectUserMembershipTemporaryMode } from "@app/services/project-member
|
||||
export const registerGroupProjectRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:projectSlug/groups/:groupSlug",
|
||||
url: "/:projectId/groups/:groupId",
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Add group to project",
|
||||
security: [
|
||||
@@ -25,17 +29,39 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) =>
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
projectSlug: z.string().trim().describe(PROJECTS.ADD_GROUP_TO_PROJECT.projectSlug),
|
||||
groupSlug: z.string().trim().describe(PROJECTS.ADD_GROUP_TO_PROJECT.groupSlug)
|
||||
}),
|
||||
body: z.object({
|
||||
role: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.default(ProjectMembershipRole.NoAccess)
|
||||
.describe(PROJECTS.ADD_GROUP_TO_PROJECT.role)
|
||||
projectId: z.string().trim().describe(PROJECTS.ADD_GROUP_TO_PROJECT.projectId),
|
||||
groupId: z.string().trim().describe(PROJECTS.ADD_GROUP_TO_PROJECT.groupId)
|
||||
}),
|
||||
body: z
|
||||
.object({
|
||||
role: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.default(ProjectMembershipRole.NoAccess)
|
||||
.describe(PROJECTS.ADD_GROUP_TO_PROJECT.role),
|
||||
roles: z
|
||||
.array(
|
||||
z.union([
|
||||
z.object({
|
||||
role: z.string(),
|
||||
isTemporary: z.literal(false).default(false)
|
||||
}),
|
||||
z.object({
|
||||
role: z.string(),
|
||||
isTemporary: z.literal(true),
|
||||
temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode),
|
||||
temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"),
|
||||
temporaryAccessStartTime: z.string().datetime()
|
||||
})
|
||||
])
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
.refine((data) => data.role || data.roles, {
|
||||
message: "Either role or roles must be present",
|
||||
path: ["role", "roles"]
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
groupMembership: GroupProjectMembershipsSchema
|
||||
@@ -48,17 +74,18 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) =>
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
groupSlug: req.params.groupSlug,
|
||||
projectSlug: req.params.projectSlug,
|
||||
role: req.body.role
|
||||
roles: req.body.roles || [{ role: req.body.role }],
|
||||
projectId: req.params.projectId,
|
||||
groupId: req.params.groupId
|
||||
});
|
||||
|
||||
return { groupMembership };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/:projectSlug/groups/:groupSlug",
|
||||
url: "/:projectId/groups/:groupId",
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
schema: {
|
||||
description: "Update group in project",
|
||||
@@ -68,8 +95,8 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) =>
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
projectSlug: z.string().trim().describe(PROJECTS.UPDATE_GROUP_IN_PROJECT.projectSlug),
|
||||
groupSlug: z.string().trim().describe(PROJECTS.UPDATE_GROUP_IN_PROJECT.groupSlug)
|
||||
projectId: z.string().trim().describe(PROJECTS.UPDATE_GROUP_IN_PROJECT.projectId),
|
||||
groupId: z.string().trim().describe(PROJECTS.UPDATE_GROUP_IN_PROJECT.groupId)
|
||||
}),
|
||||
body: z.object({
|
||||
roles: z
|
||||
@@ -103,18 +130,22 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) =>
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
groupSlug: req.params.groupSlug,
|
||||
projectSlug: req.params.projectSlug,
|
||||
projectId: req.params.projectId,
|
||||
groupId: req.params.groupId,
|
||||
roles: req.body.roles
|
||||
});
|
||||
|
||||
return { roles };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: "/:projectSlug/groups/:groupSlug",
|
||||
url: "/:projectId/groups/:groupId",
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Remove group from project",
|
||||
security: [
|
||||
@@ -123,8 +154,8 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) =>
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
projectSlug: z.string().trim().describe(PROJECTS.REMOVE_GROUP_FROM_PROJECT.projectSlug),
|
||||
groupSlug: z.string().trim().describe(PROJECTS.REMOVE_GROUP_FROM_PROJECT.groupSlug)
|
||||
projectId: z.string().trim().describe(PROJECTS.REMOVE_GROUP_FROM_PROJECT.projectId),
|
||||
groupId: z.string().trim().describe(PROJECTS.REMOVE_GROUP_FROM_PROJECT.groupId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@@ -138,17 +169,21 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) =>
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
groupSlug: req.params.groupSlug,
|
||||
projectSlug: req.params.projectSlug
|
||||
groupId: req.params.groupId,
|
||||
projectId: req.params.projectId
|
||||
});
|
||||
|
||||
return { groupMembership };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:projectSlug/groups",
|
||||
url: "/:projectId/groups",
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Return list of groups in project",
|
||||
security: [
|
||||
@@ -157,7 +192,7 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) =>
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
projectSlug: z.string().trim().describe(PROJECTS.LIST_GROUPS_IN_PROJECT.projectSlug)
|
||||
projectId: z.string().trim().describe(PROJECTS.LIST_GROUPS_IN_PROJECT.projectId)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@@ -193,9 +228,67 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) =>
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectSlug: req.params.projectSlug
|
||||
projectId: req.params.projectId
|
||||
});
|
||||
|
||||
return { groupMemberships };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:projectId/groups/:groupId",
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
description: "Return project group",
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
projectId: z.string().trim(),
|
||||
groupId: z.string().trim()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
groupMembership: z.object({
|
||||
id: z.string(),
|
||||
groupId: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
roles: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
role: z.string(),
|
||||
customRoleId: z.string().optional().nullable(),
|
||||
customRoleName: z.string().optional().nullable(),
|
||||
customRoleSlug: z.string().optional().nullable(),
|
||||
isTemporary: z.boolean(),
|
||||
temporaryMode: z.string().optional().nullable(),
|
||||
temporaryRange: z.string().nullable().optional(),
|
||||
temporaryAccessStartTime: z.date().nullable().optional(),
|
||||
temporaryAccessEndTime: z.date().nullable().optional()
|
||||
})
|
||||
),
|
||||
group: GroupsSchema.pick({ name: true, id: true, slug: true })
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
handler: async (req) => {
|
||||
const groupMembership = await server.services.groupProject.getGroupInProject({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
...req.params
|
||||
});
|
||||
|
||||
return { groupMembership };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
612
backend/src/server/routes/v3/dashboard-router.ts
Normal file
@@ -0,0 +1,612 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretFoldersSchema, SecretImportsSchema, SecretTagsSchema } from "@app/db/schemas";
|
||||
import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { DASHBOARD } from "@app/lib/api-docs";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { removeTrailingSlash } from "@app/lib/fn";
|
||||
import { OrderByDirection } from "@app/lib/types";
|
||||
import { secretsLimit } from "@app/server/config/rateLimiter";
|
||||
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
|
||||
import { getUserAgentType } from "@app/server/plugins/audit-log";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { SanitizedDynamicSecretSchema, secretRawSchema } from "@app/server/routes/sanitizedSchemas";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { SecretsOrderBy } from "@app/services/secret/secret-types";
|
||||
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
|
||||
|
||||
export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/secrets-overview",
|
||||
config: {
|
||||
rateLimit: secretsLimit
|
||||
},
|
||||
schema: {
|
||||
description: "List project secrets overview",
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
querystring: z.object({
|
||||
projectId: z.string().trim().describe(DASHBOARD.SECRET_OVERVIEW_LIST.projectId),
|
||||
environments: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform(decodeURIComponent)
|
||||
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.environments),
|
||||
secretPath: z
|
||||
.string()
|
||||
.trim()
|
||||
.default("/")
|
||||
.transform(removeTrailingSlash)
|
||||
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.secretPath),
|
||||
offset: z.coerce.number().min(0).optional().default(0).describe(DASHBOARD.SECRET_OVERVIEW_LIST.offset),
|
||||
limit: z.coerce.number().min(1).max(100).optional().default(100).describe(DASHBOARD.SECRET_OVERVIEW_LIST.limit),
|
||||
orderBy: z
|
||||
.nativeEnum(SecretsOrderBy)
|
||||
.default(SecretsOrderBy.Name)
|
||||
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.orderBy)
|
||||
.optional(),
|
||||
orderDirection: z
|
||||
.nativeEnum(OrderByDirection)
|
||||
.default(OrderByDirection.ASC)
|
||||
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.orderDirection)
|
||||
.optional(),
|
||||
search: z.string().trim().describe(DASHBOARD.SECRET_OVERVIEW_LIST.search).optional(),
|
||||
includeSecrets: z.coerce
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeSecrets),
|
||||
includeFolders: z.coerce
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeFolders),
|
||||
includeDynamicSecrets: z.coerce
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeDynamicSecrets)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
folders: SecretFoldersSchema.extend({ environment: z.string() }).array().optional(),
|
||||
dynamicSecrets: SanitizedDynamicSecretSchema.extend({ environment: z.string() }).array().optional(),
|
||||
secrets: secretRawSchema
|
||||
.extend({
|
||||
secretPath: z.string().optional(),
|
||||
tags: SecretTagsSchema.pick({
|
||||
id: true,
|
||||
slug: true,
|
||||
color: true
|
||||
})
|
||||
.extend({ name: z.string() })
|
||||
.array()
|
||||
.optional()
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
totalFolderCount: z.number().optional(),
|
||||
totalDynamicSecretCount: z.number().optional(),
|
||||
totalSecretCount: z.number().optional(),
|
||||
totalCount: z.number()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const {
|
||||
secretPath,
|
||||
projectId,
|
||||
limit,
|
||||
offset,
|
||||
search,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
includeFolders,
|
||||
includeSecrets,
|
||||
includeDynamicSecrets
|
||||
} = req.query;
|
||||
|
||||
const environments = req.query.environments.split(",");
|
||||
|
||||
if (!projectId || environments.length === 0)
|
||||
throw new BadRequestError({ message: "Missing workspace id or environment(s)" });
|
||||
|
||||
const { shouldUseSecretV2Bridge } = await server.services.projectBot.getBotKey(projectId);
|
||||
|
||||
// prevent older projects from accessing endpoint
|
||||
if (!shouldUseSecretV2Bridge) throw new BadRequestError({ message: "Project version not supported" });
|
||||
|
||||
let remainingLimit = limit;
|
||||
let adjustedOffset = offset;
|
||||
|
||||
let folders: Awaited<ReturnType<typeof server.services.folder.getFoldersMultiEnv>> | undefined;
|
||||
let secrets: Awaited<ReturnType<typeof server.services.secret.getSecretsRawMultiEnv>> | undefined;
|
||||
let dynamicSecrets:
|
||||
| Awaited<ReturnType<typeof server.services.dynamicSecret.listDynamicSecretsByFolderIds>>
|
||||
| undefined;
|
||||
|
||||
let totalFolderCount: number | undefined;
|
||||
let totalDynamicSecretCount: number | undefined;
|
||||
let totalSecretCount: number | undefined;
|
||||
|
||||
if (includeFolders) {
|
||||
// this is the unique count, ie duplicate folders across envs only count as 1
|
||||
totalFolderCount = await server.services.folder.getProjectFolderCount({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId: req.query.projectId,
|
||||
path: secretPath,
|
||||
environments,
|
||||
search
|
||||
});
|
||||
|
||||
if (remainingLimit > 0 && totalFolderCount > adjustedOffset) {
|
||||
folders = await server.services.folder.getFoldersMultiEnv({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId,
|
||||
environments,
|
||||
path: secretPath,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
search,
|
||||
limit: remainingLimit,
|
||||
offset: adjustedOffset
|
||||
});
|
||||
|
||||
// get the count of unique folder names to properly adjust remaining limit
|
||||
const uniqueFolderCount = new Set(folders.map((folder) => folder.name)).size;
|
||||
|
||||
remainingLimit -= uniqueFolderCount;
|
||||
adjustedOffset = 0;
|
||||
} else {
|
||||
adjustedOffset = Math.max(0, adjustedOffset - totalFolderCount);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeDynamicSecrets) {
|
||||
// this is the unique count, ie duplicate secrets across envs only count as 1
|
||||
totalDynamicSecretCount = await server.services.dynamicSecret.getCountMultiEnv({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId,
|
||||
search,
|
||||
environmentSlugs: environments,
|
||||
path: secretPath
|
||||
});
|
||||
|
||||
if (remainingLimit > 0 && totalDynamicSecretCount > adjustedOffset) {
|
||||
dynamicSecrets = await server.services.dynamicSecret.listDynamicSecretsByFolderIds({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId,
|
||||
search,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
environmentSlugs: environments,
|
||||
path: secretPath,
|
||||
limit: remainingLimit,
|
||||
offset: adjustedOffset
|
||||
});
|
||||
|
||||
// get the count of unique dynamic secret names to properly adjust remaining limit
|
||||
const uniqueDynamicSecretsCount = new Set(dynamicSecrets.map((dynamicSecret) => dynamicSecret.name)).size;
|
||||
|
||||
remainingLimit -= uniqueDynamicSecretsCount;
|
||||
adjustedOffset = 0;
|
||||
} else {
|
||||
adjustedOffset = Math.max(0, adjustedOffset - totalDynamicSecretCount);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeSecrets) {
|
||||
// this is the unique count, ie duplicate secrets across envs only count as 1
|
||||
totalSecretCount = await server.services.secret.getSecretsCountMultiEnv({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
environments,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
projectId,
|
||||
path: secretPath,
|
||||
search
|
||||
});
|
||||
|
||||
if (remainingLimit > 0 && totalSecretCount > adjustedOffset) {
|
||||
secrets = await server.services.secret.getSecretsRawMultiEnv({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
environments,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
projectId,
|
||||
path: secretPath,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
search,
|
||||
limit: remainingLimit,
|
||||
offset: adjustedOffset
|
||||
});
|
||||
|
||||
for await (const environment of environments) {
|
||||
const secretCountFromEnv = secrets.filter((secret) => secret.environment === environment).length;
|
||||
|
||||
if (secretCountFromEnv) {
|
||||
await server.services.auditLog.createAuditLog({
|
||||
projectId,
|
||||
...req.auditLogInfo,
|
||||
event: {
|
||||
type: EventType.GET_SECRETS,
|
||||
metadata: {
|
||||
environment,
|
||||
secretPath,
|
||||
numberOfSecrets: secretCountFromEnv
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretPulled,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
properties: {
|
||||
numberOfSecrets: secretCountFromEnv,
|
||||
workspaceId: projectId,
|
||||
environment,
|
||||
secretPath,
|
||||
channel: getUserAgentType(req.headers["user-agent"]),
|
||||
...req.auditLogInfo
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
folders,
|
||||
dynamicSecrets,
|
||||
secrets,
|
||||
totalFolderCount,
|
||||
totalDynamicSecretCount,
|
||||
totalSecretCount,
|
||||
totalCount: (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0)
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/secrets-details",
|
||||
config: {
|
||||
rateLimit: secretsLimit
|
||||
},
|
||||
schema: {
|
||||
description: "List project secrets details",
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
querystring: z.object({
|
||||
projectId: z.string().trim().describe(DASHBOARD.SECRET_DETAILS_LIST.projectId),
|
||||
environment: z.string().trim().describe(DASHBOARD.SECRET_DETAILS_LIST.environment),
|
||||
secretPath: z
|
||||
.string()
|
||||
.trim()
|
||||
.default("/")
|
||||
.transform(removeTrailingSlash)
|
||||
.describe(DASHBOARD.SECRET_DETAILS_LIST.secretPath),
|
||||
offset: z.coerce.number().min(0).optional().default(0).describe(DASHBOARD.SECRET_DETAILS_LIST.offset),
|
||||
limit: z.coerce.number().min(1).max(100).optional().default(100).describe(DASHBOARD.SECRET_DETAILS_LIST.limit),
|
||||
orderBy: z
|
||||
.nativeEnum(SecretsOrderBy)
|
||||
.default(SecretsOrderBy.Name)
|
||||
.describe(DASHBOARD.SECRET_DETAILS_LIST.orderBy)
|
||||
.optional(),
|
||||
orderDirection: z
|
||||
.nativeEnum(OrderByDirection)
|
||||
.default(OrderByDirection.ASC)
|
||||
.describe(DASHBOARD.SECRET_DETAILS_LIST.orderDirection)
|
||||
.optional(),
|
||||
search: z.string().trim().describe(DASHBOARD.SECRET_DETAILS_LIST.search).optional(),
|
||||
tags: z.string().trim().transform(decodeURIComponent).describe(DASHBOARD.SECRET_DETAILS_LIST.tags).optional(),
|
||||
includeSecrets: z.coerce
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe(DASHBOARD.SECRET_DETAILS_LIST.includeSecrets),
|
||||
includeFolders: z.coerce
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe(DASHBOARD.SECRET_DETAILS_LIST.includeFolders),
|
||||
includeDynamicSecrets: z.coerce
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe(DASHBOARD.SECRET_DETAILS_LIST.includeDynamicSecrets),
|
||||
includeImports: z.coerce
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe(DASHBOARD.SECRET_DETAILS_LIST.includeImports)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
imports: SecretImportsSchema.omit({ importEnv: true })
|
||||
.extend({
|
||||
importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() })
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
folders: SecretFoldersSchema.array().optional(),
|
||||
dynamicSecrets: SanitizedDynamicSecretSchema.array().optional(),
|
||||
secrets: secretRawSchema
|
||||
.extend({
|
||||
secretPath: z.string().optional(),
|
||||
tags: SecretTagsSchema.pick({
|
||||
id: true,
|
||||
slug: true,
|
||||
color: true
|
||||
})
|
||||
.extend({ name: z.string() })
|
||||
.array()
|
||||
.optional()
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
totalImportCount: z.number().optional(),
|
||||
totalFolderCount: z.number().optional(),
|
||||
totalDynamicSecretCount: z.number().optional(),
|
||||
totalSecretCount: z.number().optional(),
|
||||
totalCount: z.number()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const {
|
||||
secretPath,
|
||||
environment,
|
||||
projectId,
|
||||
limit,
|
||||
offset,
|
||||
search,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
includeFolders,
|
||||
includeSecrets,
|
||||
includeDynamicSecrets,
|
||||
includeImports
|
||||
} = req.query;
|
||||
|
||||
if (!projectId || !environment) throw new BadRequestError({ message: "Missing workspace id or environment" });
|
||||
|
||||
const { shouldUseSecretV2Bridge } = await server.services.projectBot.getBotKey(projectId);
|
||||
|
||||
// prevent older projects from accessing endpoint
|
||||
if (!shouldUseSecretV2Bridge) throw new BadRequestError({ message: "Project version not supported" });
|
||||
|
||||
const tags = req.query.tags?.split(",") ?? [];
|
||||
|
||||
let remainingLimit = limit;
|
||||
let adjustedOffset = offset;
|
||||
|
||||
let imports: Awaited<ReturnType<typeof server.services.secretImport.getImports>> | undefined;
|
||||
let folders: Awaited<ReturnType<typeof server.services.folder.getFolders>> | undefined;
|
||||
let secrets: Awaited<ReturnType<typeof server.services.secret.getSecretsRaw>>["secrets"] | undefined;
|
||||
let dynamicSecrets: Awaited<ReturnType<typeof server.services.dynamicSecret.listDynamicSecretsByEnv>> | undefined;
|
||||
|
||||
let totalImportCount: number | undefined;
|
||||
let totalFolderCount: number | undefined;
|
||||
let totalDynamicSecretCount: number | undefined;
|
||||
let totalSecretCount: number | undefined;
|
||||
|
||||
if (includeImports) {
|
||||
totalImportCount = await server.services.secretImport.getProjectImportCount({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId,
|
||||
environment,
|
||||
path: secretPath,
|
||||
search
|
||||
});
|
||||
|
||||
if (remainingLimit > 0 && totalImportCount > adjustedOffset) {
|
||||
imports = await server.services.secretImport.getImports({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId,
|
||||
environment,
|
||||
path: secretPath,
|
||||
search,
|
||||
limit: remainingLimit,
|
||||
offset: adjustedOffset
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: req.query.projectId,
|
||||
event: {
|
||||
type: EventType.GET_SECRET_IMPORTS,
|
||||
metadata: {
|
||||
environment,
|
||||
folderId: imports?.[0]?.folderId,
|
||||
numberOfImports: imports.length
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
remainingLimit -= imports.length;
|
||||
adjustedOffset = 0;
|
||||
} else {
|
||||
adjustedOffset = Math.max(0, adjustedOffset - totalImportCount);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeFolders) {
|
||||
totalFolderCount = await server.services.folder.getProjectFolderCount({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId,
|
||||
path: secretPath,
|
||||
environments: [environment],
|
||||
search
|
||||
});
|
||||
|
||||
if (remainingLimit > 0 && totalFolderCount > adjustedOffset) {
|
||||
folders = await server.services.folder.getFolders({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId,
|
||||
environment,
|
||||
path: secretPath,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
search,
|
||||
limit: remainingLimit,
|
||||
offset: adjustedOffset
|
||||
});
|
||||
|
||||
remainingLimit -= folders.length;
|
||||
adjustedOffset = 0;
|
||||
} else {
|
||||
adjustedOffset = Math.max(0, adjustedOffset - totalFolderCount);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeDynamicSecrets) {
|
||||
totalDynamicSecretCount = await server.services.dynamicSecret.getDynamicSecretCount({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId,
|
||||
search,
|
||||
environmentSlug: environment,
|
||||
path: secretPath
|
||||
});
|
||||
|
||||
if (remainingLimit > 0 && totalDynamicSecretCount > adjustedOffset) {
|
||||
dynamicSecrets = await server.services.dynamicSecret.listDynamicSecretsByEnv({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId,
|
||||
search,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
environmentSlug: environment,
|
||||
path: secretPath,
|
||||
limit: remainingLimit,
|
||||
offset: adjustedOffset
|
||||
});
|
||||
|
||||
remainingLimit -= dynamicSecrets.length;
|
||||
adjustedOffset = 0;
|
||||
} else {
|
||||
adjustedOffset = Math.max(0, adjustedOffset - totalDynamicSecretCount);
|
||||
}
|
||||
}
|
||||
|
||||
if (includeSecrets) {
|
||||
totalSecretCount = await server.services.secret.getSecretsCount({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
environment,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
projectId,
|
||||
path: secretPath,
|
||||
search,
|
||||
tagSlugs: tags
|
||||
});
|
||||
|
||||
if (remainingLimit > 0 && totalSecretCount > adjustedOffset) {
|
||||
const secretsRaw = await server.services.secret.getSecretsRaw({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
environment,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
projectId,
|
||||
path: secretPath,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
search,
|
||||
limit: remainingLimit,
|
||||
offset: adjustedOffset,
|
||||
tagSlugs: tags
|
||||
});
|
||||
|
||||
secrets = secretsRaw.secrets;
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
projectId,
|
||||
...req.auditLogInfo,
|
||||
event: {
|
||||
type: EventType.GET_SECRETS,
|
||||
metadata: {
|
||||
environment,
|
||||
secretPath,
|
||||
numberOfSecrets: secrets.length
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) {
|
||||
await server.services.telemetry.sendPostHogEvents({
|
||||
event: PostHogEventTypes.SecretPulled,
|
||||
distinctId: getTelemetryDistinctId(req),
|
||||
properties: {
|
||||
numberOfSecrets: secrets.length,
|
||||
workspaceId: projectId,
|
||||
environment,
|
||||
secretPath,
|
||||
channel: getUserAgentType(req.headers["user-agent"]),
|
||||
...req.auditLogInfo
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
imports,
|
||||
folders,
|
||||
dynamicSecrets,
|
||||
secrets,
|
||||
totalImportCount,
|
||||
totalFolderCount,
|
||||
totalDynamicSecretCount,
|
||||
totalSecretCount,
|
||||
totalCount:
|
||||
(totalImportCount ?? 0) + (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0)
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
@@ -1,3 +1,4 @@
|
||||
import { registerDashboardRouter } from "./dashboard-router";
|
||||
import { registerLoginRouter } from "./login-router";
|
||||
import { registerSecretBlindIndexRouter } from "./secret-blind-index-router";
|
||||
import { registerSecretRouter } from "./secret-router";
|
||||
@@ -10,4 +11,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => {
|
||||
await server.register(registerUserRouter, { prefix: "/users" });
|
||||
await server.register(registerSecretRouter, { prefix: "/secrets" });
|
||||
await server.register(registerSecretBlindIndexRouter, { prefix: "/workspaces" });
|
||||
await server.register(registerDashboardRouter, { prefix: "/dashboard" });
|
||||
};
|
||||
|
@@ -10,10 +10,15 @@ export type TGroupProjectDALFactory = ReturnType<typeof groupProjectDALFactory>;
|
||||
export const groupProjectDALFactory = (db: TDbClient) => {
|
||||
const groupProjectOrm = ormify(db, TableName.GroupProjectMembership);
|
||||
|
||||
const findByProjectId = async (projectId: string, tx?: Knex) => {
|
||||
const findByProjectId = async (projectId: string, filter?: { groupId?: string }, tx?: Knex) => {
|
||||
try {
|
||||
const docs = await (tx || db.replicaNode())(TableName.GroupProjectMembership)
|
||||
.where(`${TableName.GroupProjectMembership}.projectId`, projectId)
|
||||
.where((qb) => {
|
||||
if (filter?.groupId) {
|
||||
void qb.where(`${TableName.Groups}.id`, "=", filter.groupId);
|
||||
}
|
||||
})
|
||||
.join(TableName.Groups, `${TableName.GroupProjectMembership}.groupId`, `${TableName.Groups}.id`)
|
||||
.join(
|
||||
TableName.GroupProjectMembershipRole,
|
||||
|
@@ -7,7 +7,7 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services
|
||||
import { isAtLeastAsPrivileged } from "@app/lib/casl";
|
||||
import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto";
|
||||
import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption";
|
||||
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { groupBy } from "@app/lib/fn";
|
||||
|
||||
import { TGroupDALFactory } from "../../ee/services/group/group-dal";
|
||||
@@ -22,6 +22,7 @@ import { TGroupProjectMembershipRoleDALFactory } from "./group-project-membershi
|
||||
import {
|
||||
TCreateProjectGroupDTO,
|
||||
TDeleteProjectGroupDTO,
|
||||
TGetGroupInProjectDTO,
|
||||
TListProjectGroupDTO,
|
||||
TUpdateProjectGroupDTO
|
||||
} from "./group-project-types";
|
||||
@@ -33,7 +34,7 @@ type TGroupProjectServiceFactoryDep = {
|
||||
"create" | "transaction" | "insertMany" | "delete"
|
||||
>;
|
||||
userGroupMembershipDAL: Pick<TUserGroupMembershipDALFactory, "findGroupMembersNotInProject">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findOne" | "findProjectGhostUser">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findOne" | "findProjectGhostUser" | "findById">;
|
||||
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany" | "transaction">;
|
||||
projectRoleDAL: Pick<TProjectRoleDALFactory, "find">;
|
||||
projectBotDAL: TProjectBotDALFactory;
|
||||
@@ -55,19 +56,17 @@ export const groupProjectServiceFactory = ({
|
||||
permissionService
|
||||
}: TGroupProjectServiceFactoryDep) => {
|
||||
const addGroupToProject = async ({
|
||||
groupSlug,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectSlug,
|
||||
role
|
||||
roles,
|
||||
projectId,
|
||||
groupId
|
||||
}: TCreateProjectGroupDTO) => {
|
||||
const project = await projectDAL.findOne({
|
||||
slug: projectSlug
|
||||
});
|
||||
const project = await projectDAL.findById(projectId);
|
||||
|
||||
if (!project) throw new BadRequestError({ message: `Failed to find project with slug ${projectSlug}` });
|
||||
if (!project) throw new BadRequestError({ message: `Failed to find project with ID ${projectId}` });
|
||||
if (project.version < 2) throw new BadRequestError({ message: `Failed to add group to E2EE project` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
@@ -79,25 +78,51 @@ export const groupProjectServiceFactory = ({
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Groups);
|
||||
|
||||
const group = await groupDAL.findOne({ orgId: actorOrgId, slug: groupSlug });
|
||||
if (!group) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` });
|
||||
const group = await groupDAL.findOne({ orgId: actorOrgId, id: groupId });
|
||||
if (!group) throw new BadRequestError({ message: `Failed to find group with ID ${groupId}` });
|
||||
|
||||
const existingGroup = await groupProjectDAL.findOne({ groupId: group.id, projectId: project.id });
|
||||
if (existingGroup)
|
||||
throw new BadRequestError({
|
||||
message: `Group with slug ${groupSlug} already exists in project with id ${project.id}`
|
||||
message: `Group with ID ${groupId} already exists in project with id ${project.id}`
|
||||
});
|
||||
|
||||
const { permission: rolePermission, role: customRole } = await permissionService.getProjectPermissionByRole(
|
||||
role,
|
||||
project.id
|
||||
for await (const { role: requestedRoleChange } of roles) {
|
||||
const { permission: rolePermission } = await permissionService.getProjectPermissionByRole(
|
||||
requestedRoleChange,
|
||||
project.id
|
||||
);
|
||||
|
||||
const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
|
||||
if (!hasRequiredPrivileges) {
|
||||
throw new ForbiddenRequestError({ message: "Failed to assign group to a more privileged role" });
|
||||
}
|
||||
}
|
||||
|
||||
// validate custom roles input
|
||||
const customInputRoles = roles.filter(
|
||||
({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole)
|
||||
);
|
||||
const hasPrivilege = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
if (!hasPrivilege)
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to add group to project with more privileged role"
|
||||
const hasCustomRole = Boolean(customInputRoles.length);
|
||||
const customRoles = hasCustomRole
|
||||
? await projectRoleDAL.find({
|
||||
projectId: project.id,
|
||||
$in: { slug: customInputRoles.map(({ role }) => role) }
|
||||
})
|
||||
: [];
|
||||
|
||||
if (customRoles.length !== customInputRoles.length) {
|
||||
const customRoleSlugs = customRoles.map((customRole) => customRole.slug);
|
||||
const missingInputRoles = customInputRoles
|
||||
.filter((inputRole) => !customRoleSlugs.includes(inputRole.role))
|
||||
.map((role) => role.role);
|
||||
|
||||
throw new NotFoundError({
|
||||
message: `Custom role/s not found: ${missingInputRoles.join(", ")}`
|
||||
});
|
||||
const isCustomRole = Boolean(customRole);
|
||||
}
|
||||
const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug);
|
||||
|
||||
const projectGroup = await groupProjectDAL.transaction(async (tx) => {
|
||||
const groupProjectMembership = await groupProjectDAL.create(
|
||||
@@ -108,14 +133,31 @@ export const groupProjectServiceFactory = ({
|
||||
tx
|
||||
);
|
||||
|
||||
await groupProjectMembershipRoleDAL.create(
|
||||
{
|
||||
const sanitizedProjectMembershipRoles = roles.map((inputRole) => {
|
||||
const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]);
|
||||
if (!inputRole.isTemporary) {
|
||||
return {
|
||||
projectMembershipId: groupProjectMembership.id,
|
||||
role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role,
|
||||
customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null
|
||||
};
|
||||
}
|
||||
|
||||
// check cron or relative here later for now its just relative
|
||||
const relativeTimeInMs = ms(inputRole.temporaryRange);
|
||||
return {
|
||||
projectMembershipId: groupProjectMembership.id,
|
||||
role: isCustomRole ? ProjectMembershipRole.Custom : role,
|
||||
customRoleId: customRole?.id
|
||||
},
|
||||
tx
|
||||
);
|
||||
role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role,
|
||||
customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null,
|
||||
isTemporary: true,
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative,
|
||||
temporaryRange: inputRole.temporaryRange,
|
||||
temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime),
|
||||
temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs)
|
||||
};
|
||||
});
|
||||
|
||||
await groupProjectMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx);
|
||||
|
||||
// share project key with users in group that have not
|
||||
// individually been added to the project and that are not part of
|
||||
@@ -183,19 +225,17 @@ export const groupProjectServiceFactory = ({
|
||||
};
|
||||
|
||||
const updateGroupInProject = async ({
|
||||
projectSlug,
|
||||
groupSlug,
|
||||
projectId,
|
||||
groupId,
|
||||
roles,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TUpdateProjectGroupDTO) => {
|
||||
const project = await projectDAL.findOne({
|
||||
slug: projectSlug
|
||||
});
|
||||
const project = await projectDAL.findById(projectId);
|
||||
|
||||
if (!project) throw new BadRequestError({ message: `Failed to find project with slug ${projectSlug}` });
|
||||
if (!project) throw new BadRequestError({ message: `Failed to find project with ID ${projectId}` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
@@ -206,11 +246,24 @@ export const groupProjectServiceFactory = ({
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Groups);
|
||||
|
||||
const group = await groupDAL.findOne({ orgId: actorOrgId, slug: groupSlug });
|
||||
if (!group) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` });
|
||||
const group = await groupDAL.findOne({ orgId: actorOrgId, id: groupId });
|
||||
if (!group) throw new BadRequestError({ message: `Failed to find group with ID ${groupId}` });
|
||||
|
||||
const projectGroup = await groupProjectDAL.findOne({ groupId: group.id, projectId: project.id });
|
||||
if (!projectGroup) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` });
|
||||
if (!projectGroup) throw new BadRequestError({ message: `Failed to find group with ID ${groupId}` });
|
||||
|
||||
for await (const { role: requestedRoleChange } of roles) {
|
||||
const { permission: rolePermission } = await permissionService.getProjectPermissionByRole(
|
||||
requestedRoleChange,
|
||||
project.id
|
||||
);
|
||||
|
||||
const hasRequiredPrivileges = isAtLeastAsPrivileged(permission, rolePermission);
|
||||
|
||||
if (!hasRequiredPrivileges) {
|
||||
throw new ForbiddenRequestError({ message: "Failed to assign group to a more privileged role" });
|
||||
}
|
||||
}
|
||||
|
||||
// validate custom roles input
|
||||
const customInputRoles = roles.filter(
|
||||
@@ -223,7 +276,16 @@ export const groupProjectServiceFactory = ({
|
||||
$in: { slug: customInputRoles.map(({ role }) => role) }
|
||||
})
|
||||
: [];
|
||||
if (customRoles.length !== customInputRoles.length) throw new BadRequestError({ message: "Custom role not found" });
|
||||
if (customRoles.length !== customInputRoles.length) {
|
||||
const customRoleSlugs = customRoles.map((customRole) => customRole.slug);
|
||||
const missingInputRoles = customInputRoles
|
||||
.filter((inputRole) => !customRoleSlugs.includes(inputRole.role))
|
||||
.map((role) => role.role);
|
||||
|
||||
throw new NotFoundError({
|
||||
message: `Custom role/s not found: ${missingInputRoles.join(", ")}`
|
||||
});
|
||||
}
|
||||
|
||||
const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug);
|
||||
|
||||
@@ -260,24 +322,22 @@ export const groupProjectServiceFactory = ({
|
||||
};
|
||||
|
||||
const removeGroupFromProject = async ({
|
||||
projectSlug,
|
||||
groupSlug,
|
||||
projectId,
|
||||
groupId,
|
||||
actorId,
|
||||
actor,
|
||||
actorOrgId,
|
||||
actorAuthMethod
|
||||
}: TDeleteProjectGroupDTO) => {
|
||||
const project = await projectDAL.findOne({
|
||||
slug: projectSlug
|
||||
});
|
||||
const project = await projectDAL.findById(projectId);
|
||||
|
||||
if (!project) throw new BadRequestError({ message: `Failed to find project with slug ${projectSlug}` });
|
||||
if (!project) throw new BadRequestError({ message: `Failed to find project with ID ${projectId}` });
|
||||
|
||||
const group = await groupDAL.findOne({ orgId: actorOrgId, slug: groupSlug });
|
||||
if (!group) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` });
|
||||
const group = await groupDAL.findOne({ orgId: actorOrgId, id: groupId });
|
||||
if (!group) throw new BadRequestError({ message: `Failed to find group with ID ${groupId}` });
|
||||
|
||||
const groupProjectMembership = await groupProjectDAL.findOne({ groupId: group.id, projectId: project.id });
|
||||
if (!groupProjectMembership) throw new BadRequestError({ message: `Failed to find group with slug ${groupSlug}` });
|
||||
if (!groupProjectMembership) throw new BadRequestError({ message: `Failed to find group with ID ${groupId}` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
@@ -311,17 +371,17 @@ export const groupProjectServiceFactory = ({
|
||||
};
|
||||
|
||||
const listGroupsInProject = async ({
|
||||
projectSlug,
|
||||
projectId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TListProjectGroupDTO) => {
|
||||
const project = await projectDAL.findOne({
|
||||
slug: projectSlug
|
||||
});
|
||||
const project = await projectDAL.findById(projectId);
|
||||
|
||||
if (!project) throw new BadRequestError({ message: `Failed to find project with slug ${projectSlug}` });
|
||||
if (!project) {
|
||||
throw new BadRequestError({ message: `Failed to find project with ID ${projectId}` });
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
@@ -336,10 +396,47 @@ export const groupProjectServiceFactory = ({
|
||||
return groupMemberships;
|
||||
};
|
||||
|
||||
const getGroupInProject = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
groupId,
|
||||
projectId
|
||||
}: TGetGroupInProjectDTO) => {
|
||||
const project = await projectDAL.findById(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFoundError({ message: `Failed to find project with ID ${projectId}` });
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
project.id,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Groups);
|
||||
|
||||
const [groupMembership] = await groupProjectDAL.findByProjectId(project.id, {
|
||||
groupId
|
||||
});
|
||||
|
||||
if (!groupMembership) {
|
||||
throw new NotFoundError({
|
||||
message: "Cannot find group membership"
|
||||
});
|
||||
}
|
||||
|
||||
return groupMembership;
|
||||
};
|
||||
|
||||
return {
|
||||
addGroupToProject,
|
||||
updateGroupInProject,
|
||||
removeGroupFromProject,
|
||||
listGroupsInProject
|
||||
listGroupsInProject,
|
||||
getGroupInProject
|
||||
};
|
||||
};
|
||||
|
@@ -1,11 +1,23 @@
|
||||
import { TProjectSlugPermission } from "@app/lib/types";
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
import { ProjectUserMembershipTemporaryMode } from "../project-membership/project-membership-types";
|
||||
|
||||
export type TCreateProjectGroupDTO = {
|
||||
groupSlug: string;
|
||||
role: string;
|
||||
} & TProjectSlugPermission;
|
||||
groupId: string;
|
||||
roles: (
|
||||
| {
|
||||
role: string;
|
||||
isTemporary?: false;
|
||||
}
|
||||
| {
|
||||
role: string;
|
||||
isTemporary: true;
|
||||
temporaryMode: ProjectUserMembershipTemporaryMode.Relative;
|
||||
temporaryRange: string;
|
||||
temporaryAccessStartTime: string;
|
||||
}
|
||||
)[];
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TUpdateProjectGroupDTO = {
|
||||
roles: (
|
||||
@@ -21,11 +33,13 @@ export type TUpdateProjectGroupDTO = {
|
||||
temporaryAccessStartTime: string;
|
||||
}
|
||||
)[];
|
||||
groupSlug: string;
|
||||
} & TProjectSlugPermission;
|
||||
groupId: string;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TDeleteProjectGroupDTO = {
|
||||
groupSlug: string;
|
||||
} & TProjectSlugPermission;
|
||||
groupId: string;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TListProjectGroupDTO = TProjectSlugPermission;
|
||||
export type TListProjectGroupDTO = TProjectPermission;
|
||||
|
||||
export type TGetGroupInProjectDTO = TProjectPermission & { groupId: string };
|
||||
|
@@ -458,12 +458,6 @@ export const orgServiceFactory = ({
|
||||
|
||||
const org = await orgDAL.findOrgById(orgId);
|
||||
|
||||
if (org?.authEnforced) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to invite user due to org-level auth enforced for organization"
|
||||
});
|
||||
}
|
||||
|
||||
const isEmailInvalid = await isDisposableEmail(inviteeEmails);
|
||||
if (isEmailInvalid) {
|
||||
throw new BadRequestError({
|
||||
@@ -472,20 +466,6 @@ export const orgServiceFactory = ({
|
||||
});
|
||||
}
|
||||
const plan = await licenseService.getPlan(orgId);
|
||||
if (plan?.slug !== "enterprise" && plan?.memberLimit && plan.membersUsed >= plan.memberLimit) {
|
||||
// limit imposed on number of members allowed / number of members used exceeds the number of members allowed
|
||||
throw new BadRequestError({
|
||||
message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members."
|
||||
});
|
||||
}
|
||||
|
||||
if (plan?.slug !== "enterprise" && plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) {
|
||||
// limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed
|
||||
throw new BadRequestError({
|
||||
message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members."
|
||||
});
|
||||
}
|
||||
|
||||
const isCustomOrgRole = !Object.values(OrgMembershipRole).includes(organizationRoleSlug as OrgMembershipRole);
|
||||
if (isCustomOrgRole) {
|
||||
if (!plan?.rbac)
|
||||
@@ -570,7 +550,7 @@ export const orgServiceFactory = ({
|
||||
);
|
||||
}
|
||||
|
||||
const [inviteeMembership] = await orgDAL.findMembership(
|
||||
const [inviteeOrgMembership] = await orgDAL.findMembership(
|
||||
{
|
||||
[`${TableName.OrgMembership}.orgId` as "orgId"]: orgId,
|
||||
[`${TableName.OrgMembership}.userId` as "userId"]: inviteeUserId
|
||||
@@ -579,7 +559,27 @@ export const orgServiceFactory = ({
|
||||
);
|
||||
|
||||
// if there exist no org membership we set is as given by the request
|
||||
if (!inviteeMembership) {
|
||||
if (!inviteeOrgMembership) {
|
||||
if (plan?.slug !== "enterprise" && plan?.memberLimit && plan.membersUsed >= plan.memberLimit) {
|
||||
// limit imposed on number of members allowed / number of members used exceeds the number of members allowed
|
||||
throw new BadRequestError({
|
||||
message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members."
|
||||
});
|
||||
}
|
||||
|
||||
if (plan?.slug !== "enterprise" && plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) {
|
||||
// limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed
|
||||
throw new BadRequestError({
|
||||
message: "Failed to invite member due to member limit reached. Upgrade plan to invite more members."
|
||||
});
|
||||
}
|
||||
|
||||
if (org?.authEnforced) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to invite user due to org-level auth enforced for organization"
|
||||
});
|
||||
}
|
||||
|
||||
// as its used by project invite also
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Member);
|
||||
let roleId;
|
||||
|
@@ -5,6 +5,8 @@ import { TableName, TProjectEnvironments, TSecretFolders, TSecretFoldersUpdate }
|
||||
import { BadRequestError, DatabaseError } from "@app/lib/errors";
|
||||
import { groupBy, removeTrailingSlash } from "@app/lib/fn";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
import { OrderByDirection } from "@app/lib/types";
|
||||
import { SecretsOrderBy } from "@app/services/secret/secret-types";
|
||||
|
||||
export const validateFolderName = (folderName: string) => {
|
||||
const validNameRegex = /^[a-zA-Z0-9-_]+$/;
|
||||
@@ -83,7 +85,7 @@ const sqlFindMultipleFolderByEnvPathQuery = (db: Knex, query: Array<{ envId: str
|
||||
.from<TSecretFolders & { depth: number; path: string }>("parent");
|
||||
};
|
||||
|
||||
const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environment: string, secretPath: string) => {
|
||||
const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environments: string[], secretPath: string) => {
|
||||
// this is removing an trailing slash like /folder1/folder2/ -> /folder1/folder2
|
||||
const formatedPath = secretPath.at(-1) === "/" && secretPath.length > 1 ? secretPath.slice(0, -1) : secretPath;
|
||||
// next goal to sanitize saw the raw sql query is safe
|
||||
@@ -111,7 +113,7 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environment: stri
|
||||
projectId,
|
||||
parentId: null
|
||||
})
|
||||
.where(`${TableName.Environment}.slug`, environment)
|
||||
.whereIn(`${TableName.Environment}.slug`, environments)
|
||||
.select(selectAllTableCols(TableName.SecretFolder))
|
||||
.union(
|
||||
(qb) =>
|
||||
@@ -139,14 +141,14 @@ const sqlFindFolderByPathQuery = (db: Knex, projectId: string, environment: stri
|
||||
.from<TSecretFolders & { depth: number; path: string }>("parent")
|
||||
.leftJoin<TProjectEnvironments>(TableName.Environment, `${TableName.Environment}.id`, "parent.envId")
|
||||
.select<
|
||||
TSecretFolders & {
|
||||
(TSecretFolders & {
|
||||
depth: number;
|
||||
path: string;
|
||||
envId: string;
|
||||
envSlug: string;
|
||||
envName: string;
|
||||
projectId: string;
|
||||
}
|
||||
})[]
|
||||
>(
|
||||
selectAllTableCols("parent" as TableName.SecretFolder),
|
||||
db.ref("id").withSchema(TableName.Environment).as("envId"),
|
||||
@@ -214,7 +216,7 @@ export const secretFolderDALFactory = (db: TDbClient) => {
|
||||
const folder = await sqlFindFolderByPathQuery(
|
||||
tx || db.replicaNode(),
|
||||
projectId,
|
||||
environment,
|
||||
[environment],
|
||||
removeTrailingSlash(path)
|
||||
)
|
||||
.orderBy("depth", "desc")
|
||||
@@ -230,6 +232,35 @@ export const secretFolderDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
// finds folders by path for multiple envs
|
||||
const findBySecretPathMultiEnv = async (projectId: string, environments: string[], path: string, tx?: Knex) => {
|
||||
try {
|
||||
const pathDepth = removeTrailingSlash(path).split("/").filter(Boolean).length + 1;
|
||||
|
||||
const folders = await sqlFindFolderByPathQuery(
|
||||
tx || db.replicaNode(),
|
||||
projectId,
|
||||
environments,
|
||||
removeTrailingSlash(path)
|
||||
)
|
||||
.orderBy("depth", "desc")
|
||||
.where("depth", pathDepth);
|
||||
|
||||
const firstFolder = folders[0];
|
||||
|
||||
if (firstFolder && firstFolder.path !== removeTrailingSlash(path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return folders.map((folder) => {
|
||||
const { envId: id, envName: name, envSlug: slug, ...el } = folder;
|
||||
return { ...el, envId: id, environment: { id, name, slug } };
|
||||
});
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find folders by secret path multi env" });
|
||||
}
|
||||
};
|
||||
|
||||
// used in folder creation
|
||||
// even if its the original given /path1/path2
|
||||
// it will stop automatically at /path2
|
||||
@@ -238,7 +269,7 @@ export const secretFolderDALFactory = (db: TDbClient) => {
|
||||
const folder = await sqlFindFolderByPathQuery(
|
||||
tx || db.replicaNode(),
|
||||
projectId,
|
||||
environment,
|
||||
[environment],
|
||||
removeTrailingSlash(path)
|
||||
)
|
||||
.orderBy("depth", "desc")
|
||||
@@ -352,14 +383,77 @@ export const secretFolderDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
// find project folders for multiple envs
|
||||
const findByMultiEnv = async (
|
||||
{
|
||||
environmentIds,
|
||||
parentIds,
|
||||
search,
|
||||
limit,
|
||||
offset = 0,
|
||||
orderBy = SecretsOrderBy.Name,
|
||||
orderDirection = OrderByDirection.ASC
|
||||
}: {
|
||||
environmentIds: string[];
|
||||
parentIds: string[];
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: SecretsOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
},
|
||||
tx?: Knex
|
||||
) => {
|
||||
try {
|
||||
const query = (tx || db.replicaNode())(TableName.SecretFolder)
|
||||
.whereIn("parentId", parentIds)
|
||||
.whereIn("envId", environmentIds)
|
||||
.where("isReserved", false)
|
||||
.where((bd) => {
|
||||
if (search) {
|
||||
void bd.whereILike(`${TableName.SecretFolder}.name`, `%${search}%`);
|
||||
}
|
||||
})
|
||||
.leftJoin(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolder}.envId`)
|
||||
.select(
|
||||
selectAllTableCols(TableName.SecretFolder),
|
||||
db.raw(
|
||||
`DENSE_RANK() OVER (ORDER BY ${TableName.SecretFolder}."name" ${
|
||||
orderDirection ?? OrderByDirection.ASC
|
||||
}) as rank`
|
||||
),
|
||||
db.ref("slug").withSchema(TableName.Environment).as("environment")
|
||||
)
|
||||
.orderBy(`${TableName.SecretFolder}.${orderBy}`, orderDirection);
|
||||
|
||||
if (limit) {
|
||||
const rankOffset = offset + 1; // ranks start from 1
|
||||
return await (tx || db)
|
||||
.with("w", query)
|
||||
.select("*")
|
||||
.from<Awaited<typeof query>[number]>("w")
|
||||
.where("w.rank", ">=", rankOffset)
|
||||
.andWhere("w.rank", "<", rankOffset + limit);
|
||||
}
|
||||
|
||||
const folders = await query;
|
||||
|
||||
return folders;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find folders multi env" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...secretFolderOrm,
|
||||
update,
|
||||
findBySecretPath,
|
||||
findBySecretPathMultiEnv,
|
||||
findById,
|
||||
findByManySecretPath,
|
||||
findSecretPathByFolderIds,
|
||||
findClosestFolder,
|
||||
findByProjectId
|
||||
findByProjectId,
|
||||
findByMultiEnv
|
||||
};
|
||||
};
|
||||
|
@@ -7,6 +7,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { OrderByDirection } from "@app/lib/types";
|
||||
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
@@ -26,7 +27,7 @@ type TSecretFolderServiceFactoryDep = {
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
|
||||
folderDAL: TSecretFolderDALFactory;
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "findBySlugs">;
|
||||
folderVersionDAL: TSecretFolderVersionDALFactory;
|
||||
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
|
||||
};
|
||||
@@ -396,7 +397,12 @@ export const secretFolderServiceFactory = ({
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
environment,
|
||||
path: secretPath
|
||||
path: secretPath,
|
||||
search,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
limit,
|
||||
offset
|
||||
}: TGetFolderDTO) => {
|
||||
// folder list is allowed to be read by anyone
|
||||
// permission to check does user has access
|
||||
@@ -408,11 +414,92 @@ export const secretFolderServiceFactory = ({
|
||||
const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!parentFolder) return [];
|
||||
|
||||
const folders = await folderDAL.find({ envId: env.id, parentId: parentFolder.id, isReserved: false });
|
||||
const folders = await folderDAL.find(
|
||||
{
|
||||
envId: env.id,
|
||||
parentId: parentFolder.id,
|
||||
isReserved: false,
|
||||
$search: search ? { name: `%${search}%` } : undefined
|
||||
},
|
||||
{
|
||||
sort: orderBy ? [[orderBy, orderDirection ?? OrderByDirection.ASC]] : undefined,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
);
|
||||
return folders;
|
||||
};
|
||||
|
||||
// get folders for multiple envs
|
||||
const getFoldersMultiEnv = async ({
|
||||
projectId,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
environments,
|
||||
path: secretPath,
|
||||
...params
|
||||
}: Omit<TGetFolderDTO, "environment"> & { environments: string[] }) => {
|
||||
// folder list is allowed to be read by anyone
|
||||
// permission to check does user has access
|
||||
await permissionService.getProjectPermission(actor, actorId, projectId, actorAuthMethod, actorOrgId);
|
||||
|
||||
const envs = await projectEnvDAL.findBySlugs(projectId, environments);
|
||||
|
||||
if (!envs.length)
|
||||
throw new BadRequestError({ message: "Environment(s) not found", name: "get project folder count" });
|
||||
|
||||
const parentFolders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, secretPath);
|
||||
if (!parentFolders.length) return [];
|
||||
|
||||
const folders = await folderDAL.findByMultiEnv({
|
||||
environmentIds: envs.map((env) => env.id),
|
||||
parentIds: parentFolders.map((folder) => folder.id),
|
||||
...params
|
||||
});
|
||||
|
||||
return folders;
|
||||
};
|
||||
|
||||
// get the unique count of folders within a project path
|
||||
const getProjectFolderCount = async ({
|
||||
projectId,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
environments,
|
||||
path: secretPath,
|
||||
search
|
||||
}: Omit<TGetFolderDTO, "environment"> & { environments: string[] }) => {
|
||||
// folder list is allowed to be read by anyone
|
||||
// permission to check does user has access
|
||||
await permissionService.getProjectPermission(actor, actorId, projectId, actorAuthMethod, actorOrgId);
|
||||
|
||||
const envs = await projectEnvDAL.findBySlugs(projectId, environments);
|
||||
|
||||
if (!envs.length)
|
||||
throw new BadRequestError({ message: "Environment(s) not found", name: "get project folder count" });
|
||||
|
||||
const parentFolders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, secretPath);
|
||||
if (!parentFolders.length) return 0;
|
||||
|
||||
const folders = await folderDAL.find(
|
||||
{
|
||||
$in: {
|
||||
envId: envs.map((env) => env.id),
|
||||
parentId: parentFolders.map((folder) => folder.id)
|
||||
},
|
||||
isReserved: false,
|
||||
$search: search ? { name: `%${search}%` } : undefined
|
||||
},
|
||||
{ countDistinct: "name" }
|
||||
);
|
||||
|
||||
return Number(folders[0]?.count ?? 0);
|
||||
};
|
||||
|
||||
const getFolderById = async ({ actor, actorId, actorOrgId, actorAuthMethod, id }: TGetFolderByIdDTO) => {
|
||||
const folder = await folderDAL.findById(id);
|
||||
if (!folder) throw new NotFoundError({ message: "folder not found" });
|
||||
@@ -429,6 +516,8 @@ export const secretFolderServiceFactory = ({
|
||||
updateManyFolders,
|
||||
deleteFolder,
|
||||
getFolders,
|
||||
getFolderById
|
||||
getFolderById,
|
||||
getProjectFolderCount,
|
||||
getFoldersMultiEnv
|
||||
};
|
||||
};
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
|
||||
import { SecretsOrderBy } from "@app/services/secret/secret-types";
|
||||
|
||||
export enum ReservedFolders {
|
||||
SecretReplication = "__reserve_replication_"
|
||||
@@ -36,6 +37,11 @@ export type TDeleteFolderDTO = {
|
||||
export type TGetFolderDTO = {
|
||||
environment: string;
|
||||
path: string;
|
||||
search?: string;
|
||||
orderBy?: SecretsOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TGetFolderByIdDTO = {
|
||||
|
@@ -49,10 +49,30 @@ export const secretImportDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
const find = async (filter: Partial<TSecretImports & { projectId: string }>, tx?: Knex) => {
|
||||
const find = async (
|
||||
{
|
||||
search,
|
||||
limit,
|
||||
offset,
|
||||
...filter
|
||||
}: Partial<
|
||||
TSecretImports & {
|
||||
projectId: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
>,
|
||||
tx?: Knex
|
||||
) => {
|
||||
try {
|
||||
const docs = await (tx || db.replicaNode())(TableName.SecretImport)
|
||||
const query = (tx || db.replicaNode())(TableName.SecretImport)
|
||||
.where(filter)
|
||||
.where((bd) => {
|
||||
if (search) {
|
||||
void bd.whereILike("importPath", `%${search}%`);
|
||||
}
|
||||
})
|
||||
.join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`)
|
||||
.select(
|
||||
db.ref("*").withSchema(TableName.SecretImport) as unknown as keyof TSecretImports,
|
||||
@@ -61,6 +81,13 @@ export const secretImportDALFactory = (db: TDbClient) => {
|
||||
db.ref("id").withSchema(TableName.Environment).as("envId")
|
||||
)
|
||||
.orderBy("position", "asc");
|
||||
|
||||
if (limit) {
|
||||
void query.limit(limit).offset(offset ?? 0);
|
||||
}
|
||||
|
||||
const docs = await query;
|
||||
|
||||
return docs.map(({ envId, slug, name, ...el }) => ({
|
||||
...el,
|
||||
importEnv: { id: envId, slug, name }
|
||||
@@ -70,6 +97,28 @@ export const secretImportDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getProjectImportCount = async (
|
||||
{ search, ...filter }: Partial<TSecretImports & { projectId: string; search?: string }>,
|
||||
tx?: Knex
|
||||
) => {
|
||||
try {
|
||||
const docs = await (tx || db.replicaNode())(TableName.SecretImport)
|
||||
.where(filter)
|
||||
.where("isReplication", false)
|
||||
.where((bd) => {
|
||||
if (search) {
|
||||
void bd.whereILike("importPath", `%${search}%`);
|
||||
}
|
||||
})
|
||||
.join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`)
|
||||
.count();
|
||||
|
||||
return Number(docs[0]?.count ?? 0);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "get secret imports count" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByFolderIds = async (folderIds: string[], tx?: Knex) => {
|
||||
try {
|
||||
const docs = await (tx || db.replicaNode())(TableName.SecretImport)
|
||||
@@ -97,6 +146,7 @@ export const secretImportDALFactory = (db: TDbClient) => {
|
||||
find,
|
||||
findByFolderIds,
|
||||
findLastImportPosition,
|
||||
updateAllPosition
|
||||
updateAllPosition,
|
||||
getProjectImportCount
|
||||
};
|
||||
};
|
||||
|
@@ -220,7 +220,7 @@ export const fnSecretsV2FromImports = async ({
|
||||
const secretsFromdeeperImportGroupedByFolderId = groupBy(secretsFromDeeperImports, (i) => i.importFolderId);
|
||||
|
||||
const processedImports = allowedImports.map(({ importPath, importEnv, id, folderId }, i) => {
|
||||
const sourceImportFolder = importedFolderGroupBySourceImport[`${importEnv.id}-${importPath}`][0];
|
||||
const sourceImportFolder = importedFolderGroupBySourceImport[`${importEnv.id}-${importPath}`]?.[0];
|
||||
const folderDeeperImportSecrets =
|
||||
secretsFromdeeperImportGroupedByFolderId?.[sourceImportFolder?.id || ""]?.[0]?.secrets || [];
|
||||
const secretsWithDuplicate = (importedSecretsGroupByFolderId?.[importedFolders?.[i]?.id as string] || [])
|
||||
|
@@ -7,7 +7,7 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { getReplicationFolderName } from "@app/ee/services/secret-replication/secret-replication-service";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
|
||||
import { TKmsServiceFactory } from "../kms/kms-service";
|
||||
import { KmsDataKey } from "../kms/kms-types";
|
||||
@@ -394,6 +394,36 @@ export const secretImportServiceFactory = ({
|
||||
return { message: "replication started" };
|
||||
};
|
||||
|
||||
const getProjectImportCount = async ({
|
||||
path: secretPath,
|
||||
environment,
|
||||
projectId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
search
|
||||
}: TGetSecretImportsDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!folder) throw new NotFoundError({ message: "Folder not found", name: "Get imports" });
|
||||
|
||||
const count = await secretImportDAL.getProjectImportCount({ folderId: folder.id, search });
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
const getImports = async ({
|
||||
path: secretPath,
|
||||
environment,
|
||||
@@ -401,7 +431,10 @@ export const secretImportServiceFactory = ({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
actorOrgId,
|
||||
search,
|
||||
limit,
|
||||
offset
|
||||
}: TGetSecretImportsDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
@@ -418,7 +451,7 @@ export const secretImportServiceFactory = ({
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Get imports" });
|
||||
|
||||
const secImports = await secretImportDAL.find({ folderId: folder.id });
|
||||
const secImports = await secretImportDAL.find({ folderId: folder.id, search, limit, offset });
|
||||
return secImports;
|
||||
};
|
||||
|
||||
@@ -535,6 +568,7 @@ export const secretImportServiceFactory = ({
|
||||
getSecretsFromImports,
|
||||
getRawSecretsFromImports,
|
||||
resyncSecretImportReplication,
|
||||
getProjectImportCount,
|
||||
fnSecretsFromImports
|
||||
};
|
||||
};
|
||||
|
@@ -32,6 +32,9 @@ export type TDeleteSecretImportDTO = {
|
||||
export type TGetSecretImportsDTO = {
|
||||
environment: string;
|
||||
path: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TGetSecretsFromImportDTO = {
|
||||
|
@@ -5,6 +5,8 @@ import { TDbClient } from "@app/db";
|
||||
import { SecretsV2Schema, SecretType, TableName, TSecretsV2, TSecretsV2Update } from "@app/db/schemas";
|
||||
import { BadRequestError, DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
|
||||
import { OrderByDirection } from "@app/lib/types";
|
||||
import { SecretsOrderBy } from "@app/services/secret/secret-types";
|
||||
|
||||
export type TSecretV2BridgeDALFactory = ReturnType<typeof secretV2BridgeDALFactory>;
|
||||
|
||||
@@ -181,7 +183,16 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
const findByFolderIds = async (folderIds: string[], userId?: string, tx?: Knex) => {
|
||||
// get unique secret count by folder IDs
|
||||
const countByFolderIds = async (
|
||||
folderIds: string[],
|
||||
userId?: string,
|
||||
tx?: Knex,
|
||||
filters?: {
|
||||
search?: string;
|
||||
tagSlugs?: string[];
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
// check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo)
|
||||
if (userId && !uuidValidate(userId)) {
|
||||
@@ -189,8 +200,70 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
|
||||
userId = undefined;
|
||||
}
|
||||
|
||||
const secs = await (tx || db.replicaNode())(TableName.SecretV2)
|
||||
const query = (tx || db.replicaNode())(TableName.SecretV2)
|
||||
.whereIn("folderId", folderIds)
|
||||
.where((bd) => {
|
||||
if (filters?.search) {
|
||||
void bd.whereILike("key", `%${filters?.search}%`);
|
||||
}
|
||||
})
|
||||
.where((bd) => {
|
||||
void bd.whereNull("userId").orWhere({ userId: userId || null });
|
||||
})
|
||||
.countDistinct("key");
|
||||
|
||||
// only need to join tags if filtering by tag slugs
|
||||
const slugs = filters?.tagSlugs?.filter(Boolean);
|
||||
if (slugs && slugs.length > 0) {
|
||||
void query
|
||||
.leftJoin(
|
||||
TableName.SecretV2JnTag,
|
||||
`${TableName.SecretV2}.id`,
|
||||
`${TableName.SecretV2JnTag}.${TableName.SecretV2}Id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.SecretTag,
|
||||
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
|
||||
`${TableName.SecretTag}.id`
|
||||
)
|
||||
.whereIn("slug", slugs);
|
||||
}
|
||||
|
||||
const secrets = await query;
|
||||
|
||||
return Number(secrets[0]?.count ?? 0);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "get folder secret count" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByFolderIds = async (
|
||||
folderIds: string[],
|
||||
userId?: string,
|
||||
tx?: Knex,
|
||||
filters?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
orderBy?: SecretsOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
search?: string;
|
||||
tagSlugs?: string[];
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
// check if not uui then userId id is null (corner case because service token's ID is not UUI in effort to keep backwards compatibility from mongo)
|
||||
if (userId && !uuidValidate(userId)) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
userId = undefined;
|
||||
}
|
||||
|
||||
const query = (tx || db.replicaNode())(TableName.SecretV2)
|
||||
.whereIn("folderId", folderIds)
|
||||
.where((bd) => {
|
||||
if (filters?.search) {
|
||||
void bd.whereILike("key", `%${filters?.search}%`);
|
||||
}
|
||||
})
|
||||
.where((bd) => {
|
||||
void bd.whereNull("userId").orWhere({ userId: userId || null });
|
||||
})
|
||||
@@ -204,11 +277,37 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
|
||||
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
|
||||
`${TableName.SecretTag}.id`
|
||||
)
|
||||
.select(selectAllTableCols(TableName.SecretV2))
|
||||
.select(
|
||||
selectAllTableCols(TableName.SecretV2),
|
||||
db.raw(`DENSE_RANK() OVER (ORDER BY "key" ${filters?.orderDirection ?? OrderByDirection.ASC}) as rank`)
|
||||
)
|
||||
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
|
||||
.select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
|
||||
.select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"))
|
||||
.orderBy("id", "asc");
|
||||
.where((bd) => {
|
||||
const slugs = filters?.tagSlugs?.filter(Boolean);
|
||||
if (slugs && slugs.length > 0) {
|
||||
void bd.whereIn("slug", slugs);
|
||||
}
|
||||
})
|
||||
.orderBy(
|
||||
filters?.orderBy === SecretsOrderBy.Name ? "key" : "id",
|
||||
filters?.orderDirection ?? OrderByDirection.ASC
|
||||
);
|
||||
|
||||
let secs: Awaited<typeof query>;
|
||||
|
||||
if (filters?.limit) {
|
||||
const rankOffset = (filters?.offset ?? 0) + 1; // ranks start at 1
|
||||
secs = await (tx || db)
|
||||
.with("w", query)
|
||||
.select("*")
|
||||
.from<Awaited<typeof query>[number]>("w")
|
||||
.where("w.rank", ">=", rankOffset)
|
||||
.andWhere("w.rank", "<", rankOffset + filters.limit);
|
||||
} else {
|
||||
secs = await query;
|
||||
}
|
||||
|
||||
const data = sqlNestRelationships({
|
||||
data: secs,
|
||||
@@ -384,6 +483,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => {
|
||||
findBySecretKeys,
|
||||
upsertSecretReferences,
|
||||
findReferencedSecretReferences,
|
||||
findAllProjectSecretValues
|
||||
findAllProjectSecretValues,
|
||||
countByFolderIds
|
||||
};
|
||||
};
|
||||
|
@@ -59,7 +59,7 @@ type TSecretV2BridgeServiceFactoryDep = {
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
|
||||
folderDAL: Pick<
|
||||
TSecretFolderDALFactory,
|
||||
"findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find"
|
||||
"findBySecretPath" | "updateById" | "findById" | "findByManySecretPath" | "find" | "findBySecretPathMultiEnv"
|
||||
>;
|
||||
secretImportDAL: Pick<TSecretImportDALFactory, "find" | "findByFolderIds">;
|
||||
secretQueueService: Pick<TSecretQueueFactory, "syncSecrets" | "handleSecretReminder" | "removeSecretReminder">;
|
||||
@@ -431,6 +431,165 @@ export const secretV2BridgeServiceFactory = ({
|
||||
});
|
||||
};
|
||||
|
||||
// get unique secrets count for multiple envs
|
||||
const getSecretsCountMultiEnv = async ({
|
||||
actorId,
|
||||
path,
|
||||
|
||||
projectId,
|
||||
actor,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
environments,
|
||||
...params
|
||||
}: Pick<TGetSecretsDTO, "actorId" | "actor" | "path" | "projectId" | "actorOrgId" | "actorAuthMethod" | "search"> & {
|
||||
environments: string[];
|
||||
}) => {
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
// verify user has access to all environments
|
||||
environments.forEach((environment) =>
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
)
|
||||
);
|
||||
|
||||
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path);
|
||||
if (!folders.length) return 0;
|
||||
|
||||
const count = await secretDAL.countByFolderIds(
|
||||
folders.map((folder) => folder.id),
|
||||
actorId,
|
||||
undefined,
|
||||
params
|
||||
);
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
// get secret count for individual env
|
||||
const getSecretsCount = async ({
|
||||
actorId,
|
||||
path,
|
||||
environment,
|
||||
projectId,
|
||||
actor,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
...params
|
||||
}: Pick<
|
||||
TGetSecretsDTO,
|
||||
| "actorId"
|
||||
| "actor"
|
||||
| "path"
|
||||
| "projectId"
|
||||
| "actorOrgId"
|
||||
| "actorAuthMethod"
|
||||
| "tagSlugs"
|
||||
| "environment"
|
||||
| "search"
|
||||
>) => {
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
);
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
|
||||
if (!folder) return 0;
|
||||
|
||||
const count = await secretDAL.countByFolderIds([folder.id], actorId, undefined, params);
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
// get secrets for multiple envs
|
||||
const getSecretsMultiEnv = async ({
|
||||
actorId,
|
||||
path,
|
||||
environments,
|
||||
projectId,
|
||||
actor,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
...params
|
||||
}: Pick<TGetSecretsDTO, "actorId" | "actor" | "path" | "projectId" | "actorOrgId" | "actorAuthMethod" | "search"> & {
|
||||
environments: string[];
|
||||
}) => {
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
);
|
||||
|
||||
let paths: { folderId: string; path: string; environment: string }[] = [];
|
||||
|
||||
// verify user has access to all environments
|
||||
environments.forEach((environment) =>
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: path })
|
||||
)
|
||||
);
|
||||
|
||||
const folders = await folderDAL.findBySecretPathMultiEnv(projectId, environments, path);
|
||||
|
||||
if (!folders.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
paths = folders.map((folder) => ({ folderId: folder.id, path, environment: folder.environment.slug }));
|
||||
|
||||
const groupedPaths = groupBy(paths, (p) => p.folderId);
|
||||
|
||||
const secrets = await secretDAL.findByFolderIds(
|
||||
paths.map((p) => p.folderId),
|
||||
actorId,
|
||||
undefined,
|
||||
params
|
||||
);
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
const decryptedSecrets = secrets.map((secret) =>
|
||||
reshapeBridgeSecret(
|
||||
projectId,
|
||||
groupedPaths[secret.folderId][0].environment,
|
||||
groupedPaths[secret.folderId][0].path,
|
||||
{
|
||||
...secret,
|
||||
value: secret.encryptedValue
|
||||
? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString()
|
||||
: "",
|
||||
comment: secret.encryptedComment
|
||||
? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString()
|
||||
: ""
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
return decryptedSecrets;
|
||||
};
|
||||
|
||||
const getSecrets = async ({
|
||||
actorId,
|
||||
path,
|
||||
@@ -441,8 +600,8 @@ export const secretV2BridgeServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
includeImports,
|
||||
recursive,
|
||||
tagSlugs = [],
|
||||
expandSecretReferences: shouldExpandSecretReferences
|
||||
expandSecretReferences: shouldExpandSecretReferences,
|
||||
...params
|
||||
}: TGetSecretsDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission(
|
||||
actor,
|
||||
@@ -490,7 +649,9 @@ export const secretV2BridgeServiceFactory = ({
|
||||
|
||||
const secrets = await secretDAL.findByFolderIds(
|
||||
paths.map((p) => p.folderId),
|
||||
actorId
|
||||
actorId,
|
||||
undefined,
|
||||
params
|
||||
);
|
||||
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
@@ -509,9 +670,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
: ""
|
||||
})
|
||||
);
|
||||
const filteredSecrets = tagSlugs.length
|
||||
? decryptedSecrets.filter((secret) => Boolean(secret.tags?.find((el) => tagSlugs.includes(el.slug))))
|
||||
: decryptedSecrets;
|
||||
|
||||
const expandSecretReferences = expandSecretReferencesFactory({
|
||||
projectId,
|
||||
folderDAL,
|
||||
@@ -520,7 +679,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
});
|
||||
|
||||
if (shouldExpandSecretReferences) {
|
||||
const secretsGroupByPath = groupBy(filteredSecrets, (i) => i.secretPath);
|
||||
const secretsGroupByPath = groupBy(decryptedSecrets, (i) => i.secretPath);
|
||||
await Promise.allSettled(
|
||||
Object.keys(secretsGroupByPath).map((groupedPath) =>
|
||||
Promise.allSettled(
|
||||
@@ -541,7 +700,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
|
||||
if (!includeImports) {
|
||||
return {
|
||||
secrets: filteredSecrets
|
||||
secrets: decryptedSecrets
|
||||
};
|
||||
}
|
||||
|
||||
@@ -569,7 +728,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
});
|
||||
|
||||
return {
|
||||
secrets: filteredSecrets,
|
||||
secrets: decryptedSecrets,
|
||||
imports: importedSecrets
|
||||
};
|
||||
};
|
||||
@@ -1416,6 +1575,9 @@ export const secretV2BridgeServiceFactory = ({
|
||||
getSecrets,
|
||||
getSecretVersions,
|
||||
backfillSecretReferences,
|
||||
moveSecrets
|
||||
moveSecrets,
|
||||
getSecretsCount,
|
||||
getSecretsCountMultiEnv,
|
||||
getSecretsMultiEnv
|
||||
};
|
||||
};
|
||||
|
@@ -1,8 +1,9 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { SecretType, TSecretsV2, TSecretsV2Insert, TSecretsV2Update } from "@app/db/schemas";
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { SecretsOrderBy } from "@app/services/secret/secret-types";
|
||||
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
|
||||
|
||||
@@ -21,6 +22,11 @@ export type TGetSecretsDTO = {
|
||||
includeImports?: boolean;
|
||||
recursive?: boolean;
|
||||
tagSlugs?: string[];
|
||||
orderBy?: SecretsOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TGetASecretDTO = {
|
||||
|
@@ -1,7 +1,13 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { ProjectUpgradeStatus, ProjectVersion, TSecretSnapshotSecretsV2, TSecretVersionsV2 } from "@app/db/schemas";
|
||||
import {
|
||||
ProjectMembershipRole,
|
||||
ProjectUpgradeStatus,
|
||||
ProjectVersion,
|
||||
TSecretSnapshotSecretsV2,
|
||||
TSecretVersionsV2
|
||||
} from "@app/db/schemas";
|
||||
import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
|
||||
import { Actor, EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal";
|
||||
@@ -50,7 +56,9 @@ import { TSecretDALFactory } from "./secret-dal";
|
||||
import { interpolateSecrets } from "./secret-fns";
|
||||
import {
|
||||
TCreateSecretReminderDTO,
|
||||
TFailedIntegrationSyncEmailsPayload,
|
||||
THandleReminderDTO,
|
||||
TIntegrationSyncPayload,
|
||||
TRemoveSecretReminderDTO,
|
||||
TSyncSecretsDTO
|
||||
} from "./secret-types";
|
||||
@@ -509,6 +517,19 @@ export const secretQueueFactory = ({
|
||||
);
|
||||
};
|
||||
|
||||
const sendFailedIntegrationSyncEmails = async (payload: TFailedIntegrationSyncEmailsPayload) => {
|
||||
const appCfg = getConfig();
|
||||
if (!appCfg.isSmtpConfigured) return;
|
||||
|
||||
await queueService.queue(QueueName.IntegrationSync, QueueJobs.SendFailedIntegrationSyncEmails, payload, {
|
||||
jobId: `send-failed-integration-sync-emails-${payload.projectId}-${payload.secretPath}-${payload.environmentSlug}`,
|
||||
delay: 1_000 * 60, // 1 minute
|
||||
|
||||
removeOnFail: true,
|
||||
removeOnComplete: true
|
||||
});
|
||||
};
|
||||
|
||||
queueService.start(QueueName.SecretSync, async (job) => {
|
||||
const {
|
||||
_deDupeQueue: deDupeQueue,
|
||||
@@ -554,327 +575,396 @@ export const secretQueueFactory = ({
|
||||
});
|
||||
|
||||
queueService.start(QueueName.IntegrationSync, async (job) => {
|
||||
const { environment, actorId, isManual, projectId, secretPath, depth = 1, deDupeQueue = {} } = job.data;
|
||||
if (depth > MAX_SYNC_SECRET_DEPTH) return;
|
||||
if (job.name === QueueJobs.SendFailedIntegrationSyncEmails) {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!folder) {
|
||||
throw new Error("Secret path not found");
|
||||
}
|
||||
const jobPayload = job.data as TFailedIntegrationSyncEmailsPayload;
|
||||
|
||||
// find all imports made with the given environment and secret path
|
||||
const linkSourceDto = {
|
||||
projectId,
|
||||
importEnv: folder.environment.id,
|
||||
importPath: secretPath,
|
||||
isReplication: false
|
||||
};
|
||||
const imports = await secretImportDAL.find(linkSourceDto);
|
||||
const projectMembers = await projectMembershipDAL.findAllProjectMembers(jobPayload.projectId);
|
||||
const project = await projectDAL.findById(jobPayload.projectId);
|
||||
|
||||
if (imports.length) {
|
||||
// keep calling sync secret for all the imports made
|
||||
const importedFolderIds = unique(imports, (i) => i.folderId).map(({ folderId }) => folderId);
|
||||
const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds);
|
||||
const foldersGroupedById = groupBy(importedFolders.filter(Boolean), (i) => i?.id as string);
|
||||
logger.info(
|
||||
`getIntegrationSecrets: Syncing secret due to link change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]`
|
||||
);
|
||||
await Promise.all(
|
||||
imports
|
||||
.filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0]?.path as string))
|
||||
// filter out already synced ones
|
||||
.filter(
|
||||
({ folderId }) =>
|
||||
!deDupeQueue[
|
||||
uniqueSecretQueueKey(
|
||||
foldersGroupedById[folderId][0]?.environmentSlug as string,
|
||||
foldersGroupedById[folderId][0]?.path as string
|
||||
)
|
||||
]
|
||||
)
|
||||
.map(({ folderId }) =>
|
||||
syncSecrets({
|
||||
projectId,
|
||||
secretPath: foldersGroupedById[folderId][0]?.path as string,
|
||||
environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string,
|
||||
_deDupeQueue: deDupeQueue,
|
||||
_depth: depth + 1,
|
||||
excludeReplication: true
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(projectId);
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId
|
||||
});
|
||||
let referencedFolderIds;
|
||||
if (shouldUseSecretV2Bridge) {
|
||||
const secretReferences = await secretV2BridgeDAL.findReferencedSecretReferences(
|
||||
projectId,
|
||||
folder.environment.slug,
|
||||
secretPath
|
||||
);
|
||||
referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId);
|
||||
} else {
|
||||
const secretReferences = await secretDAL.findReferencedSecretReferences(
|
||||
projectId,
|
||||
folder.environment.slug,
|
||||
secretPath
|
||||
);
|
||||
referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId);
|
||||
}
|
||||
if (referencedFolderIds.length) {
|
||||
const referencedFolders = await folderDAL.findSecretPathByFolderIds(projectId, referencedFolderIds);
|
||||
const referencedFoldersGroupedById = groupBy(referencedFolders.filter(Boolean), (i) => i?.id as string);
|
||||
logger.info(
|
||||
`getIntegrationSecrets: Syncing secret due to reference change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]`
|
||||
);
|
||||
await Promise.all(
|
||||
referencedFolderIds
|
||||
.filter((folderId) => Boolean(referencedFoldersGroupedById[folderId][0]?.path))
|
||||
// filter out already synced ones
|
||||
.filter(
|
||||
(folderId) =>
|
||||
!deDupeQueue[
|
||||
uniqueSecretQueueKey(
|
||||
referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
|
||||
referencedFoldersGroupedById[folderId][0]?.path as string
|
||||
)
|
||||
]
|
||||
)
|
||||
.map((folderId) =>
|
||||
syncSecrets({
|
||||
projectId,
|
||||
secretPath: referencedFoldersGroupedById[folderId][0]?.path as string,
|
||||
environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
|
||||
_deDupeQueue: deDupeQueue,
|
||||
_depth: depth + 1,
|
||||
excludeReplication: true
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const integrations = await integrationDAL.findByProjectIdV2(projectId, environment); // note: returns array of integrations + integration auths in this environment
|
||||
const toBeSyncedIntegrations = integrations.filter(
|
||||
// note: sync only the integrations sourced from secretPath
|
||||
({ secretPath: integrationSecPath, isActive }) => isActive && isSamePath(secretPath, integrationSecPath)
|
||||
);
|
||||
|
||||
if (!integrations.length) return;
|
||||
logger.info(
|
||||
`getIntegrationSecrets: secret integration sync started [jobId=${job.id}] [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${job.data.depth}]`
|
||||
);
|
||||
|
||||
const lock = await keyStore.acquireLock(
|
||||
[KeyStorePrefixes.SyncSecretIntegrationLock(projectId, environment, secretPath)],
|
||||
10000,
|
||||
{
|
||||
retryCount: 3,
|
||||
retryDelay: 2000
|
||||
}
|
||||
);
|
||||
const lockAcquiredTime = new Date();
|
||||
|
||||
const lastRunSyncIntegrationTimestamp = await keyStore.getItem(
|
||||
KeyStorePrefixes.SyncSecretIntegrationLastRunTimestamp(projectId, environment, secretPath)
|
||||
);
|
||||
|
||||
// check whether the integration should wait or not
|
||||
if (lastRunSyncIntegrationTimestamp) {
|
||||
const INTEGRATION_INTERVAL = 2000;
|
||||
const isStaleSyncIntegration = new Date(job.timestamp) < new Date(lastRunSyncIntegrationTimestamp);
|
||||
if (isStaleSyncIntegration) {
|
||||
logger.info(
|
||||
`getIntegrationSecrets: secret integration sync stale [jobId=${job.id}] [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${job.data.depth}]`
|
||||
// Only send emails to admins, and if its a manual trigger, only send it to the person who triggered it (if actor is admin as well)
|
||||
const filteredProjectMembers = projectMembers
|
||||
.filter((member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin))
|
||||
.filter((member) =>
|
||||
jobPayload.manuallyTriggeredByUserId ? member.userId === jobPayload.manuallyTriggeredByUserId : true
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeDifferenceWithLastIntegration = getTimeDifferenceInSeconds(
|
||||
lockAcquiredTime.toISOString(),
|
||||
lastRunSyncIntegrationTimestamp
|
||||
);
|
||||
if (timeDifferenceWithLastIntegration < INTEGRATION_INTERVAL && timeDifferenceWithLastIntegration > 0)
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 2000 - timeDifferenceWithLastIntegration * 1000);
|
||||
});
|
||||
await smtpService.sendMail({
|
||||
recipients: filteredProjectMembers.map((member) => member.user.email!),
|
||||
template: SmtpTemplates.IntegrationSyncFailed,
|
||||
subjectLine: `Integration Sync Failed`,
|
||||
substitutions: {
|
||||
syncMessage: jobPayload.count === 1 ? jobPayload.syncMessage : undefined, // We are only displaying the sync message if its a singular integration, so we can just grab the first one in the array.
|
||||
secretPath: jobPayload.secretPath,
|
||||
environment: jobPayload.environmentName,
|
||||
count: jobPayload.count,
|
||||
projectName: project.name,
|
||||
integrationUrl: `${appCfg.SITE_URL}/integrations/${project.id}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const generateActor = async (): Promise<Actor> => {
|
||||
if (isManual && actorId) {
|
||||
const user = await userDAL.findById(actorId);
|
||||
if (job.name === QueueJobs.IntegrationSync) {
|
||||
const {
|
||||
environment,
|
||||
actorId,
|
||||
isManual,
|
||||
projectId,
|
||||
secretPath,
|
||||
depth = 1,
|
||||
deDupeQueue = {}
|
||||
} = job.data as TIntegrationSyncPayload;
|
||||
if (depth > MAX_SYNC_SECRET_DEPTH) return;
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!folder) {
|
||||
throw new Error("Secret path not found");
|
||||
}
|
||||
|
||||
// find all imports made with the given environment and secret path
|
||||
const linkSourceDto = {
|
||||
projectId,
|
||||
importEnv: folder.environment.id,
|
||||
importPath: secretPath,
|
||||
isReplication: false
|
||||
};
|
||||
const imports = await secretImportDAL.find(linkSourceDto);
|
||||
|
||||
if (imports.length) {
|
||||
// keep calling sync secret for all the imports made
|
||||
const importedFolderIds = unique(imports, (i) => i.folderId).map(({ folderId }) => folderId);
|
||||
const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds);
|
||||
const foldersGroupedById = groupBy(importedFolders.filter(Boolean), (i) => i?.id as string);
|
||||
logger.info(
|
||||
`getIntegrationSecrets: Syncing secret due to link change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]`
|
||||
);
|
||||
await Promise.all(
|
||||
imports
|
||||
.filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0]?.path as string))
|
||||
// filter out already synced ones
|
||||
.filter(
|
||||
({ folderId }) =>
|
||||
!deDupeQueue[
|
||||
uniqueSecretQueueKey(
|
||||
foldersGroupedById[folderId][0]?.environmentSlug as string,
|
||||
foldersGroupedById[folderId][0]?.path as string
|
||||
)
|
||||
]
|
||||
)
|
||||
.map(({ folderId }) =>
|
||||
syncSecrets({
|
||||
projectId,
|
||||
secretPath: foldersGroupedById[folderId][0]?.path as string,
|
||||
environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string,
|
||||
_deDupeQueue: deDupeQueue,
|
||||
_depth: depth + 1,
|
||||
excludeReplication: true
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
const { shouldUseSecretV2Bridge, botKey } = await projectBotService.getBotKey(projectId);
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId
|
||||
});
|
||||
let referencedFolderIds;
|
||||
if (shouldUseSecretV2Bridge) {
|
||||
const secretReferences = await secretV2BridgeDAL.findReferencedSecretReferences(
|
||||
projectId,
|
||||
folder.environment.slug,
|
||||
secretPath
|
||||
);
|
||||
referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId);
|
||||
} else {
|
||||
const secretReferences = await secretDAL.findReferencedSecretReferences(
|
||||
projectId,
|
||||
folder.environment.slug,
|
||||
secretPath
|
||||
);
|
||||
referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId);
|
||||
}
|
||||
if (referencedFolderIds.length) {
|
||||
const referencedFolders = await folderDAL.findSecretPathByFolderIds(projectId, referencedFolderIds);
|
||||
const referencedFoldersGroupedById = groupBy(referencedFolders.filter(Boolean), (i) => i?.id as string);
|
||||
logger.info(
|
||||
`getIntegrationSecrets: Syncing secret due to reference change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]`
|
||||
);
|
||||
await Promise.all(
|
||||
referencedFolderIds
|
||||
.filter((folderId) => Boolean(referencedFoldersGroupedById[folderId][0]?.path))
|
||||
// filter out already synced ones
|
||||
.filter(
|
||||
(folderId) =>
|
||||
!deDupeQueue[
|
||||
uniqueSecretQueueKey(
|
||||
referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
|
||||
referencedFoldersGroupedById[folderId][0]?.path as string
|
||||
)
|
||||
]
|
||||
)
|
||||
.map((folderId) =>
|
||||
syncSecrets({
|
||||
projectId,
|
||||
secretPath: referencedFoldersGroupedById[folderId][0]?.path as string,
|
||||
environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string,
|
||||
_deDupeQueue: deDupeQueue,
|
||||
_depth: depth + 1,
|
||||
excludeReplication: true
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const integrations = await integrationDAL.findByProjectIdV2(projectId, environment); // note: returns array of integrations + integration auths in this environment
|
||||
const toBeSyncedIntegrations = integrations.filter(
|
||||
// note: sync only the integrations sourced from secretPath
|
||||
({ secretPath: integrationSecPath, isActive }) => isActive && isSamePath(secretPath, integrationSecPath)
|
||||
);
|
||||
|
||||
const integrationsFailedToSync: { integrationId: string; syncMessage?: string }[] = [];
|
||||
|
||||
if (!integrations.length) return;
|
||||
logger.info(
|
||||
`getIntegrationSecrets: secret integration sync started [jobId=${job.id}] [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]`
|
||||
);
|
||||
|
||||
const lock = await keyStore.acquireLock(
|
||||
[KeyStorePrefixes.SyncSecretIntegrationLock(projectId, environment, secretPath)],
|
||||
10000,
|
||||
{
|
||||
retryCount: 3,
|
||||
retryDelay: 2000
|
||||
}
|
||||
);
|
||||
const lockAcquiredTime = new Date();
|
||||
|
||||
const lastRunSyncIntegrationTimestamp = await keyStore.getItem(
|
||||
KeyStorePrefixes.SyncSecretIntegrationLastRunTimestamp(projectId, environment, secretPath)
|
||||
);
|
||||
|
||||
// check whether the integration should wait or not
|
||||
if (lastRunSyncIntegrationTimestamp) {
|
||||
const INTEGRATION_INTERVAL = 2000;
|
||||
const isStaleSyncIntegration = new Date(job.timestamp) < new Date(lastRunSyncIntegrationTimestamp);
|
||||
if (isStaleSyncIntegration) {
|
||||
logger.info(
|
||||
`getIntegrationSecrets: secret integration sync stale [jobId=${job.id}] [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeDifferenceWithLastIntegration = getTimeDifferenceInSeconds(
|
||||
lockAcquiredTime.toISOString(),
|
||||
lastRunSyncIntegrationTimestamp
|
||||
);
|
||||
if (timeDifferenceWithLastIntegration < INTEGRATION_INTERVAL && timeDifferenceWithLastIntegration > 0)
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 2000 - timeDifferenceWithLastIntegration * 1000);
|
||||
});
|
||||
}
|
||||
|
||||
const generateActor = async (): Promise<Actor> => {
|
||||
if (isManual && actorId) {
|
||||
const user = await userDAL.findById(actorId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
return {
|
||||
type: ActorType.USER,
|
||||
metadata: {
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
userId: user.id
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: ActorType.USER,
|
||||
metadata: {
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
userId: user.id
|
||||
}
|
||||
type: ActorType.PLATFORM,
|
||||
metadata: {}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: ActorType.PLATFORM,
|
||||
metadata: {}
|
||||
};
|
||||
};
|
||||
|
||||
// akhilmhdh: this try catch is for lock release
|
||||
try {
|
||||
const secrets = shouldUseSecretV2Bridge
|
||||
? await getIntegrationSecretsV2({
|
||||
environment,
|
||||
projectId,
|
||||
folderId: folder.id,
|
||||
depth: 1,
|
||||
secretPath,
|
||||
decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "")
|
||||
})
|
||||
: await getIntegrationSecrets({
|
||||
environment,
|
||||
projectId,
|
||||
folderId: folder.id,
|
||||
key: botKey as string,
|
||||
depth: 1,
|
||||
secretPath
|
||||
});
|
||||
// akhilmhdh: this try catch is for lock release
|
||||
try {
|
||||
const secrets = shouldUseSecretV2Bridge
|
||||
? await getIntegrationSecretsV2({
|
||||
environment,
|
||||
projectId,
|
||||
folderId: folder.id,
|
||||
depth: 1,
|
||||
secretPath,
|
||||
decryptor: (value) => (value ? secretManagerDecryptor({ cipherTextBlob: value }).toString() : "")
|
||||
})
|
||||
: await getIntegrationSecrets({
|
||||
environment,
|
||||
projectId,
|
||||
folderId: folder.id,
|
||||
key: botKey as string,
|
||||
depth: 1,
|
||||
secretPath
|
||||
});
|
||||
|
||||
for (const integration of toBeSyncedIntegrations) {
|
||||
const integrationAuth = {
|
||||
...integration.integrationAuth,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: integration.projectId
|
||||
};
|
||||
for (const integration of toBeSyncedIntegrations) {
|
||||
const integrationAuth = {
|
||||
...integration.integrationAuth,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: integration.projectId
|
||||
};
|
||||
|
||||
const { accessToken, accessId } = await integrationAuthService.getIntegrationAccessToken(
|
||||
integrationAuth,
|
||||
shouldUseSecretV2Bridge,
|
||||
botKey
|
||||
);
|
||||
let awsAssumeRoleArn = null;
|
||||
if (shouldUseSecretV2Bridge) {
|
||||
if (integrationAuth.encryptedAwsAssumeIamRoleArn) {
|
||||
awsAssumeRoleArn = secretManagerDecryptor({
|
||||
cipherTextBlob: Buffer.from(integrationAuth.encryptedAwsAssumeIamRoleArn)
|
||||
}).toString();
|
||||
}
|
||||
} else if (
|
||||
integrationAuth.awsAssumeIamRoleArnTag &&
|
||||
integrationAuth.awsAssumeIamRoleArnIV &&
|
||||
integrationAuth.awsAssumeIamRoleArnCipherText
|
||||
) {
|
||||
awsAssumeRoleArn = decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: integrationAuth.awsAssumeIamRoleArnCipherText,
|
||||
iv: integrationAuth.awsAssumeIamRoleArnIV,
|
||||
tag: integrationAuth.awsAssumeIamRoleArnTag,
|
||||
key: botKey as string
|
||||
});
|
||||
}
|
||||
|
||||
const suffixedSecrets: typeof secrets = {};
|
||||
const metadata = integration.metadata as Record<string, string>;
|
||||
if (metadata) {
|
||||
Object.keys(secrets).forEach((key) => {
|
||||
const prefix = metadata?.secretPrefix || "";
|
||||
const suffix = metadata?.secretSuffix || "";
|
||||
const newKey = prefix + key + suffix;
|
||||
suffixedSecrets[newKey] = secrets[key];
|
||||
});
|
||||
}
|
||||
|
||||
// akhilmhdh: this try catch is for catching integration error and saving it in db
|
||||
try {
|
||||
// akhilmhdh: this needs to changed later to be more easier to use
|
||||
// at present this is not at all extendable like to add a new parameter for just one integration need to modify multiple places
|
||||
const response = await syncIntegrationSecrets({
|
||||
createManySecretsRawFn,
|
||||
updateManySecretsRawFn,
|
||||
integrationDAL,
|
||||
integration,
|
||||
const { accessToken, accessId } = await integrationAuthService.getIntegrationAccessToken(
|
||||
integrationAuth,
|
||||
secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets,
|
||||
accessId: accessId as string,
|
||||
awsAssumeRoleArn,
|
||||
accessToken,
|
||||
projectId,
|
||||
appendices: {
|
||||
prefix: metadata?.secretPrefix || "",
|
||||
suffix: metadata?.secretSuffix || ""
|
||||
}
|
||||
});
|
||||
|
||||
await auditLogService.createAuditLog({
|
||||
projectId,
|
||||
actor: await generateActor(),
|
||||
event: {
|
||||
type: EventType.INTEGRATION_SYNCED,
|
||||
metadata: {
|
||||
integrationId: integration.id,
|
||||
isSynced: response?.isSynced ?? true,
|
||||
lastSyncJobId: job?.id ?? "",
|
||||
lastUsed: new Date(),
|
||||
syncMessage: response?.syncMessage ?? ""
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await integrationDAL.updateById(integration.id, {
|
||||
lastSyncJobId: job.id,
|
||||
lastUsed: new Date(),
|
||||
syncMessage: response?.syncMessage ?? "",
|
||||
isSynced: response?.isSynced ?? true
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
err,
|
||||
`Secret integration sync error [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}]`
|
||||
shouldUseSecretV2Bridge,
|
||||
botKey
|
||||
);
|
||||
|
||||
const message =
|
||||
(err instanceof AxiosError ? JSON.stringify(err?.response?.data) : (err as Error)?.message) ||
|
||||
"Unknown error occurred.";
|
||||
|
||||
await auditLogService.createAuditLog({
|
||||
projectId,
|
||||
actor: await generateActor(),
|
||||
event: {
|
||||
type: EventType.INTEGRATION_SYNCED,
|
||||
metadata: {
|
||||
integrationId: integration.id,
|
||||
isSynced: false,
|
||||
lastSyncJobId: job?.id ?? "",
|
||||
lastUsed: new Date(),
|
||||
syncMessage: message
|
||||
}
|
||||
let awsAssumeRoleArn = null;
|
||||
if (shouldUseSecretV2Bridge) {
|
||||
if (integrationAuth.encryptedAwsAssumeIamRoleArn) {
|
||||
awsAssumeRoleArn = secretManagerDecryptor({
|
||||
cipherTextBlob: Buffer.from(integrationAuth.encryptedAwsAssumeIamRoleArn)
|
||||
}).toString();
|
||||
}
|
||||
});
|
||||
} else if (
|
||||
integrationAuth.awsAssumeIamRoleArnTag &&
|
||||
integrationAuth.awsAssumeIamRoleArnIV &&
|
||||
integrationAuth.awsAssumeIamRoleArnCipherText
|
||||
) {
|
||||
awsAssumeRoleArn = decryptSymmetric128BitHexKeyUTF8({
|
||||
ciphertext: integrationAuth.awsAssumeIamRoleArnCipherText,
|
||||
iv: integrationAuth.awsAssumeIamRoleArnIV,
|
||||
tag: integrationAuth.awsAssumeIamRoleArnTag,
|
||||
key: botKey as string
|
||||
});
|
||||
}
|
||||
|
||||
await integrationDAL.updateById(integration.id, {
|
||||
lastSyncJobId: job.id,
|
||||
syncMessage: message,
|
||||
isSynced: false
|
||||
const suffixedSecrets: typeof secrets = {};
|
||||
const metadata = integration.metadata as Record<string, string>;
|
||||
if (metadata) {
|
||||
Object.keys(secrets).forEach((key) => {
|
||||
const prefix = metadata?.secretPrefix || "";
|
||||
const suffix = metadata?.secretSuffix || "";
|
||||
const newKey = prefix + key + suffix;
|
||||
suffixedSecrets[newKey] = secrets[key];
|
||||
});
|
||||
}
|
||||
|
||||
// akhilmhdh: this try catch is for catching integration error and saving it in db
|
||||
try {
|
||||
// akhilmhdh: this needs to changed later to be more easier to use
|
||||
// at present this is not at all extendable like to add a new parameter for just one integration need to modify multiple places
|
||||
const response = await syncIntegrationSecrets({
|
||||
createManySecretsRawFn,
|
||||
updateManySecretsRawFn,
|
||||
integrationDAL,
|
||||
integration,
|
||||
integrationAuth,
|
||||
secrets: Object.keys(suffixedSecrets).length !== 0 ? suffixedSecrets : secrets,
|
||||
accessId: accessId as string,
|
||||
awsAssumeRoleArn,
|
||||
accessToken,
|
||||
projectId,
|
||||
appendices: {
|
||||
prefix: metadata?.secretPrefix || "",
|
||||
suffix: metadata?.secretSuffix || ""
|
||||
}
|
||||
});
|
||||
|
||||
await auditLogService.createAuditLog({
|
||||
projectId,
|
||||
actor: await generateActor(),
|
||||
event: {
|
||||
type: EventType.INTEGRATION_SYNCED,
|
||||
metadata: {
|
||||
integrationId: integration.id,
|
||||
isSynced: response?.isSynced ?? true,
|
||||
lastSyncJobId: job?.id ?? "",
|
||||
lastUsed: new Date(),
|
||||
syncMessage: response?.syncMessage ?? ""
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await integrationDAL.updateById(integration.id, {
|
||||
lastSyncJobId: job.id,
|
||||
lastUsed: new Date(),
|
||||
syncMessage: response?.syncMessage ?? "",
|
||||
isSynced: response?.isSynced ?? true
|
||||
});
|
||||
|
||||
// May be undefined, if it's undefined we assume the sync was successful, hence the strict equality type check.
|
||||
if (response?.isSynced === false) {
|
||||
integrationsFailedToSync.push({
|
||||
integrationId: integration.id,
|
||||
syncMessage: response.syncMessage
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
err,
|
||||
`Secret integration sync error [projectId=${job.data.projectId}] [environment=${environment}] [secretPath=${job.data.secretPath}]`
|
||||
);
|
||||
|
||||
const message =
|
||||
(err instanceof AxiosError ? JSON.stringify(err?.response?.data) : (err as Error)?.message) ||
|
||||
"Unknown error occurred.";
|
||||
|
||||
await auditLogService.createAuditLog({
|
||||
projectId,
|
||||
actor: await generateActor(),
|
||||
event: {
|
||||
type: EventType.INTEGRATION_SYNCED,
|
||||
metadata: {
|
||||
integrationId: integration.id,
|
||||
isSynced: false,
|
||||
lastSyncJobId: job?.id ?? "",
|
||||
lastUsed: new Date(),
|
||||
syncMessage: message
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await integrationDAL.updateById(integration.id, {
|
||||
lastSyncJobId: job.id,
|
||||
syncMessage: message,
|
||||
isSynced: false
|
||||
});
|
||||
|
||||
integrationsFailedToSync.push({
|
||||
integrationId: integration.id,
|
||||
syncMessage: message
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await lock.release();
|
||||
if (integrationsFailedToSync.length) {
|
||||
await sendFailedIntegrationSyncEmails({
|
||||
count: integrationsFailedToSync.length,
|
||||
environmentName: folder.environment.name,
|
||||
environmentSlug: environment,
|
||||
...(isManual &&
|
||||
actorId && {
|
||||
manuallyTriggeredByUserId: actorId
|
||||
}),
|
||||
projectId,
|
||||
secretPath,
|
||||
syncMessage: integrationsFailedToSync[0].syncMessage
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
|
||||
await keyStore.setItemWithExpiry(
|
||||
KeyStorePrefixes.SyncSecretIntegrationLastRunTimestamp(projectId, environment, secretPath),
|
||||
KeyStoreTtls.SetSyncSecretIntegrationLastRunTimestampInSeconds,
|
||||
lockAcquiredTime.toISOString()
|
||||
);
|
||||
logger.info("Secret integration sync ended: %s", job.id);
|
||||
await keyStore.setItemWithExpiry(
|
||||
KeyStorePrefixes.SyncSecretIntegrationLastRunTimestamp(projectId, environment, secretPath),
|
||||
KeyStoreTtls.SetSyncSecretIntegrationLastRunTimestampInSeconds,
|
||||
lockAcquiredTime.toISOString()
|
||||
);
|
||||
logger.info("Secret integration sync ended: %s", job.id);
|
||||
}
|
||||
});
|
||||
|
||||
queueService.start(QueueName.SecretReminder, async ({ data }) => {
|
||||
|
@@ -954,6 +954,120 @@ export const secretServiceFactory = ({
|
||||
return secretsDeleted;
|
||||
};
|
||||
|
||||
const getSecretsCount = async ({
|
||||
projectId,
|
||||
path,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
environment,
|
||||
tagSlugs = [],
|
||||
...v2Params
|
||||
}: Pick<
|
||||
TGetSecretsRawDTO,
|
||||
| "projectId"
|
||||
| "path"
|
||||
| "actor"
|
||||
| "actorId"
|
||||
| "actorOrgId"
|
||||
| "actorAuthMethod"
|
||||
| "environment"
|
||||
| "tagSlugs"
|
||||
| "search"
|
||||
>) => {
|
||||
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
|
||||
|
||||
if (!shouldUseSecretV2Bridge)
|
||||
throw new BadRequestError({
|
||||
message: "Project version does not support pagination",
|
||||
name: "pagination_not_supported"
|
||||
});
|
||||
|
||||
const count = await secretV2BridgeService.getSecretsCount({
|
||||
projectId,
|
||||
actorId,
|
||||
actor,
|
||||
actorOrgId,
|
||||
environment,
|
||||
path,
|
||||
actorAuthMethod,
|
||||
tagSlugs,
|
||||
...v2Params
|
||||
});
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
const getSecretsCountMultiEnv = async ({
|
||||
projectId,
|
||||
path,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
environments,
|
||||
...v2Params
|
||||
}: Pick<
|
||||
TGetSecretsRawDTO,
|
||||
"projectId" | "path" | "actor" | "actorId" | "actorOrgId" | "actorAuthMethod" | "search"
|
||||
> & { environments: string[] }) => {
|
||||
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
|
||||
|
||||
if (!shouldUseSecretV2Bridge)
|
||||
throw new BadRequestError({
|
||||
message: "Project version does not support pagination",
|
||||
name: "pagination_not_supported"
|
||||
});
|
||||
|
||||
const count = await secretV2BridgeService.getSecretsCountMultiEnv({
|
||||
projectId,
|
||||
actorId,
|
||||
actor,
|
||||
actorOrgId,
|
||||
environments,
|
||||
path,
|
||||
actorAuthMethod,
|
||||
...v2Params
|
||||
});
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
const getSecretsRawMultiEnv = async ({
|
||||
projectId,
|
||||
path,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
environments,
|
||||
...params
|
||||
}: Omit<TGetSecretsRawDTO, "environment" | "includeImports" | "expandSecretReferences" | "recursive" | "tagSlugs"> & {
|
||||
environments: string[];
|
||||
}) => {
|
||||
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
|
||||
|
||||
if (!shouldUseSecretV2Bridge)
|
||||
throw new BadRequestError({
|
||||
message: "Project version does not support pagination",
|
||||
name: "pagination_not_supported"
|
||||
});
|
||||
|
||||
const secrets = await secretV2BridgeService.getSecretsMultiEnv({
|
||||
projectId,
|
||||
actorId,
|
||||
actor,
|
||||
actorOrgId,
|
||||
environments,
|
||||
path,
|
||||
actorAuthMethod,
|
||||
...params
|
||||
});
|
||||
|
||||
return secrets;
|
||||
};
|
||||
|
||||
const getSecretsRaw = async ({
|
||||
projectId,
|
||||
path,
|
||||
@@ -965,7 +1079,8 @@ export const secretServiceFactory = ({
|
||||
includeImports,
|
||||
expandSecretReferences,
|
||||
recursive,
|
||||
tagSlugs = []
|
||||
tagSlugs = [],
|
||||
...paramsV2
|
||||
}: TGetSecretsRawDTO) => {
|
||||
const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
|
||||
if (shouldUseSecretV2Bridge) {
|
||||
@@ -980,7 +1095,8 @@ export const secretServiceFactory = ({
|
||||
recursive,
|
||||
actorAuthMethod,
|
||||
includeImports,
|
||||
tagSlugs
|
||||
tagSlugs,
|
||||
...paramsV2
|
||||
});
|
||||
return { secrets, imports };
|
||||
}
|
||||
@@ -2693,6 +2809,9 @@ export const secretServiceFactory = ({
|
||||
getSecretVersions,
|
||||
backfillSecretReferences,
|
||||
moveSecrets,
|
||||
startSecretV2Migration
|
||||
startSecretV2Migration,
|
||||
getSecretsCount,
|
||||
getSecretsCountMultiEnv,
|
||||
getSecretsRawMultiEnv
|
||||
};
|
||||
};
|
||||
|
@@ -1,7 +1,8 @@
|
||||
import { Knex } from "knex";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretType, TSecretBlindIndexes, TSecrets, TSecretsInsert, TSecretsUpdate } from "@app/db/schemas";
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
|
||||
import { TSecretDALFactory } from "@app/services/secret/secret-dal";
|
||||
@@ -21,6 +22,29 @@ type TPartialSecret = Pick<TSecrets, "id" | "secretReminderRepeatDays" | "secret
|
||||
|
||||
type TPartialInputSecret = Pick<TSecrets, "type" | "secretReminderNote" | "secretReminderRepeatDays" | "id">;
|
||||
|
||||
export const FailedIntegrationSyncEmailsPayloadSchema = z.object({
|
||||
projectId: z.string(),
|
||||
secretPath: z.string(),
|
||||
environmentName: z.string(),
|
||||
environmentSlug: z.string(),
|
||||
|
||||
count: z.number(),
|
||||
syncMessage: z.string().optional(),
|
||||
manuallyTriggeredByUserId: z.string().optional()
|
||||
});
|
||||
|
||||
export type TFailedIntegrationSyncEmailsPayload = z.infer<typeof FailedIntegrationSyncEmailsPayloadSchema>;
|
||||
|
||||
export type TIntegrationSyncPayload = {
|
||||
isManual?: boolean;
|
||||
actorId?: string;
|
||||
projectId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
depth?: number;
|
||||
deDupeQueue?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type TCreateSecretDTO = {
|
||||
secretName: string;
|
||||
path: string;
|
||||
@@ -81,6 +105,8 @@ export type TGetSecretsDTO = {
|
||||
environment: string;
|
||||
includeImports?: boolean;
|
||||
recursive?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TGetASecretDTO = {
|
||||
@@ -143,6 +169,10 @@ export type TDeleteBulkSecretDTO = {
|
||||
}>;
|
||||
} & TProjectPermission;
|
||||
|
||||
export enum SecretsOrderBy {
|
||||
Name = "name" // "key" for secrets but using name for use across resources
|
||||
}
|
||||
|
||||
export type TGetSecretsRawDTO = {
|
||||
expandSecretReferences?: boolean;
|
||||
path: string;
|
||||
@@ -150,6 +180,11 @@ export type TGetSecretsRawDTO = {
|
||||
includeImports?: boolean;
|
||||
recursive?: boolean;
|
||||
tagSlugs?: string[];
|
||||
orderBy?: SecretsOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TGetASecretRawDTO = {
|
||||
|
@@ -33,7 +33,8 @@ export enum SmtpTemplates {
|
||||
SecretLeakIncident = "secretLeakIncident.handlebars",
|
||||
WorkspaceInvite = "workspaceInvitation.handlebars",
|
||||
ScimUserProvisioned = "scimUserProvisioned.handlebars",
|
||||
PkiExpirationAlert = "pkiExpirationAlert.handlebars"
|
||||
PkiExpirationAlert = "pkiExpirationAlert.handlebars",
|
||||
IntegrationSyncFailed = "integrationSyncFailed.handlebars"
|
||||
}
|
||||
|
||||
export enum SmtpHost {
|
||||
|
@@ -0,0 +1,31 @@
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge" />
|
||||
<title>Integration Sync Failed</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h2>Infisical</h2>
|
||||
|
||||
<div>
|
||||
<p>{{count}} integration(s) failed to sync.</p>
|
||||
<a href="{{integrationUrl}}">
|
||||
View your project integrations.
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<div>
|
||||
<p><strong>Project</strong>: {{projectName}}</p>
|
||||
<p><strong>Environment</strong>: {{environment}}</p>
|
||||
<p><strong>Secret Path</strong>: {{secretPath}}</p>
|
||||
</div>
|
||||
|
||||
{{#if syncMessage}}
|
||||
<p><b>Reason: </b>{{syncMessage}}</p>
|
||||
{{/if}}
|
||||
</body>
|
||||
|
||||
</html>
|
@@ -1,4 +1,4 @@
|
||||
---
|
||||
title: "Export"
|
||||
openapi: "GET /api/v1/workspace/{workspaceId}/audit-logs"
|
||||
openapi: "GET /api/v1/organization/audit-logs"
|
||||
---
|
||||
|
164
docs/documentation/platform/dynamic-secrets/azure-entra-id.mdx
Normal file
@@ -0,0 +1,164 @@
|
||||
---
|
||||
title: "Azure Entra Id"
|
||||
description: "Learn how to dynamically generate Azure Entra Id user credentials."
|
||||
---
|
||||
|
||||
The Infisical Azure Entra Id dynamic secret allows you to generate Azure Entra Id credentials on demand based on configured role.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
Login to [Microsoft Entra ID](https://entra.microsoft.com/)
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
Go to Overview, Copy and store `Tenant Id`
|
||||

|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
Go to Applications > App registrations. Click on New Registration.
|
||||

|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
Enter an application name. Click Register.
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
Copy and store `Application Id`.
|
||||

|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
Go to Clients and Secrets. Click on New Client Secret.
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
Enter a description, select expiry and click Add.
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
Copy and store `Client Secret` value.
|
||||

|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
Go to API Permissions. Click on Add a permission.
|
||||

|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
Click on Microsoft Graph.
|
||||

|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
Click on Application Permissions. Search and select `User.ReadWrite.All` and click Add permissions.
|
||||

|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
Click on Grant admin consent for app. Click yes to confirm.
|
||||

|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
Go to Dashboard. Click on show more.
|
||||

|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
Click on Roles & admins. Search for User Administrator and click on it.
|
||||

|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
Click on Add assignments. Search for the application name you created and select it. Click on Add.
|
||||

|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Set up Dynamic Secrets with Azure Entra ID
|
||||
|
||||
<Steps>
|
||||
<Step title="Open Secret Overview Dashboard">
|
||||
Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret.
|
||||
</Step>
|
||||
<Step title="Click on the 'Add Dynamic Secret' button">
|
||||

|
||||
</Step>
|
||||
<Step title="Select 'Azure Entra ID'">
|
||||

|
||||
</Step>
|
||||
<Step title="Provide the inputs for dynamic secret parameters">
|
||||
<ParamField path="Secret Prefix" type="string" required>
|
||||
Prefix for the secrets to be created
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Default TTL" type="string" required>
|
||||
Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate)
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Max TTL" type="string" required>
|
||||
Maximum time-to-live for a generated secret.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Tenant ID" type="string" required>
|
||||
The Tenant ID of your Azure Entra ID account.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Application ID" type="string" required>
|
||||
The Application ID of the application you created in Azure Entra ID.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Client Secret" type="string" required>
|
||||
The Client Secret of the application you created in Azure Entra ID.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Users" type="selection" required>
|
||||
Multi select list of users to generate secrets for.
|
||||
</ParamField>
|
||||
|
||||
</Step>
|
||||
<Step title="Click `Submit`">
|
||||
After submitting the form, you will see a dynamic secrets for each user created in the dashboard.
|
||||
</Step>
|
||||
|
||||
<Step title="Generate dynamic secrets">
|
||||
Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials.
|
||||
To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item.
|
||||
Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section.
|
||||
|
||||

|
||||

|
||||
|
||||
When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for.
|
||||
|
||||

|
||||
|
||||
<Tip>
|
||||
Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret.
|
||||
</Tip>
|
||||
|
||||
|
||||
Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you.
|
||||
|
||||

|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Audit or Revoke Leases
|
||||
Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard.
|
||||
This will allow you see the expiration time of the lease or delete a lease before it's set time to live.
|
||||
|
||||

|
||||
|
||||
## Renew Leases
|
||||
To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** as illustrated below.
|
||||

|
||||
|
||||
<Warning>
|
||||
Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret
|
||||
</Warning>
|
After Width: | Height: | Size: 432 KiB |
After Width: | Height: | Size: 501 KiB |
After Width: | Height: | Size: 584 KiB |
After Width: | Height: | Size: 603 KiB |
After Width: | Height: | Size: 724 KiB |
BIN
docs/images/platform/dynamic-secrets/dynamic-secret-ad-lease.png
Normal file
After Width: | Height: | Size: 103 KiB |
BIN
docs/images/platform/dynamic-secrets/dynamic-secret-ad-modal.png
Normal file
After Width: | Height: | Size: 157 KiB |
After Width: | Height: | Size: 395 KiB |
After Width: | Height: | Size: 772 KiB |
After Width: | Height: | Size: 186 KiB |
After Width: | Height: | Size: 681 KiB |
After Width: | Height: | Size: 565 KiB |
After Width: | Height: | Size: 524 KiB |
@@ -167,7 +167,8 @@
|
||||
"documentation/platform/dynamic-secrets/rabbit-mq",
|
||||
"documentation/platform/dynamic-secrets/aws-iam",
|
||||
"documentation/platform/dynamic-secrets/mongo-atlas",
|
||||
"documentation/platform/dynamic-secrets/mongo-db"
|
||||
"documentation/platform/dynamic-secrets/mongo-db",
|
||||
"documentation/platform/dynamic-secrets/azure-entra-id"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
36
frontend/src/components/features/FormLabelToolTip.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { FormLabel, Tooltip } from "../v2";
|
||||
|
||||
// To give users example of possible values of TTL
|
||||
export const FormLabelToolTip = ({ label, linkToMore, content }: { label: string, linkToMore: string, content: string }) => (
|
||||
<div>
|
||||
<FormLabel
|
||||
label={label}
|
||||
icon={
|
||||
<Tooltip
|
||||
content={
|
||||
<span>
|
||||
{content}{" "}
|
||||
<a
|
||||
href={linkToMore}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-700"
|
||||
>
|
||||
More
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faQuestionCircle}
|
||||
size="sm"
|
||||
className="relative bottom-1 right-1"
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
@@ -1,36 +1,12 @@
|
||||
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { FormLabel, Tooltip } from "../v2";
|
||||
import { FormLabelToolTip } from "./FormLabelToolTip";
|
||||
|
||||
// To give users example of possible values of TTL
|
||||
export const TtlFormLabel = ({ label }: { label: string }) => (
|
||||
<div>
|
||||
<FormLabel
|
||||
<FormLabelToolTip
|
||||
label={label}
|
||||
icon={
|
||||
<Tooltip
|
||||
content={
|
||||
<span>
|
||||
1m, 2h, 3d.{" "}
|
||||
<a
|
||||
href="https://github.com/vercel/ms?tab=readme-ov-file#examples"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-700"
|
||||
>
|
||||
More
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faQuestionCircle}
|
||||
size="sm"
|
||||
className="relative bottom-1 right-1"
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
content="1m, 2h, 3d. "
|
||||
linkToMore="https://github.com/vercel/ms?tab=readme-ov-file#examples"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@@ -100,7 +100,7 @@ export default function NavHeader({
|
||||
onValueChange={(value) => {
|
||||
if (value && onEnvChange) onEnvChange(value);
|
||||
}}
|
||||
className="bg-transparent pl-0 text-sm font-medium text-primary/80 hover:text-primary"
|
||||
className="border-none bg-transparent pl-0 text-sm font-medium text-primary/80 hover:text-primary"
|
||||
dropdownContainerClassName="text-bunker-200 bg-mineshaft-800 border border-mineshaft-600 drop-shadow-2xl"
|
||||
>
|
||||
{userAvailableEnvs?.map(({ name, slug }) => (
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import { ReactElement } from "react";
|
||||
import {
|
||||
faCaretDown,
|
||||
faCheck,
|
||||
@@ -23,6 +24,7 @@ export type PaginationProps = {
|
||||
onChangePerPage: (newRows: number) => void;
|
||||
className?: string;
|
||||
perPageList?: number[];
|
||||
startAdornment?: ReactElement;
|
||||
};
|
||||
|
||||
export const Pagination = ({
|
||||
@@ -32,7 +34,8 @@ export const Pagination = ({
|
||||
onChangePage,
|
||||
onChangePerPage,
|
||||
perPageList = [10, 20, 50, 100],
|
||||
className
|
||||
className,
|
||||
startAdornment
|
||||
}: PaginationProps) => {
|
||||
const prevPageNumber = Math.max(1, page - 1);
|
||||
const canGoPrev = page > 1;
|
||||
@@ -46,11 +49,12 @@ export const Pagination = ({
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex w-full items-center justify-end bg-mineshaft-800 py-3 px-4 text-white",
|
||||
"flex w-full items-center justify-end bg-mineshaft-800 py-3 px-4 text-white",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="mr-6 flex items-center space-x-2">
|
||||
{startAdornment}
|
||||
<div className="ml-auto mr-6 flex items-center space-x-2">
|
||||
<div className="text-xs">
|
||||
{(page - 1) * perPage + 1} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
|
||||
</div>
|
||||
|
@@ -21,7 +21,8 @@ export enum OrgPermissionSubjects {
|
||||
SecretScanning = "secret-scanning",
|
||||
Identity = "identity",
|
||||
Kms = "kms",
|
||||
AdminConsole = "organization-admin-console"
|
||||
AdminConsole = "organization-admin-console",
|
||||
AuditLogs = "audit-logs"
|
||||
}
|
||||
|
||||
export enum OrgPermissionAdminConsoleAction {
|
||||
@@ -43,6 +44,7 @@ export type OrgPermissionSet =
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Billing]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Identity]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.Kms]
|
||||
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole];
|
||||
| [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]
|
||||
| [OrgPermissionActions, OrgPermissionSubjects.AuditLogs];
|
||||
|
||||
export type TOrgPermission = MongoAbility<OrgPermissionSet>;
|
||||
|
@@ -8,6 +8,7 @@ export type TGetAuditLogsFilter = {
|
||||
userAgentType?: UserAgentType;
|
||||
eventMetadata?: Record<string, string>;
|
||||
actorType?: ActorType;
|
||||
projectId?: string;
|
||||
actorId?: string; // user ID format
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
@@ -885,7 +886,7 @@ export type AuditLog = {
|
||||
userAgentType: UserAgentType;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
project: {
|
||||
project?: {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
1
frontend/src/hooks/api/dashboard/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useGetProjectSecretsDetails } from "./queries";
|
261
frontend/src/hooks/api/dashboard/queries.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { apiRequest } from "@app/config/request";
|
||||
import {
|
||||
DashboardProjectSecretsDetails,
|
||||
DashboardProjectSecretsDetailsResponse,
|
||||
DashboardProjectSecretsOverview,
|
||||
DashboardProjectSecretsOverviewResponse,
|
||||
DashboardSecretsOrderBy,
|
||||
TGetDashboardProjectSecretsDetailsDTO,
|
||||
TGetDashboardProjectSecretsOverviewDTO
|
||||
} from "@app/hooks/api/dashboard/types";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { mergePersonalSecrets } from "@app/hooks/api/secrets/queries";
|
||||
|
||||
export const dashboardKeys = {
|
||||
all: () => ["dashboard"] as const,
|
||||
getDashboardSecrets: ({
|
||||
projectId,
|
||||
secretPath
|
||||
}: Pick<TGetDashboardProjectSecretsDetailsDTO, "projectId" | "secretPath">) =>
|
||||
[...dashboardKeys.all(), { projectId, secretPath }] as const,
|
||||
getProjectSecretsOverview: ({
|
||||
projectId,
|
||||
secretPath,
|
||||
...params
|
||||
}: TGetDashboardProjectSecretsOverviewDTO) =>
|
||||
[
|
||||
...dashboardKeys.getDashboardSecrets({ projectId, secretPath }),
|
||||
"secrets-overview",
|
||||
params
|
||||
] as const,
|
||||
getProjectSecretsDetails: ({
|
||||
projectId,
|
||||
secretPath,
|
||||
environment,
|
||||
...params
|
||||
}: TGetDashboardProjectSecretsDetailsDTO) =>
|
||||
[
|
||||
...dashboardKeys.getDashboardSecrets({ projectId, secretPath }),
|
||||
environment,
|
||||
"secrets-details",
|
||||
params
|
||||
] as const
|
||||
};
|
||||
|
||||
export const fetchProjectSecretsOverview = async ({
|
||||
includeFolders,
|
||||
includeSecrets,
|
||||
includeDynamicSecrets,
|
||||
environments,
|
||||
...params
|
||||
}: TGetDashboardProjectSecretsOverviewDTO) => {
|
||||
const { data } = await apiRequest.get<DashboardProjectSecretsOverviewResponse>(
|
||||
"/api/v3/dashboard/secrets-overview",
|
||||
{
|
||||
params: {
|
||||
...params,
|
||||
environments: encodeURIComponent(environments.join(",")),
|
||||
includeFolders: includeFolders ? "1" : "",
|
||||
includeSecrets: includeSecrets ? "1" : "",
|
||||
includeDynamicSecrets: includeDynamicSecrets ? "1" : ""
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const fetchProjectSecretsDetails = async ({
|
||||
includeFolders,
|
||||
includeImports,
|
||||
includeSecrets,
|
||||
includeDynamicSecrets,
|
||||
tags,
|
||||
...params
|
||||
}: TGetDashboardProjectSecretsDetailsDTO) => {
|
||||
const { data } = await apiRequest.get<DashboardProjectSecretsDetailsResponse>(
|
||||
"/api/v3/dashboard/secrets-details",
|
||||
{
|
||||
params: {
|
||||
...params,
|
||||
includeImports: includeImports ? "1" : "",
|
||||
includeFolders: includeFolders ? "1" : "",
|
||||
includeSecrets: includeSecrets ? "1" : "",
|
||||
includeDynamicSecrets: includeDynamicSecrets ? "1" : "",
|
||||
tags: encodeURIComponent(
|
||||
Object.entries(tags)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.map(([tag]) => tag)
|
||||
.join(",")
|
||||
)
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const useGetProjectSecretsOverview = (
|
||||
{
|
||||
projectId,
|
||||
secretPath,
|
||||
offset = 0,
|
||||
limit = 100,
|
||||
orderBy = DashboardSecretsOrderBy.Name,
|
||||
orderDirection = OrderByDirection.ASC,
|
||||
search = "",
|
||||
includeSecrets,
|
||||
includeFolders,
|
||||
includeDynamicSecrets,
|
||||
environments
|
||||
}: TGetDashboardProjectSecretsOverviewDTO,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
DashboardProjectSecretsOverviewResponse,
|
||||
unknown,
|
||||
DashboardProjectSecretsOverview,
|
||||
ReturnType<typeof dashboardKeys.getProjectSecretsOverview>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
...options,
|
||||
// wait for all values to be available
|
||||
enabled: Boolean(projectId) && (options?.enabled ?? true) && Boolean(environments.length),
|
||||
queryKey: dashboardKeys.getProjectSecretsOverview({
|
||||
secretPath,
|
||||
search,
|
||||
limit,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
offset,
|
||||
projectId,
|
||||
includeSecrets,
|
||||
includeFolders,
|
||||
includeDynamicSecrets,
|
||||
environments
|
||||
}),
|
||||
queryFn: () =>
|
||||
fetchProjectSecretsOverview({
|
||||
secretPath,
|
||||
search,
|
||||
limit,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
offset,
|
||||
projectId,
|
||||
includeSecrets,
|
||||
includeFolders,
|
||||
includeDynamicSecrets,
|
||||
environments
|
||||
}),
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const serverResponse = error.response?.data as { message: string };
|
||||
createNotification({
|
||||
title: "Error fetching secret details",
|
||||
type: "error",
|
||||
text: serverResponse.message
|
||||
});
|
||||
}
|
||||
},
|
||||
select: useCallback((data: Awaited<ReturnType<typeof fetchProjectSecretsOverview>>) => {
|
||||
const { secrets, ...select } = data;
|
||||
|
||||
return {
|
||||
...select,
|
||||
secrets: secrets ? mergePersonalSecrets(secrets) : undefined
|
||||
};
|
||||
}, []),
|
||||
keepPreviousData: true
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetProjectSecretsDetails = (
|
||||
{
|
||||
projectId,
|
||||
secretPath,
|
||||
environment,
|
||||
offset = 0,
|
||||
limit = 100,
|
||||
orderBy = DashboardSecretsOrderBy.Name,
|
||||
orderDirection = OrderByDirection.ASC,
|
||||
search = "",
|
||||
includeSecrets,
|
||||
includeFolders,
|
||||
includeImports,
|
||||
includeDynamicSecrets,
|
||||
tags
|
||||
}: TGetDashboardProjectSecretsDetailsDTO,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
DashboardProjectSecretsDetailsResponse,
|
||||
unknown,
|
||||
DashboardProjectSecretsDetails,
|
||||
ReturnType<typeof dashboardKeys.getProjectSecretsDetails>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
...options,
|
||||
// wait for all values to be available
|
||||
enabled: Boolean(projectId) && (options?.enabled ?? true),
|
||||
queryKey: dashboardKeys.getProjectSecretsDetails({
|
||||
secretPath,
|
||||
search,
|
||||
limit,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
offset,
|
||||
projectId,
|
||||
environment,
|
||||
includeSecrets,
|
||||
includeFolders,
|
||||
includeImports,
|
||||
includeDynamicSecrets,
|
||||
tags
|
||||
}),
|
||||
queryFn: () =>
|
||||
fetchProjectSecretsDetails({
|
||||
secretPath,
|
||||
search,
|
||||
limit,
|
||||
orderBy,
|
||||
orderDirection,
|
||||
offset,
|
||||
projectId,
|
||||
environment,
|
||||
includeSecrets,
|
||||
includeFolders,
|
||||
includeImports,
|
||||
includeDynamicSecrets,
|
||||
tags
|
||||
}),
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const serverResponse = error.response?.data as { message: string };
|
||||
createNotification({
|
||||
title: "Error fetching secret details",
|
||||
type: "error",
|
||||
text: serverResponse.message
|
||||
});
|
||||
}
|
||||
},
|
||||
select: useCallback(
|
||||
(data: Awaited<ReturnType<typeof fetchProjectSecretsDetails>>) => ({
|
||||
...data,
|
||||
secrets: data.secrets ? mergePersonalSecrets(data.secrets) : undefined
|
||||
}),
|
||||
[]
|
||||
),
|
||||
keepPreviousData: true
|
||||
});
|
||||
};
|
68
frontend/src/hooks/api/dashboard/types.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
|
||||
import { TSecretImport } from "@app/hooks/api/secretImports/types";
|
||||
import { SecretV3Raw, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
|
||||
|
||||
export type DashboardProjectSecretsOverviewResponse = {
|
||||
folders?: (TSecretFolder & { environment: string })[];
|
||||
dynamicSecrets?: (TDynamicSecret & { environment: string })[];
|
||||
secrets?: SecretV3Raw[];
|
||||
totalSecretCount?: number;
|
||||
totalFolderCount?: number;
|
||||
totalDynamicSecretCount?: number;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
export type DashboardProjectSecretsDetailsResponse = {
|
||||
imports?: TSecretImport[];
|
||||
folders?: TSecretFolder[];
|
||||
dynamicSecrets?: TDynamicSecret[];
|
||||
secrets?: SecretV3Raw[];
|
||||
totalImportCount?: number;
|
||||
totalFolderCount?: number;
|
||||
totalDynamicSecretCount?: number;
|
||||
totalSecretCount?: number;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
export type DashboardProjectSecretsOverview = Omit<
|
||||
DashboardProjectSecretsOverviewResponse,
|
||||
"secrets"
|
||||
> & {
|
||||
secrets?: SecretV3RawSanitized[];
|
||||
};
|
||||
|
||||
export type DashboardProjectSecretsDetails = Omit<
|
||||
DashboardProjectSecretsDetailsResponse,
|
||||
"secrets"
|
||||
> & {
|
||||
secrets?: SecretV3RawSanitized[];
|
||||
};
|
||||
|
||||
export enum DashboardSecretsOrderBy {
|
||||
Name = "name"
|
||||
}
|
||||
|
||||
export type TGetDashboardProjectSecretsOverviewDTO = {
|
||||
projectId: string;
|
||||
secretPath: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
orderBy?: DashboardSecretsOrderBy;
|
||||
orderDirection?: OrderByDirection;
|
||||
search?: string;
|
||||
includeSecrets?: boolean;
|
||||
includeFolders?: boolean;
|
||||
includeDynamicSecrets?: boolean;
|
||||
environments: string[];
|
||||
};
|
||||
|
||||
export type TGetDashboardProjectSecretsDetailsDTO = Omit<
|
||||
TGetDashboardProjectSecretsOverviewDTO,
|
||||
"environments"
|
||||
> & {
|
||||
environment: string;
|
||||
includeImports?: boolean;
|
||||
tags: Record<string, boolean>;
|
||||
};
|
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
|
||||
|
||||
import { dynamicSecretKeys } from "./queries";
|
||||
import {
|
||||
@@ -22,6 +23,8 @@ export const useCreateDynamicSecret = () => {
|
||||
return data.dynamicSecret;
|
||||
},
|
||||
onSuccess: (_, { path, environmentSlug, projectSlug }) => {
|
||||
// TODO: optimize but we currently don't pass projectId
|
||||
queryClient.invalidateQueries(dashboardKeys.all());
|
||||
queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug }));
|
||||
}
|
||||
});
|
||||
@@ -39,6 +42,8 @@ export const useUpdateDynamicSecret = () => {
|
||||
return data.dynamicSecret;
|
||||
},
|
||||
onSuccess: (_, { path, environmentSlug, projectSlug }) => {
|
||||
// TODO: optimize but currently don't pass projectId
|
||||
queryClient.invalidateQueries(dashboardKeys.all());
|
||||
queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug }));
|
||||
}
|
||||
});
|
||||
@@ -56,6 +61,8 @@ export const useDeleteDynamicSecret = () => {
|
||||
return data.dynamicSecret;
|
||||
},
|
||||
onSuccess: (_, { path, environmentSlug, projectSlug }) => {
|
||||
// TODO: optimize but currently don't pass projectId
|
||||
queryClient.invalidateQueries(dashboardKeys.all());
|
||||
queryClient.invalidateQueries(dynamicSecretKeys.list({ path, projectSlug, environmentSlug }));
|
||||
}
|
||||
});
|
||||
|
@@ -71,6 +71,34 @@ export const useGetDynamicSecretDetails = ({
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetDynamicSecretProviderData = ({
|
||||
tenantId,
|
||||
applicationId,
|
||||
clientSecret,
|
||||
enabled
|
||||
}: {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
clientSecret: string;
|
||||
enabled: boolean
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: ["users"],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.post<{id:string, email: string, name:string}[]>(
|
||||
"/api/v1/dynamic-secrets/entra-id/users",
|
||||
{
|
||||
tenantId,
|
||||
applicationId,
|
||||
clientSecret
|
||||
}
|
||||
);
|
||||
return data;
|
||||
},
|
||||
enabled
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetDynamicSecretsOfAllEnv = ({
|
||||
path,
|
||||
projectSlug,
|
||||
|
@@ -24,7 +24,8 @@ export enum DynamicSecretProviders {
|
||||
MongoAtlas = "mongo-db-atlas",
|
||||
ElasticSearch = "elastic-search",
|
||||
MongoDB = "mongo-db",
|
||||
RabbitMq = "rabbit-mq"
|
||||
RabbitMq = "rabbit-mq",
|
||||
AzureEntraId = "azure-entra-id"
|
||||
}
|
||||
|
||||
export enum SqlProviders {
|
||||
@@ -177,7 +178,17 @@ export type TDynamicSecretProvider =
|
||||
};
|
||||
ca?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: DynamicSecretProviders.AzureEntraId;
|
||||
inputs: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
applicationId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TCreateDynamicSecretDTO = {
|
||||
projectSlug: string;
|
||||
|
@@ -38,17 +38,17 @@ export const useUpdateGroup = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
currentSlug,
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
role
|
||||
}: {
|
||||
currentSlug: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
role?: string;
|
||||
}) => {
|
||||
const { data: group } = await apiRequest.patch<TGroup>(`/api/v1/groups/${currentSlug}`, {
|
||||
const { data: group } = await apiRequest.patch<TGroup>(`/api/v1/groups/${id}`, {
|
||||
name,
|
||||
slug,
|
||||
role
|
||||
@@ -65,8 +65,8 @@ export const useUpdateGroup = () => {
|
||||
export const useDeleteGroup = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ slug }: { slug: string }) => {
|
||||
const { data: group } = await apiRequest.delete<TGroup>(`/api/v1/groups/${slug}`);
|
||||
mutationFn: async ({ id }: { id: string }) => {
|
||||
const { data: group } = await apiRequest.delete<TGroup>(`/api/v1/groups/${id}`);
|
||||
|
||||
return group;
|
||||
},
|
||||
@@ -79,8 +79,15 @@ export const useDeleteGroup = () => {
|
||||
export const useAddUserToGroup = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ slug, username }: { slug: string; username: string }) => {
|
||||
const { data } = await apiRequest.post<TGroup>(`/api/v1/groups/${slug}/users/${username}`);
|
||||
mutationFn: async ({
|
||||
groupId,
|
||||
username
|
||||
}: {
|
||||
groupId: string;
|
||||
username: string;
|
||||
slug: string;
|
||||
}) => {
|
||||
const { data } = await apiRequest.post<TGroup>(`/api/v1/groups/${groupId}/users/${username}`);
|
||||
|
||||
return data;
|
||||
},
|
||||
@@ -93,8 +100,17 @@ export const useAddUserToGroup = () => {
|
||||
export const useRemoveUserFromGroup = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ slug, username }: { slug: string; username: string }) => {
|
||||
const { data } = await apiRequest.delete<TGroup>(`/api/v1/groups/${slug}/users/${username}`);
|
||||
mutationFn: async ({
|
||||
username,
|
||||
groupId
|
||||
}: {
|
||||
slug: string;
|
||||
username: string;
|
||||
groupId: string;
|
||||
}) => {
|
||||
const { data } = await apiRequest.delete<TGroup>(
|
||||
`/api/v1/groups/${groupId}/users/${username}`
|
||||
);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
@@ -4,7 +4,8 @@ import { apiRequest } from "@app/config/request";
|
||||
|
||||
export const groupKeys = {
|
||||
allGroupUserMemberships: () => ["group-user-memberships"] as const,
|
||||
forGroupUserMemberships: (slug: string) => [...groupKeys.allGroupUserMemberships(), slug] as const,
|
||||
forGroupUserMemberships: (slug: string) =>
|
||||
[...groupKeys.allGroupUserMemberships(), slug] as const,
|
||||
specificGroupUserMemberships: ({
|
||||
slug,
|
||||
offset,
|
||||
@@ -28,11 +29,13 @@ type TUser = {
|
||||
};
|
||||
|
||||
export const useListGroupUsers = ({
|
||||
id,
|
||||
groupSlug,
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
username
|
||||
}: {
|
||||
id: string;
|
||||
groupSlug: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
@@ -52,14 +55,15 @@ export const useListGroupUsers = ({
|
||||
limit: String(limit),
|
||||
username
|
||||
});
|
||||
|
||||
const { data } = await apiRequest.get<{ users: TUser[]; totalCount: number; }>(
|
||||
`/api/v1/groups/${groupSlug}/users`, {
|
||||
|
||||
const { data } = await apiRequest.get<{ users: TUser[]; totalCount: number }>(
|
||||
`/api/v1/groups/${id}/users`,
|
||||
{
|
||||
params
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
return data;
|
||||
},
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@@ -8,6 +8,7 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
|
||||
|
||||
import { secretSnapshotKeys } from "../secretSnapshots/queries";
|
||||
import {
|
||||
@@ -124,6 +125,12 @@ export const useCreateFolder = () => {
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { projectId, environment, path }) => {
|
||||
queryClient.invalidateQueries(
|
||||
dashboardKeys.getDashboardSecrets({
|
||||
projectId,
|
||||
secretPath: path ?? "/"
|
||||
})
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
folderQueryKeys.getSecretFolders({ projectId, environment, path })
|
||||
);
|
||||
@@ -151,6 +158,12 @@ export const useUpdateFolder = () => {
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { projectId, environment, path }) => {
|
||||
queryClient.invalidateQueries(
|
||||
dashboardKeys.getDashboardSecrets({
|
||||
projectId,
|
||||
secretPath: path ?? "/"
|
||||
})
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
folderQueryKeys.getSecretFolders({ projectId, environment, path })
|
||||
);
|
||||
@@ -179,6 +192,12 @@ export const useDeleteFolder = () => {
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { path = "/", projectId, environment }) => {
|
||||
queryClient.invalidateQueries(
|
||||
dashboardKeys.getDashboardSecrets({
|
||||
projectId,
|
||||
secretPath: path
|
||||
})
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
folderQueryKeys.getSecretFolders({ projectId, environment, path })
|
||||
);
|
||||
@@ -206,6 +225,12 @@ export const useUpdateFolderBatch = () => {
|
||||
},
|
||||
onSuccess: (_, { projectId, folders }) => {
|
||||
folders.forEach((folder) => {
|
||||
queryClient.invalidateQueries(
|
||||
dashboardKeys.getDashboardSecrets({
|
||||
projectId,
|
||||
secretPath: folder.path ?? "/"
|
||||
})
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
folderQueryKeys.getSecretFolders({
|
||||
projectId,
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
|
||||
|
||||
import { secretImportKeys } from "./queries";
|
||||
import {
|
||||
@@ -31,6 +32,9 @@ export const useCreateSecretImport = () => {
|
||||
queryClient.invalidateQueries(
|
||||
secretImportKeys.getSecretImportSecrets({ projectId, environment, path })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
dashboardKeys.getDashboardSecrets({ projectId, secretPath: path ?? "/" })
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -55,6 +59,9 @@ export const useUpdateSecretImport = () => {
|
||||
queryClient.invalidateQueries(
|
||||
secretImportKeys.getSecretImportSecrets({ environment, path, projectId })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
dashboardKeys.getDashboardSecrets({ projectId, secretPath: path ?? "/" })
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -93,6 +100,9 @@ export const useDeleteSecretImport = () => {
|
||||
queryClient.invalidateQueries(
|
||||
secretImportKeys.getSecretImportSecrets({ projectId, environment, path })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
dashboardKeys.getDashboardSecrets({ projectId, secretPath: path ?? "/" })
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { MutationOptions, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
|
||||
|
||||
import { secretApprovalRequestKeys } from "../secretApprovalRequest/queries";
|
||||
import { secretSnapshotKeys } from "../secretSnapshots/queries";
|
||||
@@ -44,6 +45,9 @@ export const useCreateSecretV3 = ({
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment, secretPath }) => {
|
||||
queryClient.invalidateQueries(
|
||||
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
);
|
||||
@@ -96,6 +100,9 @@ export const useUpdateSecretV3 = ({
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment, secretPath }) => {
|
||||
queryClient.invalidateQueries(
|
||||
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
);
|
||||
@@ -139,6 +146,9 @@ export const useDeleteSecretV3 = ({
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment, secretPath }) => {
|
||||
queryClient.invalidateQueries(
|
||||
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
);
|
||||
@@ -172,6 +182,9 @@ export const useCreateSecretBatch = ({
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment, secretPath }) => {
|
||||
queryClient.invalidateQueries(
|
||||
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
);
|
||||
@@ -205,6 +218,9 @@ export const useUpdateSecretBatch = ({
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment, secretPath }) => {
|
||||
queryClient.invalidateQueries(
|
||||
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
);
|
||||
@@ -240,6 +256,9 @@ export const useDeleteSecretBatch = ({
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment, secretPath }) => {
|
||||
queryClient.invalidateQueries(
|
||||
dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
);
|
||||
@@ -295,6 +314,12 @@ export const useMoveSecrets = ({
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { projectId, sourceEnvironment, sourceSecretPath }) => {
|
||||
queryClient.invalidateQueries(
|
||||
dashboardKeys.getDashboardSecrets({
|
||||
projectId,
|
||||
secretPath: sourceSecretPath
|
||||
})
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret({
|
||||
workspaceId: projectId,
|
||||
|
@@ -10,23 +10,24 @@ export const useAddGroupToWorkspace = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
groupSlug,
|
||||
projectSlug,
|
||||
groupId,
|
||||
projectId,
|
||||
role
|
||||
}: {
|
||||
groupSlug: string;
|
||||
projectSlug: string;
|
||||
groupId: string;
|
||||
projectId: string;
|
||||
role?: string;
|
||||
}) => {
|
||||
const {
|
||||
data: { groupMembership }
|
||||
} = await apiRequest.post(`/api/v2/workspace/${projectSlug}/groups/${groupSlug}`, {
|
||||
} = await apiRequest.post(`/api/v2/workspace/${projectId}/groups/${groupId}`, {
|
||||
role
|
||||
});
|
||||
|
||||
return groupMembership;
|
||||
},
|
||||
onSuccess: (_, { projectSlug }) => {
|
||||
queryClient.invalidateQueries(workspaceKeys.getWorkspaceGroupMemberships(projectSlug));
|
||||
onSuccess: (_, { projectId }) => {
|
||||
queryClient.invalidateQueries(workspaceKeys.getWorkspaceGroupMemberships(projectId));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -34,17 +35,17 @@ export const useAddGroupToWorkspace = () => {
|
||||
export const useUpdateGroupWorkspaceRole = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ groupSlug, projectSlug, roles }: TUpdateWorkspaceGroupRoleDTO) => {
|
||||
mutationFn: async ({ groupId, projectId, roles }: TUpdateWorkspaceGroupRoleDTO) => {
|
||||
const {
|
||||
data: { groupMembership }
|
||||
} = await apiRequest.patch(`/api/v2/workspace/${projectSlug}/groups/${groupSlug}`, {
|
||||
} = await apiRequest.patch(`/api/v2/workspace/${projectId}/groups/${groupId}`, {
|
||||
roles
|
||||
});
|
||||
|
||||
return groupMembership;
|
||||
},
|
||||
onSuccess: (_, { projectSlug }) => {
|
||||
queryClient.invalidateQueries(workspaceKeys.getWorkspaceGroupMemberships(projectSlug));
|
||||
onSuccess: (_, { projectId }) => {
|
||||
queryClient.invalidateQueries(workspaceKeys.getWorkspaceGroupMemberships(projectId));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -53,20 +54,20 @@ export const useDeleteGroupFromWorkspace = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
groupSlug,
|
||||
projectSlug
|
||||
groupId,
|
||||
projectId
|
||||
}: {
|
||||
groupSlug: string;
|
||||
projectSlug: string;
|
||||
groupId: string;
|
||||
projectId: string;
|
||||
username?: string;
|
||||
}) => {
|
||||
const {
|
||||
data: { groupMembership }
|
||||
} = await apiRequest.delete(`/api/v2/workspace/${projectSlug}/groups/${groupSlug}`);
|
||||
} = await apiRequest.delete(`/api/v2/workspace/${projectId}/groups/${groupId}`);
|
||||
return groupMembership;
|
||||
},
|
||||
onSuccess: (_, { projectSlug, username }) => {
|
||||
queryClient.invalidateQueries(workspaceKeys.getWorkspaceGroupMemberships(projectSlug));
|
||||
onSuccess: (_, { projectId, username }) => {
|
||||
queryClient.invalidateQueries(workspaceKeys.getWorkspaceGroupMemberships(projectId));
|
||||
|
||||
if (username) {
|
||||
queryClient.invalidateQueries(userKeys.listUserGroupMemberships(username));
|
||||
|
@@ -535,14 +535,14 @@ export const useGetWorkspaceIdentityMemberships = (
|
||||
});
|
||||
};
|
||||
|
||||
export const useListWorkspaceGroups = (projectSlug: string) => {
|
||||
export const useListWorkspaceGroups = (projectId: string) => {
|
||||
return useQuery({
|
||||
queryKey: workspaceKeys.getWorkspaceGroupMemberships(projectSlug),
|
||||
queryKey: workspaceKeys.getWorkspaceGroupMemberships(projectId),
|
||||
queryFn: async () => {
|
||||
const {
|
||||
data: { groupMemberships }
|
||||
} = await apiRequest.get<{ groupMemberships: TGroupMembership[] }>(
|
||||
`/api/v2/workspace/${projectSlug}/groups`
|
||||
`/api/v2/workspace/${projectId}/groups`
|
||||
);
|
||||
return groupMemberships;
|
||||
},
|
||||
|
@@ -127,8 +127,8 @@ export type TUpdateWorkspaceIdentityRoleDTO = {
|
||||
};
|
||||
|
||||
export type TUpdateWorkspaceGroupRoleDTO = {
|
||||
groupSlug: string;
|
||||
projectSlug: string;
|
||||
groupId: string;
|
||||
projectId: string;
|
||||
roles: (
|
||||
| {
|
||||
role: string;
|
||||
|
1
frontend/src/hooks/utils/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./secrets-overview";
|
86
frontend/src/hooks/utils/secrets-overview.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import { DashboardProjectSecretsOverview } from "@app/hooks/api/dashboard/types";
|
||||
|
||||
export const useFolderOverview = (folders: DashboardProjectSecretsOverview["folders"]) => {
|
||||
const folderNames = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
folders?.forEach((folder) => {
|
||||
names.add(folder.name);
|
||||
});
|
||||
return [...names];
|
||||
}, [folders]);
|
||||
|
||||
const isFolderPresentInEnv = useCallback(
|
||||
(name: string, env: string) => {
|
||||
return Boolean(
|
||||
folders?.find(
|
||||
({ name: folderName, environment }) => folderName === name && environment === env
|
||||
)
|
||||
);
|
||||
},
|
||||
[folders]
|
||||
);
|
||||
|
||||
const getFolderByNameAndEnv = useCallback(
|
||||
(name: string, env: string) => {
|
||||
return folders?.find(
|
||||
({ name: folderName, environment }) => folderName === name && environment === env
|
||||
);
|
||||
},
|
||||
[folders]
|
||||
);
|
||||
|
||||
return { folderNames, isFolderPresentInEnv, getFolderByNameAndEnv };
|
||||
};
|
||||
|
||||
export const useDynamicSecretOverview = (
|
||||
dynamicSecrets: DashboardProjectSecretsOverview["dynamicSecrets"]
|
||||
) => {
|
||||
const dynamicSecretNames = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
dynamicSecrets?.forEach((dynamicSecret) => {
|
||||
names.add(dynamicSecret.name);
|
||||
});
|
||||
return [...names];
|
||||
}, [dynamicSecrets]);
|
||||
|
||||
const isDynamicSecretPresentInEnv = useCallback(
|
||||
(name: string, env: string) => {
|
||||
return Boolean(
|
||||
dynamicSecrets?.find(
|
||||
({ name: dynamicSecretName, environment }) =>
|
||||
dynamicSecretName === name && environment === env
|
||||
)
|
||||
);
|
||||
},
|
||||
[dynamicSecrets]
|
||||
);
|
||||
|
||||
return { dynamicSecretNames, isDynamicSecretPresentInEnv };
|
||||
};
|
||||
|
||||
export const useSecretOverview = (secrets: DashboardProjectSecretsOverview["secrets"]) => {
|
||||
const secKeys = useMemo(() => {
|
||||
const keys = new Set<string>();
|
||||
secrets?.forEach((secret) => keys.add(secret.key));
|
||||
return [...keys];
|
||||
}, [secrets]);
|
||||
|
||||
const getEnvSecretKeyCount = useCallback(
|
||||
(env: string) => {
|
||||
return secrets?.filter((secret) => secret.env === env).length ?? 0;
|
||||
},
|
||||
[secrets]
|
||||
);
|
||||
|
||||
const getSecretByKey = useCallback(
|
||||
(env: string, key: string) => {
|
||||
const sec = secrets?.find((s) => s.env === env && s.key === key);
|
||||
return sec;
|
||||
},
|
||||
[secrets]
|
||||
);
|
||||
|
||||
return { secKeys, getSecretByKey, getEnvSecretKeyCount };
|
||||
};
|
@@ -675,18 +675,6 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
</MenuItem>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href={`/project/${currentWorkspace?.id}/audit-logs`} passHref>
|
||||
<a>
|
||||
<MenuItem
|
||||
isSelected={
|
||||
router.asPath === `/project/${currentWorkspace?.id}/audit-logs`
|
||||
}
|
||||
icon="system-outline-168-view-headline"
|
||||
>
|
||||
Audit Logs
|
||||
</MenuItem>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href={`/project/${currentWorkspace?.id}/settings`} passHref>
|
||||
<a>
|
||||
<MenuItem
|
||||
@@ -755,6 +743,16 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
<Link href={`/org/${currentOrg?.id}/audit-logs`} passHref>
|
||||
<a>
|
||||
<MenuItem
|
||||
isSelected={router.asPath === `/org/${currentOrg?.id}/audit-logs`}
|
||||
icon="system-outline-168-view-headline"
|
||||
>
|
||||
Audit Logs
|
||||
</MenuItem>
|
||||
</a>
|
||||
</Link>
|
||||
<Link href={`/org/${currentOrg?.id}/settings`} passHref>
|
||||
<a>
|
||||
<MenuItem
|
||||
|
117
frontend/src/layouts/AppLayout/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React, { ErrorInfo, ReactNode, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { faBugs, faHome } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Button } from "@app/components/v2";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
const ErrorPage = ({ error }: { error: Error | null }) => {
|
||||
const [orgId, setOrgId] = React.useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const currentUrl = router?.asPath?.split("?")?.[0];
|
||||
|
||||
// Workaround: Fixes localStorage not being available in the error boundary until the next render.
|
||||
useEffect(() => {
|
||||
const savedOrgId = localStorage.getItem("orgData.id");
|
||||
|
||||
if (savedOrgId) {
|
||||
setOrgId(savedOrgId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-mineshaft-900">
|
||||
<div className="flex max-w-md flex-col rounded-md border border-mineshaft-600 bg-mineshaft-800 p-8 text-center text-mineshaft-200">
|
||||
<FontAwesomeIcon icon={faBugs} className="my-2 inline text-6xl" />
|
||||
<p>
|
||||
Something went wrong. Please contact{" "}
|
||||
<a
|
||||
className="inline cursor-pointer text-mineshaft-100 underline decoration-primary-500 underline-offset-4 opacity-80 duration-200 hover:opacity-100"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="mailto:support@infisical.com"
|
||||
>
|
||||
support@infisical.com
|
||||
</a>
|
||||
, or{" "}
|
||||
<Link passHref href="https://infisical.com/slack">
|
||||
<a
|
||||
className="inline cursor-pointer text-mineshaft-100 underline decoration-primary-500 underline-offset-4 opacity-80 duration-200 hover:opacity-100"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
join our Slack community
|
||||
</a>
|
||||
</Link>{" "}
|
||||
if the issue persists.
|
||||
</p>
|
||||
|
||||
{orgId && (
|
||||
<Button
|
||||
className="mt-4"
|
||||
size="xs"
|
||||
onClick={() =>
|
||||
// we need to go to /org/${orgId}/overview, but we need to do a full page reload to ensure that the error the user is facing is properly reset.
|
||||
window.location.assign(`/org/${orgId}/overview`)
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHome} className="mr-2" />
|
||||
Back To Home
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{error?.message && (
|
||||
<>
|
||||
<div className="my-4 h-px w-full bg-mineshaft-600" />
|
||||
<p className="thin-scrollbar max-h-44 w-full overflow-auto text-ellipsis rounded-md bg-mineshaft-700 p-2">
|
||||
<code className="text-xs">
|
||||
{currentUrl}, {error.message}
|
||||
</code>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
console.error("Error caught by ErrorBoundary:", error, errorInfo);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
const { hasError, error } = this.state;
|
||||
const { children } = this.props;
|
||||
|
||||
if (hasError) {
|
||||
return <ErrorPage error={error} />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
const ErrorBoundaryWrapper = ({ children }: ErrorBoundaryProps) => {
|
||||
return <ErrorBoundary>{children}</ErrorBoundary>;
|
||||
};
|
||||
|
||||
export default ErrorBoundaryWrapper;
|
@@ -27,6 +27,7 @@ import {
|
||||
WorkspaceProvider
|
||||
} from "@app/context";
|
||||
import { AppLayout } from "@app/layouts";
|
||||
import ErrorBoundaryWrapper from "@app/layouts/AppLayout/ErrorBoundary";
|
||||
import { queryClient } from "@app/reactQuery";
|
||||
|
||||
import "nprogress/nprogress.css";
|
||||
@@ -85,46 +86,50 @@ const App = ({ Component, pageProps, ...appProps }: NextAppProp): JSX.Element =>
|
||||
!Component.requireAuth
|
||||
) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NotificationContainer />
|
||||
<ServerConfigProvider>
|
||||
<UserProvider>
|
||||
<AuthProvider>
|
||||
<Component {...pageProps} />
|
||||
</AuthProvider>
|
||||
</UserProvider>
|
||||
</ServerConfigProvider>
|
||||
</QueryClientProvider>
|
||||
<ErrorBoundaryWrapper>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NotificationContainer />
|
||||
<ServerConfigProvider>
|
||||
<UserProvider>
|
||||
<AuthProvider>
|
||||
<Component {...pageProps} />
|
||||
</AuthProvider>
|
||||
</UserProvider>
|
||||
</ServerConfigProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundaryWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Layout = Component?.layout || AppLayout;
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<NotificationContainer />
|
||||
<ServerConfigProvider>
|
||||
<AuthProvider>
|
||||
<OrgProvider>
|
||||
<OrgPermissionProvider>
|
||||
<WorkspaceProvider>
|
||||
<ProjectPermissionProvider>
|
||||
<SubscriptionProvider>
|
||||
<UserProvider>
|
||||
<Layout>
|
||||
<Component {...pageProps} />
|
||||
</Layout>
|
||||
</UserProvider>
|
||||
</SubscriptionProvider>
|
||||
</ProjectPermissionProvider>
|
||||
</WorkspaceProvider>
|
||||
</OrgPermissionProvider>
|
||||
</OrgProvider>
|
||||
</AuthProvider>
|
||||
</ServerConfigProvider>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
<ErrorBoundaryWrapper>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<NotificationContainer />
|
||||
<ServerConfigProvider>
|
||||
<AuthProvider>
|
||||
<OrgProvider>
|
||||
<OrgPermissionProvider>
|
||||
<WorkspaceProvider>
|
||||
<ProjectPermissionProvider>
|
||||
<SubscriptionProvider>
|
||||
<UserProvider>
|
||||
<Layout>
|
||||
<Component {...pageProps} />
|
||||
</Layout>
|
||||
</UserProvider>
|
||||
</SubscriptionProvider>
|
||||
</ProjectPermissionProvider>
|
||||
</WorkspaceProvider>
|
||||
</OrgPermissionProvider>
|
||||
</OrgProvider>
|
||||
</AuthProvider>
|
||||
</ServerConfigProvider>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundaryWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -1,15 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Head from "next/head";
|
||||
|
||||
import { AuditLogsPage } from "@app/views/Project/AuditLogsPage";
|
||||
import { AuditLogsPage } from "@app/views/Org/AuditLogsPage";
|
||||
|
||||
const Logs = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="h-full bg-bunker-800">
|
||||
<Head>
|
||||
<title>{t("common.head-title", { title: t("settings.project.title") })}</title>
|
||||
<title>Infisical | Audit Logs</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
<meta property="og:image" content="/images/message.png" />
|
||||
</Head>
|
@@ -4,7 +4,7 @@ import { EmptyState } from "@app/components/v2";
|
||||
import { useSubscription } from "@app/context";
|
||||
import { EventType } from "@app/hooks/api/auditLogs/enums";
|
||||
import { TIntegrationWithEnv } from "@app/hooks/api/integrations/types";
|
||||
import { LogsSection } from "@app/views/Project/AuditLogsPage/components";
|
||||
import { LogsSection } from "@app/views/Org/AuditLogsPage/components";
|
||||
|
||||
// Add more events if needed
|
||||
const INTEGRATION_EVENTS = [EventType.INTEGRATION_SYNCED];
|
||||
|
21
frontend/src/views/Org/AuditLogsPage/AuditLogsPage.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { withPermission } from "@app/hoc";
|
||||
|
||||
import { LogsSection } from "./components";
|
||||
|
||||
export const AuditLogsPage = withPermission(
|
||||
() => {
|
||||
return (
|
||||
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
|
||||
<div className="w-full max-w-7xl px-6">
|
||||
<div className="bg-bunker-800 py-6">
|
||||
<p className="text-3xl font-semibold text-gray-200">Audit Logs</p>
|
||||
<div />
|
||||
</div>
|
||||
<LogsSection filterClassName="static p-2" showFilters isOrgAuditLogs />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
{ action: OrgPermissionActions.Read, subject: OrgPermissionSubjects.AuditLogs }
|
||||
);
|
@@ -40,16 +40,24 @@ type Props = {
|
||||
eventType?: EventType[];
|
||||
};
|
||||
className?: string;
|
||||
isOrgAuditLogs?: boolean;
|
||||
control: Control<AuditLogFilterFormData>;
|
||||
reset: UseFormReset<AuditLogFilterFormData>;
|
||||
watch: UseFormWatch<AuditLogFilterFormData>;
|
||||
};
|
||||
|
||||
export const LogsFilter = ({ presets, className, control, reset, watch }: Props) => {
|
||||
export const LogsFilter = ({
|
||||
presets,
|
||||
isOrgAuditLogs,
|
||||
className,
|
||||
control,
|
||||
reset,
|
||||
watch
|
||||
}: Props) => {
|
||||
const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false);
|
||||
const [isEndDatePickerOpen, setIsEndDatePickerOpen] = useState(false);
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { currentWorkspace, workspaces } = useWorkspace();
|
||||
const { data, isLoading } = useGetAuditLogActorFilterOpts(currentWorkspace?.id ?? "");
|
||||
|
||||
const renderActorSelectItem = (actor: Actor) => {
|
||||
@@ -112,7 +120,7 @@ export const LogsFilter = ({ presets, className, control, reset, watch }: Props)
|
||||
? eventTypes.find((eventType) => eventType.value === selectedEventTypes[0])
|
||||
?.label
|
||||
: selectedEventTypes?.length === 0
|
||||
? "Select event types"
|
||||
? "All events"
|
||||
: `${selectedEventTypes?.length} events selected`}
|
||||
<FontAwesomeIcon icon={faChevronDown} className="ml-2 text-xs" />
|
||||
</div>
|
||||
@@ -191,7 +199,7 @@ export const LogsFilter = ({ presets, className, control, reset, watch }: Props)
|
||||
<Controller
|
||||
control={control}
|
||||
name="userAgentType"
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Source"
|
||||
errorText={error?.message}
|
||||
@@ -199,13 +207,22 @@ export const LogsFilter = ({ presets, className, control, reset, watch }: Props)
|
||||
className="w-40"
|
||||
>
|
||||
<Select
|
||||
{...(field.value ? { value: field.value } : { placeholder: "Select" })}
|
||||
value={value === undefined ? "all" : value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100"
|
||||
onValueChange={(e) => {
|
||||
if (e === "all") onChange(undefined);
|
||||
else onChange(e);
|
||||
}}
|
||||
className={twMerge(
|
||||
"w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100",
|
||||
value === undefined && "text-mineshaft-400"
|
||||
)}
|
||||
>
|
||||
{userAgentTypes.map(({ label, value }) => (
|
||||
<SelectItem value={String(value || "")} key={label}>
|
||||
<SelectItem value="all" key="all">
|
||||
All sources
|
||||
</SelectItem>
|
||||
{userAgentTypes.map(({ label, value: userAgent }) => (
|
||||
<SelectItem value={userAgent} key={label}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -213,6 +230,43 @@ export const LogsFilter = ({ presets, className, control, reset, watch }: Props)
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isOrgAuditLogs && workspaces.length > 0 && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="projectId"
|
||||
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Project"
|
||||
errorText={error?.message}
|
||||
isError={Boolean(error)}
|
||||
className="w-40"
|
||||
>
|
||||
<Select
|
||||
value={value === undefined ? "all" : value}
|
||||
{...field}
|
||||
onValueChange={(e) => {
|
||||
if (e === "all") onChange(undefined);
|
||||
else onChange(e);
|
||||
}}
|
||||
className={twMerge(
|
||||
"w-full border border-mineshaft-500 bg-mineshaft-700 text-mineshaft-100",
|
||||
value === undefined && "text-mineshaft-400"
|
||||
)}
|
||||
>
|
||||
<SelectItem value="all" key="all">
|
||||
All projects
|
||||
</SelectItem>
|
||||
{workspaces.map((project) => (
|
||||
<SelectItem value={String(project.id || "")} key={project.id}>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Controller
|
||||
name="startDate"
|
||||
control={control}
|
||||
@@ -272,7 +326,8 @@ export const LogsFilter = ({ presets, className, control, reset, watch }: Props)
|
||||
actor: presets?.actorId,
|
||||
userAgentType: undefined,
|
||||
startDate: undefined,
|
||||
endDate: undefined
|
||||
endDate: undefined,
|
||||
projectId: undefined
|
||||
})
|
||||
}
|
||||
>
|
@@ -47,6 +47,7 @@ export const LogsSection = ({
|
||||
const { control, reset, watch } = useForm<AuditLogFilterFormData>({
|
||||
resolver: yupResolver(auditLogFilterFormSchema),
|
||||
defaultValues: {
|
||||
projectId: undefined,
|
||||
actor: presets?.actorId,
|
||||
eventType: presets?.eventType || [],
|
||||
page: 1,
|
||||
@@ -65,6 +66,7 @@ export const LogsSection = ({
|
||||
const eventType = watch("eventType") as EventType[] | undefined;
|
||||
const userAgentType = watch("userAgentType") as UserAgentType | undefined;
|
||||
const actor = watch("actor");
|
||||
const projectId = watch("projectId");
|
||||
|
||||
const startDate = watch("startDate");
|
||||
const endDate = watch("endDate");
|
||||
@@ -73,6 +75,7 @@ export const LogsSection = ({
|
||||
<div>
|
||||
{showFilters && (
|
||||
<LogsFilter
|
||||
isOrgAuditLogs
|
||||
className={filterClassName}
|
||||
presets={presets}
|
||||
control={control}
|
||||
@@ -87,6 +90,7 @@ export const LogsSection = ({
|
||||
showActorColumn={!!showActorColumn && !isOrgAuditLogs}
|
||||
filter={{
|
||||
eventMetadata: presets?.eventMetadata,
|
||||
projectId,
|
||||
actorType: presets?.actorType,
|
||||
limit: 15,
|
||||
eventType,
|
@@ -41,12 +41,23 @@ export const LogsTable = ({
|
||||
}: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
// Determine the project ID for filtering
|
||||
const filterProjectId =
|
||||
// Use the projectId from the filter if it exists
|
||||
filter?.projectId ??
|
||||
// Otherwise, if we're not looking at org-wide audit logs
|
||||
(!isOrgAuditLogs
|
||||
? // Use the current workspace ID (or an empty string if that's null)
|
||||
currentWorkspace?.id ?? ""
|
||||
: // For org-wide audit logs, use null (no specific project filter)
|
||||
null);
|
||||
|
||||
const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage } = useGetAuditLogs(
|
||||
{
|
||||
...filter,
|
||||
limit: AUDIT_LOG_LIMIT
|
||||
},
|
||||
!isOrgAuditLogs ? currentWorkspace?.id ?? "" : null,
|
||||
filterProjectId,
|
||||
{
|
||||
refetchInterval
|
||||
}
|
@@ -39,6 +39,8 @@ export const LogsTableRow = ({ auditLog, isOrgAuditLogs, showActorColumn }: Prop
|
||||
};
|
||||
|
||||
const renderMetadata = (event: Event) => {
|
||||
const metadataKeys = Object.keys(event.metadata);
|
||||
|
||||
switch (event.type) {
|
||||
case EventType.GET_SECRETS:
|
||||
return (
|
||||
@@ -476,7 +478,47 @@ export const LogsTableRow = ({ auditLog, isOrgAuditLogs, showActorColumn }: Prop
|
||||
</Tooltip>
|
||||
</Td>
|
||||
);
|
||||
|
||||
case EventType.GET_WORKSPACE_KEY:
|
||||
return (
|
||||
<Td>
|
||||
<p>{`Key ID: ${event.metadata.keyId}`}</p>
|
||||
</Td>
|
||||
);
|
||||
|
||||
case EventType.LOGIN_IDENTITY_UNIVERSAL_AUTH:
|
||||
case EventType.ADD_IDENTITY_UNIVERSAL_AUTH:
|
||||
case EventType.UPDATE_IDENTITY_UNIVERSAL_AUTH:
|
||||
case EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS:
|
||||
return (
|
||||
<Td>
|
||||
<p>{`Identity ID: ${event.metadata.identityId}`}</p>
|
||||
</Td>
|
||||
);
|
||||
|
||||
case EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET:
|
||||
case EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET:
|
||||
return (
|
||||
<Td>
|
||||
<p>{`Identity ID: ${event.metadata.identityId}`}</p>
|
||||
<p>{`Client Secret ID: ${event.metadata.clientSecretId}`}</p>
|
||||
</Td>
|
||||
);
|
||||
|
||||
// ? If for some reason non the above events are matched, we will display the first 3 metadata items in the metadata object.
|
||||
default:
|
||||
if (metadataKeys.length) {
|
||||
const maxMetadataLength = metadataKeys.length > 3 ? 3 : metadataKeys.length;
|
||||
return (
|
||||
<Td>
|
||||
{Object.entries(event.metadata)
|
||||
.slice(0, maxMetadataLength)
|
||||
.map(([key, value]) => {
|
||||
return <p key={`audit-log-metadata-${key}`}>{`${key}: ${value}`}</p>;
|
||||
})}
|
||||
</Td>
|
||||
);
|
||||
}
|
||||
return <Td />;
|
||||
}
|
||||
};
|
||||
@@ -531,7 +573,7 @@ export const LogsTableRow = ({ auditLog, isOrgAuditLogs, showActorColumn }: Prop
|
||||
<Tr className={`log-${auditLog.id} h-10 border-x-0 border-b border-t-0`}>
|
||||
<Td>{formatDate(auditLog.createdAt)}</Td>
|
||||
<Td>{`${eventToNameMap[auditLog.event.type]}`}</Td>
|
||||
{isOrgAuditLogs && <Td>{auditLog.project.name}</Td>}
|
||||
{isOrgAuditLogs && <Td>{auditLog?.project?.name ?? "N/A"}</Td>}
|
||||
{showActorColumn && renderActor(auditLog.actor)}
|
||||
{renderSource()}
|
||||
{renderMetadata(auditLog.event)}
|
@@ -5,6 +5,7 @@ import { EventType, UserAgentType } from "@app/hooks/api/auditLogs/enums";
|
||||
export const auditLogFilterFormSchema = yup
|
||||
.object({
|
||||
eventMetadata: yup.object({}).optional(),
|
||||
projectId: yup.string().optional(),
|
||||
eventType: yup.array(yup.string().oneOf(Object.values(EventType), "Invalid event type")),
|
||||
actor: yup.string(),
|
||||
userAgentType: yup.string().oneOf(Object.values(UserAgentType), "Invalid user agent type"),
|
@@ -35,10 +35,12 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const [searchMemberFilter, setSearchMemberFilter] = useState("");
|
||||
|
||||
const popUpData = popUp?.groupMembers?.data as {
|
||||
groupId: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
const { data, isLoading } = useListGroupUsers({
|
||||
id: popUpData?.groupId,
|
||||
groupSlug: popUpData?.slug,
|
||||
offset: (page - 1) * perPage,
|
||||
limit: perPage,
|
||||
@@ -54,11 +56,13 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
|
||||
if (assign) {
|
||||
await assignMutateAsync({
|
||||
groupId: popUpData.groupId,
|
||||
username,
|
||||
slug: popUpData.slug
|
||||
});
|
||||
} else {
|
||||
await unassignMutateAsync({
|
||||
groupId: popUpData.groupId,
|
||||
username,
|
||||
slug: popUpData.slug
|
||||
});
|
||||
|
@@ -85,7 +85,7 @@ export const OrgGroupModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Pr
|
||||
|
||||
if (group) {
|
||||
await updateMutateAsync({
|
||||
currentSlug: group.slug,
|
||||
id: group.groupId,
|
||||
name,
|
||||
slug,
|
||||
role: role || undefined
|
||||
|
@@ -34,10 +34,10 @@ export const OrgGroupsSection = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteGroupSubmit = async ({ name, slug }: { name: string; slug: string }) => {
|
||||
const onDeleteGroupSubmit = async ({ name, groupId }: { name: string; groupId: string }) => {
|
||||
try {
|
||||
await deleteMutateAsync({
|
||||
slug
|
||||
id: groupId
|
||||
});
|
||||
createNotification({
|
||||
text: `Successfully deleted the group named ${name}`,
|
||||
@@ -87,7 +87,7 @@ export const OrgGroupsSection = () => {
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteGroup", isOpen)}
|
||||
deleteKey="confirm"
|
||||
onDeleteApproved={() =>
|
||||
onDeleteGroupSubmit(popUp?.deleteGroup?.data as { name: string; slug: string })
|
||||
onDeleteGroupSubmit(popUp?.deleteGroup?.data as { name: string; groupId: string })
|
||||
}
|
||||
/>
|
||||
<UpgradePlanModal
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { faEllipsis,faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faEllipsis, faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -52,10 +52,10 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
|
||||
const { data: roles } = useGetOrgRoles(orgId);
|
||||
|
||||
const handleChangeRole = async ({ currentSlug, role }: { currentSlug: string; role: string }) => {
|
||||
const handleChangeRole = async ({ id, role }: { id: string; role: string }) => {
|
||||
try {
|
||||
await updateMutateAsync({
|
||||
currentSlug,
|
||||
id,
|
||||
role
|
||||
});
|
||||
|
||||
@@ -112,7 +112,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
|
||||
onValueChange={(selectedRole) =>
|
||||
handleChangeRole({
|
||||
currentSlug: slug,
|
||||
id,
|
||||
role: selectedRole
|
||||
})
|
||||
}
|
||||
@@ -135,6 +135,18 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-1">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
createNotification({
|
||||
text: "Copied group ID to clipboard",
|
||||
type: "info"
|
||||
});
|
||||
navigator.clipboard.writeText(id);
|
||||
}}
|
||||
>
|
||||
Copy Group ID
|
||||
</DropdownMenuItem>
|
||||
<OrgPermissionCan
|
||||
I={OrgPermissionActions.Edit}
|
||||
a={OrgPermissionSubjects.Identity}
|
||||
@@ -147,6 +159,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("groupMembers", {
|
||||
groupId: id,
|
||||
slug
|
||||
});
|
||||
}}
|
||||
@@ -195,7 +208,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePopUpOpen("deleteGroup", {
|
||||
slug,
|
||||
groupId: id,
|
||||
name
|
||||
});
|
||||
}}
|
||||
|
@@ -277,7 +277,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isLoading && data && data.totalCount > INIT_PER_PAGE && (
|
||||
{!isLoading && data && data.totalCount > 0 && (
|
||||
<Pagination
|
||||
count={data.totalCount}
|
||||
page={page}
|
||||
|
@@ -32,6 +32,8 @@ export const formSchema = z.object({
|
||||
create: z.boolean().optional()
|
||||
})
|
||||
.optional(),
|
||||
|
||||
"audit-logs": generalPermissionSchema,
|
||||
member: generalPermissionSchema,
|
||||
groups: generalPermissionSchema,
|
||||
role: generalPermissionSchema,
|
||||
|
@@ -41,6 +41,10 @@ const SIMPLE_PERMISSION_OPTIONS = [
|
||||
title: "Incident Contacts",
|
||||
formName: "incident-contact"
|
||||
},
|
||||
{
|
||||
title: "Audit Logs",
|
||||
formName: "audit-logs"
|
||||
},
|
||||
{
|
||||
title: "Organization Profile",
|
||||
formName: "settings"
|
||||
|