diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 2e42f3388..cd4ee6ff7 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -194,6 +194,25 @@ export const FOLDERS = { } } as const; +export const SECRETS = { + ATTACH_TAGS: { + secretName: "The name of the secret to attach tags to.", + secretPath: "The path of the secret to attach tags to.", + type: "The type of the secret to attach tags to. (shared/personal)", + environment: "The slug of the environment where the secret is located", + projectSlug: "The slug of the project where the secret is located", + tagSlugs: "An array of tag slugs to attach to the secret." + }, + DETACH_TAGS: { + secretName: "The name of the secret to detach tags from.", + secretPath: "The path of the secret to detach tags from.", + type: "The type of the secret to attach tags to. (shared/personal)", + environment: "The slug of the environment where the secret is located", + projectSlug: "The slug of the project where the secret is located", + tagSlugs: "An array of tag slugs to detach from the secret." + } +} as const; + export const RAW_SECRETS = { LIST: { workspaceId: "The ID of the project to list secrets from.", @@ -285,3 +304,19 @@ export const AUDIT_LOGS = { actor: "The actor to filter the audit logs by." } } as const; + +export const SECRET_TAGS = { + LIST: { + projectId: "The ID of the project to list tags from." + }, + CREATE: { + projectId: "The ID of the project to create the tag in.", + name: "The name of the tag to create.", + slug: "The slug of the tag to create.", + color: "The color of the tag to create." + }, + DELETE: { + tagId: "The ID of the tag to delete.", + projectId: "The ID of the project to delete the tag from." + } +} as const; diff --git a/backend/src/server/routes/v1/secret-tag-router.ts b/backend/src/server/routes/v1/secret-tag-router.ts index c60f2b9ba..519b257ae 100644 --- a/backend/src/server/routes/v1/secret-tag-router.ts +++ b/backend/src/server/routes/v1/secret-tag-router.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { SecretTagsSchema } from "@app/db/schemas"; +import { SECRET_TAGS } from "@app/lib/api-docs"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -10,7 +11,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { method: "GET", schema: { params: z.object({ - projectId: z.string().trim() + projectId: z.string().trim().describe(SECRET_TAGS.LIST.projectId) }), response: { 200: z.object({ @@ -36,12 +37,12 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { method: "POST", schema: { params: z.object({ - projectId: z.string().trim() + projectId: z.string().trim().describe(SECRET_TAGS.CREATE.projectId) }), body: z.object({ - name: z.string().trim(), - slug: z.string().trim(), - color: z.string() + name: z.string().trim().describe(SECRET_TAGS.CREATE.name), + slug: z.string().trim().describe(SECRET_TAGS.CREATE.slug), + color: z.string().trim().describe(SECRET_TAGS.CREATE.color) }), response: { 200: z.object({ @@ -68,8 +69,8 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { method: "DELETE", schema: { params: z.object({ - projectId: z.string().trim(), - tagId: z.string().trim() + projectId: z.string().trim().describe(SECRET_TAGS.DELETE.projectId), + tagId: z.string().trim().describe(SECRET_TAGS.DELETE.tagId) }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 1e224aa3b..f69466328 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -10,7 +10,7 @@ import { } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { CommitType } from "@app/ee/services/secret-approval-request/secret-approval-request-types"; -import { RAW_SECRETS } from "@app/lib/api-docs"; +import { RAW_SECRETS, SECRETS } from "@app/lib/api-docs"; import { BadRequestError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; @@ -23,6 +23,124 @@ import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; import { secretRawSchema } from "../sanitizedSchemas"; export const registerSecretRouter = async (server: FastifyZodProvider) => { + server.route({ + url: "/tags/:secretName", + method: "POST", + schema: { + description: "Attach tags to a secret", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + secretName: z.string().trim().describe(SECRETS.ATTACH_TAGS.secretName) + }), + body: z.object({ + projectSlug: z.string().trim().describe(SECRETS.ATTACH_TAGS.projectSlug), + environment: z.string().trim().describe(SECRETS.ATTACH_TAGS.environment), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(SECRETS.ATTACH_TAGS.secretPath), + type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(SECRETS.ATTACH_TAGS.type), + tagSlugs: z.string().array().min(1).describe(SECRETS.ATTACH_TAGS.tagSlugs) + }), + response: { + 200: z.object({ + secret: SecretsSchema.omit({ secretBlindIndex: true }).merge( + z.object({ + tags: SecretTagsSchema.pick({ + id: true, + slug: true, + name: true, + color: true + }).array() + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const secret = await server.services.secret.attachTags({ + secretName: req.params.secretName, + tagSlugs: req.body.tagSlugs, + path: req.body.secretPath, + environment: req.body.environment, + type: req.body.type, + projectSlug: req.body.projectSlug, + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return { secret }; + } + }); + + server.route({ + url: "/tags/:secretName", + method: "DELETE", + schema: { + description: "Detach tags from a secret", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + secretName: z.string().trim().describe(SECRETS.DETACH_TAGS.secretName) + }), + body: z.object({ + projectSlug: z.string().trim().describe(SECRETS.DETACH_TAGS.projectSlug), + environment: z.string().trim().describe(SECRETS.DETACH_TAGS.environment), + secretPath: z + .string() + .trim() + .default("/") + .transform(removeTrailingSlash) + .describe(SECRETS.DETACH_TAGS.secretPath), + type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(SECRETS.DETACH_TAGS.type), + tagSlugs: z.string().array().min(1).describe(SECRETS.DETACH_TAGS.tagSlugs) + }), + response: { + 200: z.object({ + secret: SecretsSchema.omit({ secretBlindIndex: true }).merge( + z.object({ + tags: SecretTagsSchema.pick({ + id: true, + slug: true, + name: true, + color: true + }).array() + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const secret = await server.services.secret.detachTags({ + secretName: req.params.secretName, + tagSlugs: req.body.tagSlugs, + path: req.body.secretPath, + environment: req.body.environment, + type: req.body.type, + projectSlug: req.body.projectSlug, + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return { secret }; + } + }); + server.route({ url: "/raw", method: "GET", diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index 369e005ac..4f4225344 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -168,8 +168,12 @@ export const projectDALFactory = (db: TDbClient) => { } }; - const findProjectBySlug = async (slug: string, orgId: string) => { + const findProjectBySlug = async (slug: string, orgId: string | undefined) => { try { + if (!orgId) { + throw new BadRequestError({ message: "Organization ID is required when querying with slugs" }); + } + const projects = await db(TableName.ProjectMembership) .where(`${TableName.Project}.slug`, slug) .where(`${TableName.Project}.orgId`, orgId) diff --git a/backend/src/services/secret/secret-dal.ts b/backend/src/services/secret/secret-dal.ts index 11cd522ca..504174765 100644 --- a/backend/src/services/secret/secret-dal.ts +++ b/backend/src/services/secret/secret-dal.ts @@ -150,6 +150,27 @@ export const secretDALFactory = (db: TDbClient) => { } }; + const getSecretTags = async (secretId: string, tx?: Knex) => { + try { + const tags = await (tx || db)(TableName.JnSecretTag) + .join(TableName.SecretTag, `${TableName.JnSecretTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id`) + .where({ [`${TableName.Secret}Id` as const]: secretId }) + .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")) + .select(db.ref("name").withSchema(TableName.SecretTag).as("tagName")); + + return tags.map((el) => ({ + id: el.tagId, + color: el.tagColor, + slug: el.tagSlug, + name: el.tagName + })); + } catch (error) { + throw new DatabaseError({ error, name: "get secret tags" }); + } + }; + const findByBlindIndexes = async ( folderId: string, blindIndexes: Array<{ blindIndex: string; type: SecretType }>, @@ -184,6 +205,7 @@ export const secretDALFactory = (db: TDbClient) => { bulkUpdate, deleteMany, bulkUpdateNoVersionIncrement, + getSecretTags, findByFolderId, findByBlindIndexes }; diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index f47428fc7..e3b57802f 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -22,6 +22,7 @@ import { TSecretDALFactory } from "./secret-dal"; import { decryptSecretRaw, fnSecretBlindIndexCheck, fnSecretBulkInsert, fnSecretBulkUpdate } from "./secret-fns"; import { TSecretQueueFactory } from "./secret-queue"; import { + TAttachSecretTagsDTO, TCreateBulkSecretDTO, TCreateSecretDTO, TCreateSecretRawDTO, @@ -47,7 +48,7 @@ type TSecretServiceFactoryDep = { secretTagDAL: TSecretTagDALFactory; secretVersionDAL: TSecretVersionDALFactory; folderDAL: Pick; - projectDAL: Pick; + projectDAL: Pick; secretBlindIndexDAL: TSecretBlindIndexDALFactory; permissionService: Pick; snapshotService: Pick; @@ -307,6 +308,7 @@ export const secretServiceFactory = ({ if ((inputSecret.tags || []).length !== tags.length) throw new BadRequestError({ message: "Tag not found" }); const { secretName, ...el } = inputSecret; + const updatedSecret = await secretDAL.transaction(async (tx) => fnSecretBulkUpdate({ folderId, @@ -442,6 +444,7 @@ export const secretServiceFactory = ({ const folderId = folder.id; const secrets = await secretDAL.findByFolderId(folderId, actorId); + if (includeImports) { const secretImports = await secretImportDAL.find({ folderId }); const allowedImports = secretImports.filter(({ importEnv, importPath }) => @@ -994,7 +997,209 @@ export const secretServiceFactory = ({ return secretVersions; }; + const attachTags = async ({ + secretName, + tagSlugs, + path: secretPath, + environment, + type, + projectSlug, + actor, + actorAuthMethod, + actorOrgId, + actorId + }: TAttachSecretTagsDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + + await projectDAL.checkProjectUpgradeStatus(project.id); + + const secret = await getSecretByName({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId: project.id, + environment, + path: secretPath, + secretName, + type + }); + + if (!secret) { + throw new BadRequestError({ message: "Secret not found" }); + } + const folder = await folderDAL.findBySecretPath(project.id, environment, secretPath); + + if (!folder) { + throw new BadRequestError({ message: "Folder not found" }); + } + + const tags = await secretTagDAL.find({ + projectId: project.id, + $in: { + slug: tagSlugs + } + }); + + if (tags.length !== tagSlugs.length) { + throw new BadRequestError({ message: "One or more tags not found." }); + } + + const secretTags = await secretDAL.getSecretTags(secret.id); + + if (secretTags.some((tag) => tagSlugs.includes(tag.slug))) { + throw new BadRequestError({ message: "One or more tags already exist on the secret" }); + } + + const combinedTags = new Set([...secretTags.map((tag) => tag.id), ...tags.map((el) => el.id)]); + + const updatedSecret = await secretDAL.transaction(async (tx) => + fnSecretBulkUpdate({ + folderId: folder.id, + projectId: project.id, + inputSecrets: [ + { + filter: { id: secret.id }, + data: { + tags: Array.from(combinedTags) + } + } + ], + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, + tx + }) + ); + + await snapshotService.performSnapshot(folder.id); + await secretQueueService.syncSecrets({ secretPath, projectId: project.id, environment }); + + return { + ...updatedSecret[0], + tags: [...secretTags, ...tags].map((t) => ({ id: t.id, slug: t.slug, name: t.name, color: t.color })) + }; + }; + + const detachTags = async ({ + secretName, + tagSlugs, + path: secretPath, + environment, + type, + projectSlug, + actor, + actorAuthMethod, + actorOrgId, + actorId + }: TAttachSecretTagsDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + + await projectDAL.checkProjectUpgradeStatus(project.id); + + const secret = await getSecretByName({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + projectId: project.id, + environment, + path: secretPath, + secretName, + type + }); + + if (!secret) { + throw new BadRequestError({ message: "Secret not found" }); + } + const folder = await folderDAL.findBySecretPath(project.id, environment, secretPath); + + if (!folder) { + throw new BadRequestError({ message: "Folder not found" }); + } + + const tags = await secretTagDAL.find({ + projectId: project.id, + $in: { + slug: tagSlugs + } + }); + + if (tags.length !== tagSlugs.length) { + throw new BadRequestError({ message: "One or more tags not found." }); + } + + const secretTags = await secretDAL.getSecretTags(secret.id); + + // Make sure all the tags exist on the secret + const tagIdsToRemove = tags.map((tag) => tag.id); + const secretTagIds = secretTags.map((tag) => tag.id); + + if (!tagIdsToRemove.every((el) => secretTagIds.includes(el))) { + throw new BadRequestError({ message: "One or more tags not found on the secret" }); + } + + const newTags = secretTags.filter((tag) => !tagIdsToRemove.includes(tag.id)); + + const updatedSecret = await secretDAL.transaction(async (tx) => + fnSecretBulkUpdate({ + folderId: folder.id, + projectId: project.id, + inputSecrets: [ + { + filter: { id: secret.id }, + data: { + tags: newTags.map((tag) => tag.id) + } + } + ], + secretDAL, + secretVersionDAL, + secretTagDAL, + secretVersionTagDAL, + tx + }) + ); + + await snapshotService.performSnapshot(folder.id); + await secretQueueService.syncSecrets({ secretPath, projectId: project.id, environment }); + + return { + ...updatedSecret[0], + tags: newTags + }; + }; + return { + attachTags, + detachTags, createSecret, deleteSecret, updateSecret, diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 7ad4d65d7..efd4f0f8b 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -206,6 +206,15 @@ export type TFnSecretBulkUpdate = { tx?: Knex; }; +export type TAttachSecretTagsDTO = { + projectSlug: string; + secretName: string; + tagSlugs: string[]; + environment: string; + path: string; + type: SecretType; +} & Omit; + export type TFnSecretBulkDelete = { folderId: string; projectId: string; diff --git a/docs/api-reference/endpoints/secret-tags/create.mdx b/docs/api-reference/endpoints/secret-tags/create.mdx new file mode 100644 index 000000000..82d0eed17 --- /dev/null +++ b/docs/api-reference/endpoints/secret-tags/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/workspace/{projectId}/tags" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-tags/delete.mdx b/docs/api-reference/endpoints/secret-tags/delete.mdx new file mode 100644 index 000000000..cc98f03c2 --- /dev/null +++ b/docs/api-reference/endpoints/secret-tags/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/workspace/{projectId}/tags/{tagId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-tags/list.mdx b/docs/api-reference/endpoints/secret-tags/list.mdx new file mode 100644 index 000000000..c4a940f77 --- /dev/null +++ b/docs/api-reference/endpoints/secret-tags/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/workspace/{projectId}/tags" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/secrets/attach-tags.mdx b/docs/api-reference/endpoints/secrets/attach-tags.mdx new file mode 100644 index 000000000..8dd0e6081 --- /dev/null +++ b/docs/api-reference/endpoints/secrets/attach-tags.mdx @@ -0,0 +1,4 @@ +--- +title: "Attach tags" +openapi: "POST /api/v3/secrets/tags/{secretName}" +--- diff --git a/docs/api-reference/endpoints/secrets/detach-tags.mdx b/docs/api-reference/endpoints/secrets/detach-tags.mdx new file mode 100644 index 000000000..a74b1174e --- /dev/null +++ b/docs/api-reference/endpoints/secrets/detach-tags.mdx @@ -0,0 +1,4 @@ +--- +title: "Detach tags" +openapi: "DELETE /api/v3/secrets/tags/{secretName}" +--- \ No newline at end of file diff --git a/docs/mint.json b/docs/mint.json index 732c0f036..625974241 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -467,6 +467,14 @@ "api-reference/endpoints/folders/delete" ] }, + { + "group": "Secret tags", + "pages": [ + "api-reference/endpoints/secret-tags/list", + "api-reference/endpoints/secret-tags/create", + "api-reference/endpoints/secret-tags/delete" + ] + }, { "group": "Secrets", "pages": [ @@ -474,7 +482,9 @@ "api-reference/endpoints/secrets/create", "api-reference/endpoints/secrets/read", "api-reference/endpoints/secrets/update", - "api-reference/endpoints/secrets/delete" + "api-reference/endpoints/secrets/delete", + "api-reference/endpoints/secrets/attach-tags", + "api-reference/endpoints/secrets/detach-tags" ] }, {