mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-16 05:13:43 +00:00
Merge branch 'main' into ENG-2773
This commit is contained in:
@@ -36,7 +36,8 @@ export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvide
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRET_LEASES.CREATE.path),
|
||||
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.path)
|
||||
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.environmentSlug),
|
||||
config: z.any().optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { DynamicSecretLeasesSchema } from "@app/db/schemas";
|
||||
import { ApiDocsTags, DYNAMIC_SECRET_LEASES } from "@app/lib/api-docs";
|
||||
import { daysToMillisecond } from "@app/lib/dates";
|
||||
import { removeTrailingSlash } from "@app/lib/fn";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { SanitizedDynamicSecretSchema } from "@app/server/routes/sanitizedSchemas";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerKubernetesDynamicSecretLeaseRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.DynamicSecrets],
|
||||
body: z.object({
|
||||
dynamicSecretName: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.dynamicSecretName).toLowerCase(),
|
||||
projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.projectSlug),
|
||||
ttl: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(DYNAMIC_SECRET_LEASES.CREATE.ttl)
|
||||
.superRefine((val, ctx) => {
|
||||
if (!val) return;
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be greater than 1min" });
|
||||
if (valMs > daysToMillisecond(1))
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
}),
|
||||
path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRET_LEASES.CREATE.path),
|
||||
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.environmentSlug),
|
||||
config: z
|
||||
.object({
|
||||
namespace: z.string().min(1).optional().describe(DYNAMIC_SECRET_LEASES.KUBERNETES.CREATE.config.namespace)
|
||||
})
|
||||
.optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
lease: DynamicSecretLeasesSchema,
|
||||
dynamicSecret: SanitizedDynamicSecretSchema,
|
||||
data: z.unknown()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { data, lease, dynamicSecret } = await server.services.dynamicSecretLease.create({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
name: req.body.dynamicSecretName,
|
||||
...req.body
|
||||
});
|
||||
return { lease, data, dynamicSecret };
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { registerAssumePrivilegeRouter } from "./assume-privilege-router";
|
||||
import { registerAuditLogStreamRouter } from "./audit-log-stream-router";
|
||||
import { registerCaCrlRouter } from "./certificate-authority-crl-router";
|
||||
import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router";
|
||||
import { registerKubernetesDynamicSecretLeaseRouter } from "./dynamic-secret-lease-routers/kubernetes-lease-router";
|
||||
import { registerDynamicSecretRouter } from "./dynamic-secret-router";
|
||||
import { registerExternalKmsRouter } from "./external-kms-router";
|
||||
import { registerGatewayRouter } from "./gateway-router";
|
||||
@@ -18,6 +19,7 @@ import { registerLdapRouter } from "./ldap-router";
|
||||
import { registerLicenseRouter } from "./license-router";
|
||||
import { registerOidcRouter } from "./oidc-router";
|
||||
import { registerOrgRoleRouter } from "./org-role-router";
|
||||
import { registerPITRouter } from "./pit-router";
|
||||
import { registerProjectRoleRouter } from "./project-role-router";
|
||||
import { registerProjectRouter } from "./project-router";
|
||||
import { registerRateLimitRouter } from "./rate-limit-router";
|
||||
@@ -53,6 +55,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
|
||||
{ prefix: "/workspace" }
|
||||
);
|
||||
await server.register(registerSnapshotRouter, { prefix: "/secret-snapshot" });
|
||||
await server.register(registerPITRouter, { prefix: "/pit" });
|
||||
await server.register(registerSecretApprovalPolicyRouter, { prefix: "/secret-approvals" });
|
||||
await server.register(registerSecretApprovalRequestRouter, {
|
||||
prefix: "/secret-approval-requests"
|
||||
@@ -69,6 +72,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
|
||||
async (dynamicSecretRouter) => {
|
||||
await dynamicSecretRouter.register(registerDynamicSecretRouter);
|
||||
await dynamicSecretRouter.register(registerDynamicSecretLeaseRouter, { prefix: "/leases" });
|
||||
await dynamicSecretRouter.register(registerKubernetesDynamicSecretLeaseRouter, { prefix: "/leases/kubernetes" });
|
||||
},
|
||||
{ prefix: "/dynamic-secrets" }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
/* eslint-disable @typescript-eslint/no-base-to-string */
|
||||
import { z } from "zod";
|
||||
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { removeTrailingSlash } from "@app/lib/fn";
|
||||
import { readLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { booleanSchema } from "@app/server/routes/sanitizedSchemas";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { commitChangesResponseSchema, resourceChangeSchema } from "@app/services/folder-commit/folder-commit-schemas";
|
||||
|
||||
const commitHistoryItemSchema = z.object({
|
||||
id: z.string(),
|
||||
folderId: z.string(),
|
||||
actorType: z.string(),
|
||||
actorMetadata: z.unknown().optional(),
|
||||
message: z.string().optional().nullable(),
|
||||
commitId: z.string(),
|
||||
createdAt: z.string().or(z.date()),
|
||||
envId: z.string()
|
||||
});
|
||||
|
||||
const folderStateSchema = z.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
id: z.string(),
|
||||
versionId: z.string(),
|
||||
secretKey: z.string().optional(),
|
||||
secretVersion: z.number().optional(),
|
||||
folderName: z.string().optional(),
|
||||
folderVersion: z.number().optional()
|
||||
})
|
||||
);
|
||||
|
||||
export const registerPITRouter = async (server: FastifyZodProvider) => {
|
||||
// Get commits count for a folder
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/commits/count",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
environment: z.string().trim(),
|
||||
path: z.string().trim().default("/").transform(removeTrailingSlash),
|
||||
projectId: z.string().trim()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
count: z.number(),
|
||||
folderId: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const result = await server.services.pit.getCommitsCount({
|
||||
actor: req.permission?.type,
|
||||
actorId: req.permission?.id,
|
||||
actorOrgId: req.permission?.orgId,
|
||||
actorAuthMethod: req.permission?.authMethod,
|
||||
projectId: req.query.projectId,
|
||||
environment: req.query.environment,
|
||||
path: req.query.path
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: req.query.projectId,
|
||||
event: {
|
||||
type: EventType.GET_PROJECT_PIT_COMMIT_COUNT,
|
||||
metadata: {
|
||||
environment: req.query.environment,
|
||||
path: req.query.path,
|
||||
commitCount: result.count.toString()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
// Get all commits for a folder
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/commits",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
environment: z.string().trim(),
|
||||
path: z.string().trim().default("/").transform(removeTrailingSlash),
|
||||
projectId: z.string().trim(),
|
||||
offset: z.coerce.number().min(0).default(0),
|
||||
limit: z.coerce.number().min(1).max(100).default(20),
|
||||
search: z.string().trim().optional(),
|
||||
sort: z.enum(["asc", "desc"]).default("desc")
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
commits: commitHistoryItemSchema.array(),
|
||||
total: z.number(),
|
||||
hasMore: z.boolean()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const result = await server.services.pit.getCommitsForFolder({
|
||||
actor: req.permission?.type,
|
||||
actorId: req.permission?.id,
|
||||
actorOrgId: req.permission?.orgId,
|
||||
actorAuthMethod: req.permission?.authMethod,
|
||||
projectId: req.query.projectId,
|
||||
environment: req.query.environment,
|
||||
path: req.query.path,
|
||||
offset: req.query.offset,
|
||||
limit: req.query.limit,
|
||||
search: req.query.search,
|
||||
sort: req.query.sort
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: req.query.projectId,
|
||||
event: {
|
||||
type: EventType.GET_PROJECT_PIT_COMMITS,
|
||||
metadata: {
|
||||
environment: req.query.environment,
|
||||
path: req.query.path,
|
||||
commitCount: result.commits.length.toString(),
|
||||
offset: req.query.offset.toString(),
|
||||
limit: req.query.limit.toString(),
|
||||
search: req.query.search,
|
||||
sort: req.query.sort
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
// Get commit changes for a specific commit
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/commits/:commitId/changes",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
commitId: z.string().trim()
|
||||
}),
|
||||
querystring: z.object({
|
||||
projectId: z.string().trim()
|
||||
}),
|
||||
response: {
|
||||
200: commitChangesResponseSchema
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const result = await server.services.pit.getCommitChanges({
|
||||
actor: req.permission?.type,
|
||||
actorId: req.permission?.id,
|
||||
actorOrgId: req.permission?.orgId,
|
||||
actorAuthMethod: req.permission?.authMethod,
|
||||
projectId: req.query.projectId,
|
||||
commitId: req.params.commitId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: req.query.projectId,
|
||||
event: {
|
||||
type: EventType.GET_PROJECT_PIT_COMMIT_CHANGES,
|
||||
metadata: {
|
||||
commitId: req.params.commitId,
|
||||
changesCount: (result.changes.changes?.length || 0).toString()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
// Retrieve rollback changes for a commit
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/commits/:commitId/compare",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
commitId: z.string().trim()
|
||||
}),
|
||||
querystring: z.object({
|
||||
folderId: z.string().trim(),
|
||||
environment: z.string().trim(),
|
||||
deepRollback: booleanSchema.default(false),
|
||||
secretPath: z.string().trim().default("/").transform(removeTrailingSlash),
|
||||
projectId: z.string().trim()
|
||||
}),
|
||||
response: {
|
||||
200: z.array(
|
||||
z.object({
|
||||
folderId: z.string(),
|
||||
folderName: z.string(),
|
||||
folderPath: z.string().optional(),
|
||||
changes: z.array(resourceChangeSchema)
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const result = await server.services.pit.compareCommitChanges({
|
||||
actor: req.permission?.type,
|
||||
actorId: req.permission?.id,
|
||||
actorOrgId: req.permission?.orgId,
|
||||
actorAuthMethod: req.permission?.authMethod,
|
||||
projectId: req.query.projectId,
|
||||
commitId: req.params.commitId,
|
||||
folderId: req.query.folderId,
|
||||
environment: req.query.environment,
|
||||
deepRollback: req.query.deepRollback,
|
||||
secretPath: req.query.secretPath
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: req.query.projectId,
|
||||
event: {
|
||||
type: EventType.PIT_COMPARE_FOLDER_STATES,
|
||||
metadata: {
|
||||
targetCommitId: req.params.commitId,
|
||||
folderId: req.query.folderId,
|
||||
deepRollback: req.query.deepRollback,
|
||||
diffsCount: result.length.toString(),
|
||||
environment: req.query.environment,
|
||||
folderPath: req.query.secretPath
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
// Rollback to a previous commit
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/commits/:commitId/rollback",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
commitId: z.string().trim()
|
||||
}),
|
||||
body: z.object({
|
||||
folderId: z.string().trim(),
|
||||
deepRollback: z.boolean().default(false),
|
||||
message: z.string().max(256).trim().optional(),
|
||||
environment: z.string().trim(),
|
||||
projectId: z.string().trim()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
success: z.boolean(),
|
||||
secretChangesCount: z.number().optional(),
|
||||
folderChangesCount: z.number().optional(),
|
||||
totalChanges: z.number().optional()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const result = await server.services.pit.rollbackToCommit({
|
||||
actor: req.permission?.type,
|
||||
actorId: req.permission?.id,
|
||||
actorOrgId: req.permission?.orgId,
|
||||
actorAuthMethod: req.permission?.authMethod,
|
||||
projectId: req.body.projectId,
|
||||
commitId: req.params.commitId,
|
||||
folderId: req.body.folderId,
|
||||
deepRollback: req.body.deepRollback,
|
||||
message: req.body.message,
|
||||
environment: req.body.environment
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: req.body.projectId,
|
||||
event: {
|
||||
type: EventType.PIT_ROLLBACK_COMMIT,
|
||||
metadata: {
|
||||
targetCommitId: req.params.commitId,
|
||||
environment: req.body.environment,
|
||||
folderId: req.body.folderId,
|
||||
deepRollback: req.body.deepRollback,
|
||||
message: req.body.message || "Rollback to previous commit",
|
||||
totalChanges: result.totalChanges?.toString() || "0"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
// Revert commit
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/commits/:commitId/revert",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
commitId: z.string().trim()
|
||||
}),
|
||||
body: z.object({
|
||||
projectId: z.string().trim()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
originalCommitId: z.string(),
|
||||
revertCommitId: z.string().optional(),
|
||||
changesReverted: z.number().optional()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const result = await server.services.pit.revertCommit({
|
||||
actor: req.permission?.type,
|
||||
actorId: req.permission?.id,
|
||||
actorOrgId: req.permission?.orgId,
|
||||
actorAuthMethod: req.permission?.authMethod,
|
||||
projectId: req.body.projectId,
|
||||
commitId: req.params.commitId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: req.body.projectId,
|
||||
event: {
|
||||
type: EventType.PIT_REVERT_COMMIT,
|
||||
metadata: {
|
||||
commitId: req.params.commitId,
|
||||
revertCommitId: result.revertCommitId,
|
||||
changesReverted: result.changesReverted?.toString()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
// Folder state at commit
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/commits/:commitId",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
commitId: z.string().trim()
|
||||
}),
|
||||
querystring: z.object({
|
||||
folderId: z.string().trim(),
|
||||
projectId: z.string().trim()
|
||||
}),
|
||||
response: {
|
||||
200: folderStateSchema
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const result = await server.services.pit.getFolderStateAtCommit({
|
||||
actor: req.permission?.type,
|
||||
actorId: req.permission?.id,
|
||||
actorOrgId: req.permission?.orgId,
|
||||
actorAuthMethod: req.permission?.authMethod,
|
||||
projectId: req.query.projectId,
|
||||
commitId: req.params.commitId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: req.query.projectId,
|
||||
event: {
|
||||
type: EventType.PIT_GET_FOLDER_STATE,
|
||||
metadata: {
|
||||
commitId: req.params.commitId,
|
||||
folderId: req.query.folderId,
|
||||
resourceCount: result.length.toString()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -65,9 +65,10 @@ export const registerSnapshotRouter = async (server: FastifyZodProvider) => {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
hide: true,
|
||||
deprecated: true,
|
||||
tags: [ApiDocsTags.Projects],
|
||||
description: "Roll back project secrets to those captured in a secret snapshot version.",
|
||||
description: "(Deprecated) Roll back project secrets to those captured in a secret snapshot version.",
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
@@ -84,6 +85,10 @@ export const registerSnapshotRouter = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
throw new Error(
|
||||
"This endpoint is deprecated. Please use the new PIT recovery system. More information is available at: https://infisical.com/docs/documentation/platform/pit-recovery."
|
||||
);
|
||||
|
||||
const secretSnapshot = await server.services.snapshot.rollbackSnapshot({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
TSecretSyncRaw,
|
||||
TUpdateSecretSyncDTO
|
||||
} from "@app/services/secret-sync/secret-sync-types";
|
||||
import { TWebhookPayloads } from "@app/services/webhook/webhook-types";
|
||||
import { WorkflowIntegration } from "@app/services/workflow-integration/workflow-integration-types";
|
||||
|
||||
import { KmipPermission } from "../kmip/kmip-enum";
|
||||
@@ -206,6 +207,7 @@ export enum EventType {
|
||||
CREATE_WEBHOOK = "create-webhook",
|
||||
UPDATE_WEBHOOK_STATUS = "update-webhook-status",
|
||||
DELETE_WEBHOOK = "delete-webhook",
|
||||
WEBHOOK_TRIGGERED = "webhook-triggered",
|
||||
GET_SECRET_IMPORTS = "get-secret-imports",
|
||||
GET_SECRET_IMPORT = "get-secret-import",
|
||||
CREATE_SECRET_IMPORT = "create-secret-import",
|
||||
@@ -393,6 +395,13 @@ export enum EventType {
|
||||
PROJECT_ASSUME_PRIVILEGE_SESSION_START = "project-assume-privileges-session-start",
|
||||
PROJECT_ASSUME_PRIVILEGE_SESSION_END = "project-assume-privileges-session-end",
|
||||
|
||||
GET_PROJECT_PIT_COMMITS = "get-project-pit-commits",
|
||||
GET_PROJECT_PIT_COMMIT_CHANGES = "get-project-pit-commit-changes",
|
||||
GET_PROJECT_PIT_COMMIT_COUNT = "get-project-pit-commit-count",
|
||||
PIT_ROLLBACK_COMMIT = "pit-rollback-commit",
|
||||
PIT_REVERT_COMMIT = "pit-revert-commit",
|
||||
PIT_GET_FOLDER_STATE = "pit-get-folder-state",
|
||||
PIT_COMPARE_FOLDER_STATES = "pit-compare-folder-states",
|
||||
SECRET_SCANNING_DATA_SOURCE_LIST = "secret-scanning-data-source-list",
|
||||
SECRET_SCANNING_DATA_SOURCE_CREATE = "secret-scanning-data-source-create",
|
||||
SECRET_SCANNING_DATA_SOURCE_UPDATE = "secret-scanning-data-source-update",
|
||||
@@ -1440,6 +1449,14 @@ interface DeleteWebhookEvent {
|
||||
};
|
||||
}
|
||||
|
||||
export interface WebhookTriggeredEvent {
|
||||
type: EventType.WEBHOOK_TRIGGERED;
|
||||
metadata: {
|
||||
webhookId: string;
|
||||
status: string;
|
||||
} & TWebhookPayloads;
|
||||
}
|
||||
|
||||
interface GetSecretImportsEvent {
|
||||
type: EventType.GET_SECRET_IMPORTS;
|
||||
metadata: {
|
||||
@@ -2979,6 +2996,78 @@ interface MicrosoftTeamsWorkflowIntegrationUpdateEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface GetProjectPitCommitsEvent {
|
||||
type: EventType.GET_PROJECT_PIT_COMMITS;
|
||||
metadata: {
|
||||
commitCount: string;
|
||||
environment: string;
|
||||
path: string;
|
||||
offset: string;
|
||||
limit: string;
|
||||
search?: string;
|
||||
sort: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetProjectPitCommitChangesEvent {
|
||||
type: EventType.GET_PROJECT_PIT_COMMIT_CHANGES;
|
||||
metadata: {
|
||||
changesCount: string;
|
||||
commitId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetProjectPitCommitCountEvent {
|
||||
type: EventType.GET_PROJECT_PIT_COMMIT_COUNT;
|
||||
metadata: {
|
||||
environment: string;
|
||||
path: string;
|
||||
commitCount: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PitRollbackCommitEvent {
|
||||
type: EventType.PIT_ROLLBACK_COMMIT;
|
||||
metadata: {
|
||||
targetCommitId: string;
|
||||
folderId: string;
|
||||
deepRollback: boolean;
|
||||
message: string;
|
||||
totalChanges: string;
|
||||
environment: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PitRevertCommitEvent {
|
||||
type: EventType.PIT_REVERT_COMMIT;
|
||||
metadata: {
|
||||
commitId: string;
|
||||
revertCommitId?: string;
|
||||
changesReverted?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PitGetFolderStateEvent {
|
||||
type: EventType.PIT_GET_FOLDER_STATE;
|
||||
metadata: {
|
||||
commitId: string;
|
||||
folderId: string;
|
||||
resourceCount: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PitCompareFolderStatesEvent {
|
||||
type: EventType.PIT_COMPARE_FOLDER_STATES;
|
||||
metadata: {
|
||||
targetCommitId: string;
|
||||
folderId: string;
|
||||
deepRollback: boolean;
|
||||
diffsCount: string;
|
||||
environment: string;
|
||||
folderPath: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SecretScanningDataSourceListEvent {
|
||||
type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST;
|
||||
metadata: {
|
||||
@@ -3221,6 +3310,7 @@ export type Event =
|
||||
| CreateWebhookEvent
|
||||
| UpdateWebhookStatusEvent
|
||||
| DeleteWebhookEvent
|
||||
| WebhookTriggeredEvent
|
||||
| GetSecretImportsEvent
|
||||
| GetSecretImportEvent
|
||||
| CreateSecretImportEvent
|
||||
@@ -3397,6 +3487,13 @@ export type Event =
|
||||
| MicrosoftTeamsWorkflowIntegrationGetEvent
|
||||
| MicrosoftTeamsWorkflowIntegrationListEvent
|
||||
| MicrosoftTeamsWorkflowIntegrationUpdateEvent
|
||||
| GetProjectPitCommitsEvent
|
||||
| GetProjectPitCommitChangesEvent
|
||||
| PitRollbackCommitEvent
|
||||
| GetProjectPitCommitCountEvent
|
||||
| PitRevertCommitEvent
|
||||
| PitCompareFolderStatesEvent
|
||||
| PitGetFolderStateEvent
|
||||
| SecretScanningDataSourceListEvent
|
||||
| SecretScanningDataSourceGetEvent
|
||||
| SecretScanningDataSourceCreateEvent
|
||||
|
||||
@@ -10,6 +10,7 @@ import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal";
|
||||
import { DynamicSecretStatus } from "../dynamic-secret/dynamic-secret-types";
|
||||
import { DynamicSecretProviders, TDynamicProviderFns } from "../dynamic-secret/providers/models";
|
||||
import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal";
|
||||
import { TDynamicSecretLeaseConfig } from "./dynamic-secret-lease-types";
|
||||
|
||||
type TDynamicSecretLeaseQueueServiceFactoryDep = {
|
||||
queueService: TQueueServiceFactory;
|
||||
@@ -134,10 +135,15 @@ export const dynamicSecretLeaseQueueServiceFactory = ({
|
||||
|
||||
await Promise.all(dynamicSecretLeases.map(({ id }) => unsetLeaseRevocation(id)));
|
||||
await Promise.all(
|
||||
dynamicSecretLeases.map(({ externalEntityId }) =>
|
||||
selectedProvider.revoke(decryptedStoredInput, externalEntityId, {
|
||||
projectId: folder.projectId
|
||||
})
|
||||
dynamicSecretLeases.map(({ externalEntityId, config }) =>
|
||||
selectedProvider.revoke(
|
||||
decryptedStoredInput,
|
||||
externalEntityId,
|
||||
{
|
||||
projectId: folder.projectId
|
||||
},
|
||||
config as TDynamicSecretLeaseConfig
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
TCreateDynamicSecretLeaseDTO,
|
||||
TDeleteDynamicSecretLeaseDTO,
|
||||
TDetailsDynamicSecretLeaseDTO,
|
||||
TDynamicSecretLeaseConfig,
|
||||
TListDynamicSecretLeasesDTO,
|
||||
TRenewDynamicSecretLeaseDTO
|
||||
} from "./dynamic-secret-lease-types";
|
||||
@@ -77,7 +78,8 @@ export const dynamicSecretLeaseServiceFactory = ({
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
ttl
|
||||
ttl,
|
||||
config
|
||||
}: TCreateDynamicSecretLeaseDTO) => {
|
||||
const appCfg = getConfig();
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
@@ -163,7 +165,8 @@ export const dynamicSecretLeaseServiceFactory = ({
|
||||
expireAt: expireAt.getTime(),
|
||||
usernameTemplate: dynamicSecretCfg.usernameTemplate,
|
||||
identity,
|
||||
metadata: { projectId }
|
||||
metadata: { projectId },
|
||||
config
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === "object" && error !== null && "sqlMessage" in error) {
|
||||
@@ -177,8 +180,10 @@ export const dynamicSecretLeaseServiceFactory = ({
|
||||
expireAt,
|
||||
version: 1,
|
||||
dynamicSecretId: dynamicSecretCfg.id,
|
||||
externalEntityId: entityId
|
||||
externalEntityId: entityId,
|
||||
config
|
||||
});
|
||||
|
||||
await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(expireAt) - Number(new Date()));
|
||||
return { lease: dynamicSecretLease, dynamicSecret: dynamicSecretCfg, data };
|
||||
};
|
||||
@@ -342,7 +347,12 @@ export const dynamicSecretLeaseServiceFactory = ({
|
||||
) as object;
|
||||
|
||||
const revokeResponse = await selectedProvider
|
||||
.revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId, { projectId })
|
||||
.revoke(
|
||||
decryptedStoredInput,
|
||||
dynamicSecretLease.externalEntityId,
|
||||
{ projectId },
|
||||
dynamicSecretLease.config as TDynamicSecretLeaseConfig
|
||||
)
|
||||
.catch(async (err) => {
|
||||
// only propogate this error if forced is false
|
||||
if (!isForced) return { error: err as Error };
|
||||
|
||||
@@ -10,6 +10,7 @@ export type TCreateDynamicSecretLeaseDTO = {
|
||||
environmentSlug: string;
|
||||
ttl?: string;
|
||||
projectSlug: string;
|
||||
config?: TDynamicSecretLeaseConfig;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TDetailsDynamicSecretLeaseDTO = {
|
||||
@@ -41,3 +42,9 @@ export type TRenewDynamicSecretLeaseDTO = {
|
||||
ttl?: string;
|
||||
projectSlug: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TDynamicSecretKubernetesLeaseConfig = {
|
||||
namespace?: string;
|
||||
};
|
||||
|
||||
export type TDynamicSecretLeaseConfig = TDynamicSecretKubernetesLeaseConfig;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import axios from "axios";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import handlebars from "handlebars";
|
||||
import https from "https";
|
||||
|
||||
import { InternalServerError } from "@app/lib/errors";
|
||||
import { BadRequestError, InternalServerError } from "@app/lib/errors";
|
||||
import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
|
||||
import { TKubernetesTokenRequest } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-types";
|
||||
|
||||
import { TDynamicSecretKubernetesLeaseConfig } from "../../dynamic-secret-lease/dynamic-secret-lease-types";
|
||||
import { TGatewayServiceFactory } from "../../gateway/gateway-service";
|
||||
import {
|
||||
DynamicSecretKubernetesSchema,
|
||||
@@ -19,6 +20,9 @@ import {
|
||||
|
||||
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
|
||||
|
||||
// This value is just a placeholder. When using gateway auth method, the url is irrelevant.
|
||||
const GATEWAY_AUTH_DEFAULT_URL = "https://kubernetes.default.svc.cluster.local";
|
||||
|
||||
type TKubernetesProviderDTO = {
|
||||
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
|
||||
};
|
||||
@@ -36,7 +40,7 @@ const generateUsername = (usernameTemplate?: string | null) => {
|
||||
export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): TDynamicProviderFns => {
|
||||
const validateProviderInputs = async (inputs: unknown) => {
|
||||
const providerInputs = await DynamicSecretKubernetesSchema.parseAsync(inputs);
|
||||
if (!providerInputs.gatewayId) {
|
||||
if (!providerInputs.gatewayId && providerInputs.url) {
|
||||
await blockLocalAndPrivateIpAddresses(providerInputs.url);
|
||||
}
|
||||
|
||||
@@ -103,135 +107,173 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
const serviceAccountName = generateUsername();
|
||||
const roleBindingName = `${serviceAccountName}-role-binding`;
|
||||
|
||||
// 1. Create a test service account
|
||||
await axios.post(
|
||||
`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts`,
|
||||
{
|
||||
metadata: {
|
||||
name: serviceAccountName,
|
||||
namespace: providerInputs.namespace
|
||||
}
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT,
|
||||
httpsAgent
|
||||
}
|
||||
);
|
||||
const namespaces = providerInputs.namespace.split(",").map((namespace) => namespace.trim());
|
||||
|
||||
// 2. Create a test role binding
|
||||
const roleBindingUrl =
|
||||
providerInputs.roleType === KubernetesRoleType.ClusterRole
|
||||
? `${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings`
|
||||
: `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings`;
|
||||
|
||||
const roleBindingMetadata = {
|
||||
name: roleBindingName,
|
||||
...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace: providerInputs.namespace })
|
||||
};
|
||||
|
||||
await axios.post(
|
||||
roleBindingUrl,
|
||||
{
|
||||
metadata: roleBindingMetadata,
|
||||
roleRef: {
|
||||
kind: providerInputs.roleType === KubernetesRoleType.ClusterRole ? "ClusterRole" : "Role",
|
||||
name: providerInputs.role,
|
||||
apiGroup: "rbac.authorization.k8s.io"
|
||||
},
|
||||
subjects: [
|
||||
// Test each namespace sequentially instead of in parallel to simplify cleanup
|
||||
for await (const namespace of namespaces) {
|
||||
try {
|
||||
// 1. Create a test service account
|
||||
await axios.post(
|
||||
`${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts`,
|
||||
{
|
||||
kind: "ServiceAccount",
|
||||
name: serviceAccountName,
|
||||
namespace: providerInputs.namespace
|
||||
metadata: {
|
||||
name: serviceAccountName,
|
||||
namespace
|
||||
}
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Api
|
||||
? {
|
||||
httpsAgent
|
||||
}
|
||||
: {}),
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT,
|
||||
httpsAgent
|
||||
}
|
||||
);
|
||||
);
|
||||
|
||||
// 3. Request a token for the test service account
|
||||
await axios.post(
|
||||
`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${serviceAccountName}/token`,
|
||||
{
|
||||
spec: {
|
||||
expirationSeconds: 600, // 10 minutes
|
||||
...(providerInputs.audiences?.length ? { audiences: providerInputs.audiences } : {})
|
||||
// 2. Create a test role binding
|
||||
const roleBindingUrl =
|
||||
providerInputs.roleType === KubernetesRoleType.ClusterRole
|
||||
? `${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings`
|
||||
: `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings`;
|
||||
|
||||
const roleBindingMetadata = {
|
||||
name: roleBindingName,
|
||||
...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace })
|
||||
};
|
||||
|
||||
await axios.post(
|
||||
roleBindingUrl,
|
||||
{
|
||||
metadata: roleBindingMetadata,
|
||||
roleRef: {
|
||||
kind: providerInputs.roleType === KubernetesRoleType.ClusterRole ? "ClusterRole" : "Role",
|
||||
name: providerInputs.role,
|
||||
apiGroup: "rbac.authorization.k8s.io"
|
||||
},
|
||||
subjects: [
|
||||
{
|
||||
kind: "ServiceAccount",
|
||||
name: serviceAccountName,
|
||||
namespace
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Api
|
||||
? {
|
||||
httpsAgent
|
||||
}
|
||||
: {}),
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT
|
||||
}
|
||||
);
|
||||
|
||||
// 3. Request a token for the test service account
|
||||
await axios.post(
|
||||
`${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts/${serviceAccountName}/token`,
|
||||
{
|
||||
spec: {
|
||||
expirationSeconds: 600, // 10 minutes
|
||||
...(providerInputs.audiences?.length ? { audiences: providerInputs.audiences } : {})
|
||||
}
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Api
|
||||
? {
|
||||
httpsAgent
|
||||
}
|
||||
: {}),
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT
|
||||
}
|
||||
);
|
||||
|
||||
// 4. Cleanup: delete role binding and service account
|
||||
if (providerInputs.roleType === KubernetesRoleType.Role) {
|
||||
await axios.delete(
|
||||
`${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings/${roleBindingName}`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Api
|
||||
? {
|
||||
httpsAgent
|
||||
}
|
||||
: {}),
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await axios.delete(`${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/${roleBindingName}`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Api
|
||||
? {
|
||||
httpsAgent
|
||||
}
|
||||
: {}),
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT,
|
||||
httpsAgent
|
||||
}
|
||||
);
|
||||
|
||||
// 4. Cleanup: delete role binding and service account
|
||||
if (providerInputs.roleType === KubernetesRoleType.Role) {
|
||||
await axios.delete(
|
||||
`${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings/${roleBindingName}`,
|
||||
{
|
||||
await axios.delete(`${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts/${serviceAccountName}`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Api
|
||||
? {
|
||||
httpsAgent
|
||||
}
|
||||
: {}),
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT,
|
||||
httpsAgent
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT
|
||||
});
|
||||
} catch (error) {
|
||||
const cleanupInfo = `You may need to manually clean up the following resources in namespace "${namespace}": Service Account - ${serviceAccountName}, ${providerInputs.roleType === KubernetesRoleType.Role ? "Role" : "Cluster Role"} Binding - ${roleBindingName}.`;
|
||||
let mainErrorMessage = "Unknown error";
|
||||
if (error instanceof AxiosError) {
|
||||
mainErrorMessage = (error.response?.data as { message: string })?.message;
|
||||
} else if (error instanceof Error) {
|
||||
mainErrorMessage = error.message;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await axios.delete(`${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings/${roleBindingName}`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT,
|
||||
httpsAgent
|
||||
});
|
||||
}
|
||||
|
||||
await axios.delete(
|
||||
`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${serviceAccountName}`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT,
|
||||
httpsAgent
|
||||
throw new Error(`${mainErrorMessage}. ${cleanupInfo}`);
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const serviceAccountStaticCallback = async (host: string, port: number, httpsAgent?: https.Agent) => {
|
||||
@@ -247,17 +289,23 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Api
|
||||
? {
|
||||
httpsAgent
|
||||
}
|
||||
: {}),
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT,
|
||||
httpsAgent
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const url = new URL(providerInputs.url);
|
||||
const rawUrl =
|
||||
providerInputs.authMethod === KubernetesAuthMethod.Gateway ? GATEWAY_AUTH_DEFAULT_URL : providerInputs.url || "";
|
||||
const url = new URL(rawUrl);
|
||||
const k8sGatewayHost = url.hostname;
|
||||
const k8sPort = url.port ? Number(url.port) : 443;
|
||||
const k8sHost = `${url.protocol}//${url.hostname}`;
|
||||
@@ -315,11 +363,13 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
const create = async ({
|
||||
inputs,
|
||||
expireAt,
|
||||
usernameTemplate
|
||||
usernameTemplate,
|
||||
config
|
||||
}: {
|
||||
inputs: unknown;
|
||||
expireAt: number;
|
||||
usernameTemplate?: string | null;
|
||||
config?: TDynamicSecretKubernetesLeaseConfig;
|
||||
}) => {
|
||||
const providerInputs = await validateProviderInputs(inputs);
|
||||
|
||||
@@ -331,26 +381,44 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
const baseUrl = port ? `${host}:${port}` : host;
|
||||
const serviceAccountName = generateUsername(usernameTemplate);
|
||||
const roleBindingName = `${serviceAccountName}-role-binding`;
|
||||
const allowedNamespaces = providerInputs.namespace.split(",").map((namespace) => namespace.trim());
|
||||
|
||||
if (config?.namespace && !allowedNamespaces?.includes(config?.namespace)) {
|
||||
throw new BadRequestError({
|
||||
message: `Namespace ${config?.namespace} is not allowed. Allowed namespaces: ${allowedNamespaces?.join(", ")}`
|
||||
});
|
||||
}
|
||||
|
||||
const namespace = config?.namespace || allowedNamespaces[0];
|
||||
if (!namespace) {
|
||||
throw new BadRequestError({
|
||||
message: "No namespace provided"
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Create the service account
|
||||
await axios.post(
|
||||
`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts`,
|
||||
`${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts`,
|
||||
{
|
||||
metadata: {
|
||||
name: serviceAccountName,
|
||||
namespace: providerInputs.namespace
|
||||
namespace
|
||||
}
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Api
|
||||
? {
|
||||
httpsAgent
|
||||
}
|
||||
: {}),
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT,
|
||||
httpsAgent
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT
|
||||
}
|
||||
);
|
||||
|
||||
@@ -358,11 +426,11 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
const roleBindingUrl =
|
||||
providerInputs.roleType === KubernetesRoleType.ClusterRole
|
||||
? `${baseUrl}/apis/rbac.authorization.k8s.io/v1/clusterrolebindings`
|
||||
: `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings`;
|
||||
: `${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings`;
|
||||
|
||||
const roleBindingMetadata = {
|
||||
name: roleBindingName,
|
||||
...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace: providerInputs.namespace })
|
||||
...(providerInputs.roleType !== KubernetesRoleType.ClusterRole && { namespace })
|
||||
};
|
||||
|
||||
await axios.post(
|
||||
@@ -378,7 +446,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
{
|
||||
kind: "ServiceAccount",
|
||||
name: serviceAccountName,
|
||||
namespace: providerInputs.namespace
|
||||
namespace
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -386,18 +454,22 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Api
|
||||
? {
|
||||
httpsAgent
|
||||
}
|
||||
: {}),
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT,
|
||||
httpsAgent
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT
|
||||
}
|
||||
);
|
||||
|
||||
// 3. Request a token for the service account
|
||||
const res = await axios.post<TKubernetesTokenRequest>(
|
||||
`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${serviceAccountName}/token`,
|
||||
`${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts/${serviceAccountName}/token`,
|
||||
{
|
||||
spec: {
|
||||
expirationSeconds: Math.floor((expireAt - Date.now()) / 1000),
|
||||
@@ -408,12 +480,16 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Api
|
||||
? {
|
||||
httpsAgent
|
||||
}
|
||||
: {}),
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT,
|
||||
httpsAgent
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT
|
||||
}
|
||||
);
|
||||
|
||||
@@ -425,6 +501,12 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
throw new Error("invalid callback");
|
||||
}
|
||||
|
||||
if (config?.namespace && config.namespace !== providerInputs.namespace) {
|
||||
throw new BadRequestError({
|
||||
message: `Namespace ${config?.namespace} is not allowed. Allowed namespace: ${providerInputs.namespace}.`
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = port ? `${host}:${port}` : host;
|
||||
|
||||
const res = await axios.post<TKubernetesTokenRequest>(
|
||||
@@ -439,19 +521,25 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Api
|
||||
? {
|
||||
httpsAgent
|
||||
}
|
||||
: {}),
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT,
|
||||
httpsAgent
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT
|
||||
}
|
||||
);
|
||||
|
||||
return { ...res.data, serviceAccountName: providerInputs.serviceAccountName };
|
||||
};
|
||||
|
||||
const url = new URL(providerInputs.url);
|
||||
const rawUrl =
|
||||
providerInputs.authMethod === KubernetesAuthMethod.Gateway ? GATEWAY_AUTH_DEFAULT_URL : providerInputs.url || "";
|
||||
const url = new URL(rawUrl);
|
||||
const k8sHost = `${url.protocol}//${url.hostname}`;
|
||||
const k8sGatewayHost = url.hostname;
|
||||
const k8sPort = url.port ? Number(url.port) : 443;
|
||||
@@ -511,7 +599,13 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async (inputs: unknown, entityId: string) => {
|
||||
const revoke = async (
|
||||
inputs: unknown,
|
||||
entityId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_metadata: { projectId: string },
|
||||
config?: TDynamicSecretKubernetesLeaseConfig
|
||||
) => {
|
||||
const providerInputs = await validateProviderInputs(inputs);
|
||||
|
||||
const serviceAccountDynamicCallback = async (host: string, port: number, httpsAgent?: https.Agent) => {
|
||||
@@ -522,19 +616,25 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
const baseUrl = port ? `${host}:${port}` : host;
|
||||
const roleBindingName = `${entityId}-role-binding`;
|
||||
|
||||
const namespace = config?.namespace ?? providerInputs.namespace.split(",")[0].trim();
|
||||
|
||||
if (providerInputs.roleType === KubernetesRoleType.Role) {
|
||||
await axios.delete(
|
||||
`${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${providerInputs.namespace}/rolebindings/${roleBindingName}`,
|
||||
`${baseUrl}/apis/rbac.authorization.k8s.io/v1/namespaces/${namespace}/rolebindings/${roleBindingName}`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Api
|
||||
? {
|
||||
httpsAgent
|
||||
}
|
||||
: {}),
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT,
|
||||
httpsAgent
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT
|
||||
}
|
||||
);
|
||||
} else {
|
||||
@@ -542,31 +642,44 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO):
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Api
|
||||
? {
|
||||
httpsAgent
|
||||
}
|
||||
: {}),
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT,
|
||||
httpsAgent
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the service account
|
||||
await axios.delete(`${baseUrl}/api/v1/namespaces/${providerInputs.namespace}/serviceaccounts/${entityId}`, {
|
||||
await axios.delete(`${baseUrl}/api/v1/namespaces/${namespace}/serviceaccounts/${entityId}`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken }
|
||||
? { "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount }
|
||||
: { Authorization: `Bearer ${providerInputs.clusterToken}` })
|
||||
},
|
||||
...(providerInputs.authMethod === KubernetesAuthMethod.Api
|
||||
? {
|
||||
httpsAgent
|
||||
}
|
||||
: {}),
|
||||
signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT),
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT,
|
||||
httpsAgent
|
||||
timeout: EXTERNAL_REQUEST_TIMEOUT
|
||||
});
|
||||
};
|
||||
|
||||
if (providerInputs.credentialType === KubernetesCredentialType.Dynamic) {
|
||||
const url = new URL(providerInputs.url);
|
||||
const rawUrl =
|
||||
providerInputs.authMethod === KubernetesAuthMethod.Gateway
|
||||
? GATEWAY_AUTH_DEFAULT_URL
|
||||
: providerInputs.url || "";
|
||||
|
||||
const url = new URL(rawUrl);
|
||||
const k8sGatewayHost = url.hostname;
|
||||
const k8sPort = url.port ? Number(url.port) : 443;
|
||||
const k8sHost = `${url.protocol}//${url.hostname}`;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import RE2 from "re2";
|
||||
import { z } from "zod";
|
||||
|
||||
import { CharacterType, characterValidator } from "@app/lib/validator/validate-string";
|
||||
|
||||
import { TDynamicSecretLeaseConfig } from "../../dynamic-secret-lease/dynamic-secret-lease-types";
|
||||
|
||||
export type PasswordRequirements = {
|
||||
length: number;
|
||||
required: {
|
||||
@@ -323,24 +328,54 @@ export const LdapSchema = z.union([
|
||||
export const DynamicSecretKubernetesSchema = z
|
||||
.discriminatedUnion("credentialType", [
|
||||
z.object({
|
||||
url: z.string().url().trim().min(1),
|
||||
url: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((val: string | undefined) => !val || new RE2(/^https?:\/\/.+/).test(val), {
|
||||
message: "Invalid URL. Must start with http:// or https:// (e.g. https://example.com)"
|
||||
}),
|
||||
clusterToken: z.string().trim().optional(),
|
||||
ca: z.string().optional(),
|
||||
sslEnabled: z.boolean().default(false),
|
||||
credentialType: z.literal(KubernetesCredentialType.Static),
|
||||
serviceAccountName: z.string().trim().min(1),
|
||||
namespace: z.string().trim().min(1),
|
||||
namespace: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((val) => !val.includes(","), "Namespace must be a single value, not a comma-separated list")
|
||||
.refine(
|
||||
(val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val),
|
||||
"Invalid namespace format"
|
||||
),
|
||||
gatewayId: z.string().optional(),
|
||||
audiences: z.array(z.string().trim().min(1)),
|
||||
authMethod: z.nativeEnum(KubernetesAuthMethod).default(KubernetesAuthMethod.Api)
|
||||
}),
|
||||
z.object({
|
||||
url: z.string().url().trim().min(1),
|
||||
url: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.refine((val: string | undefined) => !val || new RE2(/^https?:\/\/.+/).test(val), {
|
||||
message: "Invalid URL. Must start with http:// or https:// (e.g. https://example.com)"
|
||||
}),
|
||||
clusterToken: z.string().trim().optional(),
|
||||
ca: z.string().optional(),
|
||||
sslEnabled: z.boolean().default(false),
|
||||
credentialType: z.literal(KubernetesCredentialType.Dynamic),
|
||||
namespace: z.string().trim().min(1),
|
||||
namespace: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((val) => {
|
||||
const namespaces = val.split(",").map((ns) => ns.trim());
|
||||
return (
|
||||
namespaces.length > 0 &&
|
||||
namespaces.every((ns) => ns.length > 0) &&
|
||||
namespaces.every((ns) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(ns))
|
||||
);
|
||||
}, "Must be a valid comma-separated list of namespace values"),
|
||||
gatewayId: z.string().optional(),
|
||||
audiences: z.array(z.string().trim().min(1)),
|
||||
roleType: z.nativeEnum(KubernetesRoleType),
|
||||
@@ -356,12 +391,21 @@ export const DynamicSecretKubernetesSchema = z
|
||||
message: "When auth method is set to Gateway, a gateway must be selected"
|
||||
});
|
||||
}
|
||||
if ((data.authMethod === KubernetesAuthMethod.Api || !data.authMethod) && !data.clusterToken) {
|
||||
ctx.addIssue({
|
||||
path: ["clusterToken"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "When auth method is set to Manual Token, a cluster token must be provided"
|
||||
});
|
||||
if (data.authMethod === KubernetesAuthMethod.Api || !data.authMethod) {
|
||||
if (!data.clusterToken) {
|
||||
ctx.addIssue({
|
||||
path: ["clusterToken"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "When auth method is set to Token, a cluster token must be provided"
|
||||
});
|
||||
}
|
||||
if (!data.url) {
|
||||
ctx.addIssue({
|
||||
path: ["url"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "When auth method is set to Token, a cluster URL must be provided"
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -475,10 +519,16 @@ export type TDynamicProviderFns = {
|
||||
name: string;
|
||||
};
|
||||
metadata: { projectId: string };
|
||||
config?: TDynamicSecretLeaseConfig;
|
||||
}) => Promise<{ entityId: string; data: unknown }>;
|
||||
validateConnection: (inputs: unknown, metadata: { projectId: string }) => Promise<boolean>;
|
||||
validateProviderInputs: (inputs: object, metadata: { projectId: string }) => Promise<unknown>;
|
||||
revoke: (inputs: unknown, entityId: string, metadata: { projectId: string }) => Promise<{ entityId: string }>;
|
||||
revoke: (
|
||||
inputs: unknown,
|
||||
entityId: string,
|
||||
metadata: { projectId: string },
|
||||
config?: TDynamicSecretLeaseConfig
|
||||
) => Promise<{ entityId: string }>;
|
||||
renew: (
|
||||
inputs: unknown,
|
||||
entityId: string,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionCmekActions,
|
||||
ProjectPermissionCommitsActions,
|
||||
ProjectPermissionDynamicSecretActions,
|
||||
ProjectPermissionGroupActions,
|
||||
ProjectPermissionIdentityActions,
|
||||
@@ -90,6 +91,11 @@ const buildAdminPermissionRules = () => {
|
||||
ProjectPermissionSub.Certificates
|
||||
);
|
||||
|
||||
can(
|
||||
[ProjectPermissionCommitsActions.Read, ProjectPermissionCommitsActions.PerformRollback],
|
||||
ProjectPermissionSub.Commits
|
||||
);
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionSshHostActions.Edit,
|
||||
@@ -292,6 +298,11 @@ const buildMemberPermissionRules = () => {
|
||||
ProjectPermissionSub.SecretImports
|
||||
);
|
||||
|
||||
can(
|
||||
[ProjectPermissionCommitsActions.Read, ProjectPermissionCommitsActions.PerformRollback],
|
||||
ProjectPermissionSub.Commits
|
||||
);
|
||||
|
||||
can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretApproval);
|
||||
can([ProjectPermissionSecretRotationActions.Read], ProjectPermissionSub.SecretRotation);
|
||||
|
||||
@@ -479,6 +490,7 @@ const buildViewerPermissionRules = () => {
|
||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates);
|
||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates);
|
||||
can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs);
|
||||
can(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits);
|
||||
|
||||
can(
|
||||
[
|
||||
|
||||
@@ -17,6 +17,11 @@ export enum ProjectPermissionActions {
|
||||
Delete = "delete"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionCommitsActions {
|
||||
Read = "read",
|
||||
PerformRollback = "perform-rollback"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionCertificateActions {
|
||||
Read = "read",
|
||||
Create = "create",
|
||||
@@ -172,6 +177,7 @@ export enum ProjectPermissionSub {
|
||||
SecretRollback = "secret-rollback",
|
||||
SecretApproval = "secret-approval",
|
||||
SecretRotation = "secret-rotation",
|
||||
Commits = "commits",
|
||||
Identity = "identity",
|
||||
CertificateAuthorities = "certificate-authorities",
|
||||
Certificates = "certificates",
|
||||
@@ -325,6 +331,7 @@ export type ProjectPermissionSet =
|
||||
| [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback]
|
||||
| [ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback]
|
||||
| [ProjectPermissionActions.Edit, ProjectPermissionSub.Kms]
|
||||
| [ProjectPermissionCommitsActions, ProjectPermissionSub.Commits]
|
||||
| [ProjectPermissionSecretScanningDataSourceActions, ProjectPermissionSub.SecretScanningDataSources]
|
||||
| [ProjectPermissionSecretScanningFindingActions, ProjectPermissionSub.SecretScanningFindings]
|
||||
| [ProjectPermissionSecretScanningConfigActions, ProjectPermissionSub.SecretScanningConfigs];
|
||||
@@ -676,6 +683,12 @@ const GeneralPermissionSchema = [
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.Commits).describe("The entity this permission pertains to."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCommitsActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
subject: z
|
||||
.literal(ProjectPermissionSub.SecretScanningDataSources)
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { ProjectPermissionCommitsActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { NotFoundError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
|
||||
import { ResourceType, TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service";
|
||||
import {
|
||||
isFolderCommitChange,
|
||||
isSecretCommitChange
|
||||
} from "@app/services/folder-commit-changes/folder-commit-changes-dal";
|
||||
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
|
||||
import { TSecretServiceFactory } from "@app/services/secret/secret-service";
|
||||
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||
import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service";
|
||||
|
||||
import { TPermissionServiceFactory } from "../permission/permission-service";
|
||||
|
||||
type TPitServiceFactoryDep = {
|
||||
folderCommitService: TFolderCommitServiceFactory;
|
||||
secretService: Pick<TSecretServiceFactory, "getSecretVersionsV2ByIds" | "getChangeVersions">;
|
||||
folderService: Pick<TSecretFolderServiceFactory, "getFolderById" | "getFolderVersions">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
folderDAL: Pick<TSecretFolderDALFactory, "findSecretPathByFolderIds">;
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
|
||||
};
|
||||
|
||||
export type TPitServiceFactory = ReturnType<typeof pitServiceFactory>;
|
||||
|
||||
export const pitServiceFactory = ({
|
||||
folderCommitService,
|
||||
secretService,
|
||||
folderService,
|
||||
permissionService,
|
||||
folderDAL,
|
||||
projectEnvDAL
|
||||
}: TPitServiceFactoryDep) => {
|
||||
const getCommitsCount = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId,
|
||||
environment,
|
||||
path
|
||||
}: {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorOrgId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
projectId: string;
|
||||
environment: string;
|
||||
path: string;
|
||||
}) => {
|
||||
const result = await folderCommitService.getCommitsCount({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId,
|
||||
environment,
|
||||
path
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const getCommitsForFolder = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId,
|
||||
environment,
|
||||
path,
|
||||
offset,
|
||||
limit,
|
||||
search,
|
||||
sort
|
||||
}: {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorOrgId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
projectId: string;
|
||||
environment: string;
|
||||
path: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
search?: string;
|
||||
sort: "asc" | "desc";
|
||||
}) => {
|
||||
const result = await folderCommitService.getCommitsForFolder({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId,
|
||||
environment,
|
||||
path,
|
||||
offset,
|
||||
limit,
|
||||
search,
|
||||
sort
|
||||
});
|
||||
|
||||
return {
|
||||
commits: result.commits.map((commit) => ({
|
||||
...commit,
|
||||
commitId: commit.commitId.toString()
|
||||
})),
|
||||
total: result.total,
|
||||
hasMore: result.hasMore
|
||||
};
|
||||
};
|
||||
|
||||
const getCommitChanges = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId,
|
||||
commitId
|
||||
}: {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorOrgId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
projectId: string;
|
||||
commitId: string;
|
||||
}) => {
|
||||
const changes = await folderCommitService.getCommitChanges({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId,
|
||||
commitId
|
||||
});
|
||||
|
||||
const [folderWithPath] = await folderDAL.findSecretPathByFolderIds(projectId, [changes.folderId]);
|
||||
|
||||
for (const change of changes.changes) {
|
||||
if (isSecretCommitChange(change)) {
|
||||
change.versions = await secretService.getChangeVersions(
|
||||
{
|
||||
secretVersion: change.secretVersion,
|
||||
secretId: change.secretId,
|
||||
id: change.id,
|
||||
isUpdate: change.isUpdate,
|
||||
changeType: change.changeType
|
||||
},
|
||||
(Number.parseInt(change.secretVersion, 10) - 1).toString(),
|
||||
actorId,
|
||||
actor,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
changes.envId,
|
||||
projectId,
|
||||
folderWithPath?.path || ""
|
||||
);
|
||||
} else if (isFolderCommitChange(change)) {
|
||||
change.versions = await folderService.getFolderVersions(
|
||||
change,
|
||||
(Number.parseInt(change.folderVersion, 10) - 1).toString(),
|
||||
change.folderChangeId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
changes: {
|
||||
...changes,
|
||||
commitId: changes.commitId.toString()
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const compareCommitChanges = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId,
|
||||
commitId,
|
||||
folderId,
|
||||
environment,
|
||||
deepRollback,
|
||||
secretPath
|
||||
}: {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorOrgId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
projectId: string;
|
||||
commitId: string;
|
||||
folderId: string;
|
||||
environment: string;
|
||||
deepRollback: boolean;
|
||||
secretPath: string;
|
||||
}) => {
|
||||
const latestCommit = await folderCommitService.getLatestCommit({
|
||||
folderId,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId
|
||||
});
|
||||
|
||||
const targetCommit = await folderCommitService.getCommitById({
|
||||
commitId,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId
|
||||
});
|
||||
|
||||
const env = await projectEnvDAL.findOne({
|
||||
projectId,
|
||||
slug: environment
|
||||
});
|
||||
|
||||
if (!latestCommit) {
|
||||
throw new NotFoundError({ message: "Latest commit not found" });
|
||||
}
|
||||
|
||||
let diffs;
|
||||
if (deepRollback) {
|
||||
diffs = await folderCommitService.deepCompareFolder({
|
||||
targetCommitId: targetCommit.id,
|
||||
envId: env.id,
|
||||
projectId
|
||||
});
|
||||
} else {
|
||||
const folderData = await folderService.getFolderById({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
id: folderId
|
||||
});
|
||||
|
||||
diffs = [
|
||||
{
|
||||
folderId: folderData.id,
|
||||
folderName: folderData.name,
|
||||
folderPath: secretPath,
|
||||
changes: await folderCommitService.compareFolderStates({
|
||||
targetCommitId: commitId,
|
||||
currentCommitId: latestCommit.id
|
||||
})
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
for (const diff of diffs) {
|
||||
for (const change of diff.changes) {
|
||||
// Use discriminated union type checking
|
||||
if (change.type === ResourceType.SECRET) {
|
||||
// TypeScript now knows this is a SecretChange
|
||||
if (change.secretKey && change.secretVersion && change.secretId) {
|
||||
change.versions = await secretService.getChangeVersions(
|
||||
{
|
||||
secretVersion: change.secretVersion,
|
||||
secretId: change.secretId,
|
||||
id: change.id,
|
||||
isUpdate: change.isUpdate,
|
||||
changeType: change.changeType
|
||||
},
|
||||
change.fromVersion || "1",
|
||||
actorId,
|
||||
actor,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
env.id,
|
||||
projectId,
|
||||
diff.folderPath || ""
|
||||
);
|
||||
}
|
||||
} else if (change.type === ResourceType.FOLDER) {
|
||||
// TypeScript now knows this is a FolderChange
|
||||
if (change.folderVersion) {
|
||||
change.versions = await folderService.getFolderVersions(change, change.fromVersion || "1", change.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diffs;
|
||||
};
|
||||
|
||||
const rollbackToCommit = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId,
|
||||
commitId,
|
||||
folderId,
|
||||
deepRollback,
|
||||
message,
|
||||
environment
|
||||
}: {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorOrgId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
projectId: string;
|
||||
commitId: string;
|
||||
folderId: string;
|
||||
deepRollback: boolean;
|
||||
message?: string;
|
||||
environment: string;
|
||||
}) => {
|
||||
const { permission: userPermission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.SecretManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(userPermission).throwUnlessCan(
|
||||
ProjectPermissionCommitsActions.PerformRollback,
|
||||
ProjectPermissionSub.Commits
|
||||
);
|
||||
|
||||
const latestCommit = await folderCommitService.getLatestCommit({
|
||||
folderId,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId
|
||||
});
|
||||
|
||||
if (!latestCommit) {
|
||||
throw new NotFoundError({ message: "Latest commit not found" });
|
||||
}
|
||||
|
||||
logger.info(`PIT - Attempting to rollback folder ${folderId} from commit ${latestCommit.id} to commit ${commitId}`);
|
||||
|
||||
const targetCommit = await folderCommitService.getCommitById({
|
||||
commitId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
projectId
|
||||
});
|
||||
|
||||
const env = await projectEnvDAL.findOne({
|
||||
projectId,
|
||||
slug: environment
|
||||
});
|
||||
|
||||
if (!targetCommit || targetCommit.folderId !== folderId || targetCommit.envId !== env.id) {
|
||||
throw new NotFoundError({ message: "Target commit not found" });
|
||||
}
|
||||
|
||||
if (!latestCommit || latestCommit.envId !== env.id) {
|
||||
throw new NotFoundError({ message: "Latest commit not found" });
|
||||
}
|
||||
|
||||
if (deepRollback) {
|
||||
await folderCommitService.deepRollbackFolder(commitId, env.id, actorId, actor, projectId, message);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const diff = await folderCommitService.compareFolderStates({
|
||||
currentCommitId: latestCommit.id,
|
||||
targetCommitId: commitId
|
||||
});
|
||||
|
||||
const response = await folderCommitService.applyFolderStateDifferences({
|
||||
differences: diff,
|
||||
actorInfo: {
|
||||
actorType: actor,
|
||||
actorId,
|
||||
message: message || "Rollback to previous commit"
|
||||
},
|
||||
folderId,
|
||||
projectId,
|
||||
reconstructNewFolders: deepRollback
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
secretChangesCount: response.secretChangesCount,
|
||||
folderChangesCount: response.folderChangesCount,
|
||||
totalChanges: response.totalChanges
|
||||
};
|
||||
};
|
||||
|
||||
const revertCommit = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId,
|
||||
commitId
|
||||
}: {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorOrgId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
projectId: string;
|
||||
commitId: string;
|
||||
}) => {
|
||||
const response = await folderCommitService.revertCommitChanges({
|
||||
commitId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
projectId
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const getFolderStateAtCommit = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId,
|
||||
commitId
|
||||
}: {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorOrgId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
projectId: string;
|
||||
commitId: string;
|
||||
}) => {
|
||||
const commit = await folderCommitService.getCommitById({
|
||||
commitId,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
projectId
|
||||
});
|
||||
|
||||
if (!commit) {
|
||||
throw new NotFoundError({ message: `Commit with ID ${commitId} not found` });
|
||||
}
|
||||
|
||||
const response = await folderCommitService.reconstructFolderState(commitId);
|
||||
|
||||
return response.map((item) => {
|
||||
if (item.type === ResourceType.SECRET) {
|
||||
return {
|
||||
...item,
|
||||
secretVersion: Number(item.secretVersion)
|
||||
};
|
||||
}
|
||||
|
||||
if (item.type === ResourceType.FOLDER) {
|
||||
return {
|
||||
...item,
|
||||
folderVersion: Number(item.folderVersion)
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
getCommitsCount,
|
||||
getCommitsForFolder,
|
||||
getCommitChanges,
|
||||
compareCommitChanges,
|
||||
rollbackToCommit,
|
||||
revertCommit,
|
||||
getFolderStateAtCommit
|
||||
};
|
||||
};
|
||||
+42
-15
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
|
||||
import {
|
||||
@@ -20,6 +21,7 @@ import { EnforcementLevel } from "@app/lib/types";
|
||||
import { triggerWorkflowIntegrationNotification } from "@app/lib/workflow-integrations/trigger-notification";
|
||||
import { TriggerFeature } from "@app/lib/workflow-integrations/types";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service";
|
||||
@@ -130,6 +132,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
projectMicrosoftTeamsConfigDAL: Pick<TProjectMicrosoftTeamsConfigDALFactory, "getIntegrationDetailsByProject">;
|
||||
microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "sendNotification">;
|
||||
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
|
||||
};
|
||||
|
||||
export type TSecretApprovalRequestServiceFactory = ReturnType<typeof secretApprovalRequestServiceFactory>;
|
||||
@@ -161,7 +164,8 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
projectSlackConfigDAL,
|
||||
resourceMetadataDAL,
|
||||
projectMicrosoftTeamsConfigDAL,
|
||||
microsoftTeamsService
|
||||
microsoftTeamsService,
|
||||
folderCommitService
|
||||
}: TSecretApprovalRequestServiceFactoryDep) => {
|
||||
const requestCount = async ({ projectId, actor, actorId, actorOrgId, actorAuthMethod }: TApprovalRequestCountDTO) => {
|
||||
if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" });
|
||||
@@ -243,7 +247,7 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId);
|
||||
|
||||
const { policy } = secretApprovalRequest;
|
||||
const { hasRole } = await permissionService.getProjectPermission({
|
||||
const { hasRole, permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
@@ -259,6 +263,12 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
throw new ForbiddenRequestError({ message: "User has insufficient privileges" });
|
||||
}
|
||||
|
||||
const hasSecretReadAccess = permission.can(
|
||||
ProjectPermissionSecretActions.DescribeAndReadValue,
|
||||
ProjectPermissionSub.Secrets
|
||||
);
|
||||
const hiddenSecretValue = "******";
|
||||
|
||||
let secrets;
|
||||
if (shouldUseSecretV2Bridge) {
|
||||
const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
@@ -275,9 +285,9 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
version: el.version,
|
||||
secretMetadata: el.secretMetadata as ResourceMetadataDTO,
|
||||
isRotatedSecret: el.secret?.isRotatedSecret ?? false,
|
||||
secretValue:
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
el.secret && el.secret.isRotatedSecret
|
||||
secretValue: !hasSecretReadAccess
|
||||
? hiddenSecretValue
|
||||
: el.secret && el.secret.isRotatedSecret
|
||||
? undefined
|
||||
: el.encryptedValue
|
||||
? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString()
|
||||
@@ -290,9 +300,11 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
secretKey: el.secret.key,
|
||||
id: el.secret.id,
|
||||
version: el.secret.version,
|
||||
secretValue: el.secret.encryptedValue
|
||||
? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedValue }).toString()
|
||||
: "",
|
||||
secretValue: !hasSecretReadAccess
|
||||
? hiddenSecretValue
|
||||
: el.secret.encryptedValue
|
||||
? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedValue }).toString()
|
||||
: "",
|
||||
secretComment: el.secret.encryptedComment
|
||||
? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedComment }).toString()
|
||||
: ""
|
||||
@@ -303,9 +315,11 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
secretKey: el.secretVersion.key,
|
||||
id: el.secretVersion.id,
|
||||
version: el.secretVersion.version,
|
||||
secretValue: el.secretVersion.encryptedValue
|
||||
? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedValue }).toString()
|
||||
: "",
|
||||
secretValue: !hasSecretReadAccess
|
||||
? hiddenSecretValue
|
||||
: el.secretVersion.encryptedValue
|
||||
? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedValue }).toString()
|
||||
: "",
|
||||
secretComment: el.secretVersion.encryptedComment
|
||||
? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedComment }).toString()
|
||||
: "",
|
||||
@@ -597,6 +611,10 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
? await fnSecretV2BridgeBulkInsert({
|
||||
tx,
|
||||
folderId,
|
||||
actor: {
|
||||
actorId,
|
||||
type: actor
|
||||
},
|
||||
orgId: actorOrgId,
|
||||
inputSecrets: secretCreationCommits.map((el) => ({
|
||||
tagIds: el?.tags.map(({ id }) => id),
|
||||
@@ -619,13 +637,18 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
secretDAL: secretV2BridgeDAL,
|
||||
secretVersionDAL: secretVersionV2BridgeDAL,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL: secretVersionTagV2BridgeDAL
|
||||
secretVersionTagDAL: secretVersionTagV2BridgeDAL,
|
||||
folderCommitService
|
||||
})
|
||||
: [];
|
||||
const updatedSecrets = secretUpdationCommits.length
|
||||
? await fnSecretV2BridgeBulkUpdate({
|
||||
folderId,
|
||||
orgId: actorOrgId,
|
||||
actor: {
|
||||
actorId,
|
||||
type: actor
|
||||
},
|
||||
tx,
|
||||
inputSecrets: secretUpdationCommits.map((el) => {
|
||||
const encryptedValue =
|
||||
@@ -659,7 +682,8 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
secretVersionDAL: secretVersionV2BridgeDAL,
|
||||
secretTagDAL,
|
||||
secretVersionTagDAL: secretVersionTagV2BridgeDAL,
|
||||
resourceMetadataDAL
|
||||
resourceMetadataDAL,
|
||||
folderCommitService
|
||||
})
|
||||
: [];
|
||||
const deletedSecret = secretDeletionCommits.length
|
||||
@@ -667,10 +691,13 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
projectId,
|
||||
folderId,
|
||||
tx,
|
||||
actorId: "",
|
||||
actorId,
|
||||
actorType: actor,
|
||||
secretDAL: secretV2BridgeDAL,
|
||||
secretQueueService,
|
||||
inputSecrets: secretDeletionCommits.map(({ key }) => ({ secretKey: key, type: SecretType.Shared }))
|
||||
inputSecrets: secretDeletionCommits.map(({ key }) => ({ secretKey: key, type: SecretType.Shared })),
|
||||
folderCommitService,
|
||||
secretVersionDAL: secretVersionV2BridgeDAL
|
||||
})
|
||||
: [];
|
||||
const updatedSecretApproval = await secretApprovalRequestDAL.updateById(
|
||||
|
||||
@@ -10,6 +10,7 @@ import { logger } from "@app/lib/logger";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
||||
@@ -87,6 +88,7 @@ type TSecretReplicationServiceFactoryDep = {
|
||||
|
||||
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
|
||||
};
|
||||
|
||||
export type TSecretReplicationServiceFactory = ReturnType<typeof secretReplicationServiceFactory>;
|
||||
@@ -132,6 +134,7 @@ export const secretReplicationServiceFactory = ({
|
||||
secretVersionV2BridgeDAL,
|
||||
secretV2BridgeDAL,
|
||||
kmsService,
|
||||
folderCommitService,
|
||||
resourceMetadataDAL
|
||||
}: TSecretReplicationServiceFactoryDep) => {
|
||||
const $getReplicatedSecrets = (
|
||||
@@ -419,7 +422,7 @@ export const secretReplicationServiceFactory = ({
|
||||
return {
|
||||
op: operation,
|
||||
requestId: approvalRequestDoc.id,
|
||||
metadata: doc.metadata,
|
||||
metadata: doc.metadata ? JSON.stringify(doc.metadata) : [],
|
||||
secretMetadata: JSON.stringify(doc.secretMetadata),
|
||||
key: doc.key,
|
||||
encryptedValue: doc.encryptedValue,
|
||||
@@ -446,11 +449,12 @@ export const secretReplicationServiceFactory = ({
|
||||
tx,
|
||||
secretTagDAL,
|
||||
resourceMetadataDAL,
|
||||
folderCommitService,
|
||||
secretVersionTagDAL: secretVersionV2TagBridgeDAL,
|
||||
inputSecrets: locallyCreatedSecrets.map((doc) => {
|
||||
return {
|
||||
type: doc.type,
|
||||
metadata: doc.metadata,
|
||||
metadata: doc.metadata ? JSON.stringify(doc.metadata) : [],
|
||||
key: doc.key,
|
||||
encryptedValue: doc.encryptedValue,
|
||||
encryptedComment: doc.encryptedComment,
|
||||
@@ -466,6 +470,7 @@ export const secretReplicationServiceFactory = ({
|
||||
orgId,
|
||||
folderId: destinationReplicationFolderId,
|
||||
secretVersionDAL: secretVersionV2BridgeDAL,
|
||||
folderCommitService,
|
||||
secretDAL: secretV2BridgeDAL,
|
||||
tx,
|
||||
resourceMetadataDAL,
|
||||
@@ -479,7 +484,7 @@ export const secretReplicationServiceFactory = ({
|
||||
},
|
||||
data: {
|
||||
type: doc.type,
|
||||
metadata: doc.metadata,
|
||||
metadata: doc.metadata ? JSON.stringify(doc.metadata) : [],
|
||||
key: doc.key,
|
||||
encryptedValue: doc.encryptedValue as Buffer,
|
||||
encryptedComment: doc.encryptedComment,
|
||||
|
||||
+46
@@ -101,10 +101,56 @@ export const azureClientSecretRotationFactory: TRotationFactory<
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a credential with the given keyId exists.
|
||||
*/
|
||||
const credentialExists = async (keyId: string): Promise<boolean> => {
|
||||
const accessToken = await getAzureConnectionAccessToken(connection.id, appConnectionDAL, kmsService);
|
||||
const endpoint = `${GRAPH_API_BASE}/applications/${objectId}/passwordCredentials`;
|
||||
|
||||
try {
|
||||
const { data } = await request.get<{ value: Array<{ keyId: string }> }>(endpoint, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
return data.value?.some((credential) => credential.keyId === keyId) || false;
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AxiosError) {
|
||||
let message;
|
||||
if (
|
||||
error.response?.data &&
|
||||
typeof error.response.data === "object" &&
|
||||
"error" in error.response.data &&
|
||||
typeof (error.response.data as AzureErrorResponse).error.message === "string"
|
||||
) {
|
||||
message = (error.response.data as AzureErrorResponse).error.message;
|
||||
}
|
||||
throw new BadRequestError({
|
||||
message: `Failed to check credential existence for app ${objectId}: ${
|
||||
message || error.message || "Unknown error"
|
||||
}`
|
||||
});
|
||||
}
|
||||
throw new BadRequestError({
|
||||
message: "Unable to validate connection: verify credentials"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Revokes a client secret from the Azure app using its keyId.
|
||||
* First checks if the credential exists before attempting revocation.
|
||||
*/
|
||||
const revokeCredential = async (keyId: string) => {
|
||||
// Check if credential exists before attempting revocation
|
||||
const exists = await credentialExists(keyId);
|
||||
if (!exists) {
|
||||
return; // Credential doesn't exist, nothing to revoke
|
||||
}
|
||||
|
||||
const accessToken = await getAzureConnectionAccessToken(connection.id, appConnectionDAL, kmsService);
|
||||
const endpoint = `${GRAPH_API_BASE}/applications/${objectId}/removePassword`;
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ import { TAppConnectionDALFactory } from "@app/services/app-connection/app-conne
|
||||
import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns";
|
||||
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
||||
@@ -98,7 +99,7 @@ export type TSecretRotationV2ServiceFactoryDep = {
|
||||
TSecretV2BridgeDALFactory,
|
||||
"bulkUpdate" | "insertMany" | "deleteMany" | "upsertSecretReferences" | "find" | "invalidateSecretCacheByProjectId"
|
||||
>;
|
||||
secretVersionV2BridgeDAL: Pick<TSecretVersionV2DALFactory, "insertMany">;
|
||||
secretVersionV2BridgeDAL: Pick<TSecretVersionV2DALFactory, "insertMany" | "findLatestVersionMany">;
|
||||
secretVersionTagV2BridgeDAL: Pick<TSecretVersionV2TagDALFactory, "insertMany">;
|
||||
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
|
||||
secretTagDAL: Pick<TSecretTagDALFactory, "saveTagsToSecretV2" | "deleteTagsToSecretV2" | "find">;
|
||||
@@ -106,6 +107,7 @@ export type TSecretRotationV2ServiceFactoryDep = {
|
||||
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
|
||||
queueService: Pick<TQueueServiceFactory, "queuePg">;
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">;
|
||||
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
|
||||
};
|
||||
|
||||
export type TSecretRotationV2ServiceFactory = ReturnType<typeof secretRotationV2ServiceFactory>;
|
||||
@@ -146,6 +148,7 @@ export const secretRotationV2ServiceFactory = ({
|
||||
snapshotService,
|
||||
keyStore,
|
||||
queueService,
|
||||
folderCommitService,
|
||||
appConnectionDAL
|
||||
}: TSecretRotationV2ServiceFactoryDep) => {
|
||||
const $queueSendSecretRotationStatusNotification = async (secretRotation: TSecretRotationV2Raw) => {
|
||||
@@ -539,7 +542,12 @@ export const secretRotationV2ServiceFactory = ({
|
||||
secretVersionDAL: secretVersionV2BridgeDAL,
|
||||
secretVersionTagDAL: secretVersionTagV2BridgeDAL,
|
||||
secretTagDAL,
|
||||
resourceMetadataDAL
|
||||
folderCommitService,
|
||||
resourceMetadataDAL,
|
||||
actor: {
|
||||
type: actor.type,
|
||||
actorId: actor.id
|
||||
}
|
||||
});
|
||||
|
||||
await secretRotationV2DAL.insertSecretMappings(
|
||||
@@ -675,7 +683,12 @@ export const secretRotationV2ServiceFactory = ({
|
||||
secretVersionDAL: secretVersionV2BridgeDAL,
|
||||
secretVersionTagDAL: secretVersionTagV2BridgeDAL,
|
||||
secretTagDAL,
|
||||
resourceMetadataDAL
|
||||
folderCommitService,
|
||||
resourceMetadataDAL,
|
||||
actor: {
|
||||
type: actor.type,
|
||||
actorId: actor.id
|
||||
}
|
||||
});
|
||||
|
||||
secretsMappingUpdated = true;
|
||||
@@ -793,6 +806,9 @@ export const secretRotationV2ServiceFactory = ({
|
||||
projectId,
|
||||
folderId,
|
||||
actorId: actor.id, // not actually used since rotated secrets are shared
|
||||
actorType: actor.type,
|
||||
folderCommitService,
|
||||
secretVersionDAL: secretVersionV2BridgeDAL,
|
||||
tx
|
||||
});
|
||||
}
|
||||
@@ -936,6 +952,10 @@ export const secretRotationV2ServiceFactory = ({
|
||||
secretDAL: secretV2BridgeDAL,
|
||||
secretVersionDAL: secretVersionV2BridgeDAL,
|
||||
secretVersionTagDAL: secretVersionTagV2BridgeDAL,
|
||||
folderCommitService,
|
||||
actor: {
|
||||
type: ActorType.PLATFORM
|
||||
},
|
||||
secretTagDAL,
|
||||
resourceMetadataDAL
|
||||
});
|
||||
|
||||
+20
-1
@@ -14,6 +14,7 @@ import { logger } from "@app/lib/logger";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { CommitType, TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
||||
@@ -53,6 +54,7 @@ type TSecretRotationQueueFactoryDep = {
|
||||
secretVersionV2BridgeDAL: Pick<TSecretVersionV2DALFactory, "insertMany" | "findLatestVersionMany">;
|
||||
telemetryService: Pick<TTelemetryServiceFactory, "sendPostHogEvents">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
|
||||
};
|
||||
|
||||
// These error should stop the repeatable job and ask user to reconfigure rotation
|
||||
@@ -77,6 +79,7 @@ export const secretRotationQueueFactory = ({
|
||||
telemetryService,
|
||||
secretV2BridgeDAL,
|
||||
secretVersionV2BridgeDAL,
|
||||
folderCommitService,
|
||||
kmsService
|
||||
}: TSecretRotationQueueFactoryDep) => {
|
||||
const addToQueue = async (rotationId: string, interval: number) => {
|
||||
@@ -330,7 +333,7 @@ export const secretRotationQueueFactory = ({
|
||||
})),
|
||||
tx
|
||||
);
|
||||
await secretVersionV2BridgeDAL.insertMany(
|
||||
const secretVersions = await secretVersionV2BridgeDAL.insertMany(
|
||||
updatedSecrets.map(({ id, updatedAt, createdAt, ...el }) => ({
|
||||
...el,
|
||||
actorType: ActorType.PLATFORM,
|
||||
@@ -338,6 +341,22 @@ export const secretRotationQueueFactory = ({
|
||||
})),
|
||||
tx
|
||||
);
|
||||
|
||||
await folderCommitService.createCommit(
|
||||
{
|
||||
actor: {
|
||||
type: ActorType.PLATFORM
|
||||
},
|
||||
message: "Changed by Secret rotation",
|
||||
folderId: secretVersions[0].folderId,
|
||||
changes: secretVersions.map((sv) => ({
|
||||
type: CommitType.ADD,
|
||||
isUpdate: true,
|
||||
secretVersionId: sv.id
|
||||
}))
|
||||
},
|
||||
tx
|
||||
);
|
||||
});
|
||||
|
||||
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(secretRotation.projectId);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { InternalServerError, NotFoundError } from "@app/lib/errors";
|
||||
import { groupBy } from "@app/lib/fn";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { CommitType, TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
||||
@@ -51,8 +52,8 @@ type TSecretSnapshotServiceFactoryDep = {
|
||||
snapshotSecretV2BridgeDAL: TSnapshotSecretV2DALFactory;
|
||||
snapshotFolderDAL: TSnapshotFolderDALFactory;
|
||||
secretVersionDAL: Pick<TSecretVersionDALFactory, "insertMany" | "findLatestVersionByFolderId">;
|
||||
secretVersionV2BridgeDAL: Pick<TSecretVersionV2DALFactory, "insertMany" | "findLatestVersionByFolderId">;
|
||||
folderVersionDAL: Pick<TSecretFolderVersionDALFactory, "findLatestVersionByFolderId" | "insertMany">;
|
||||
secretVersionV2BridgeDAL: Pick<TSecretVersionV2DALFactory, "insertMany" | "findLatestVersionByFolderId" | "findOne">;
|
||||
folderVersionDAL: Pick<TSecretFolderVersionDALFactory, "findLatestVersionByFolderId" | "insertMany" | "findOne">;
|
||||
secretDAL: Pick<TSecretDALFactory, "delete" | "insertMany">;
|
||||
secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "delete" | "insertMany">;
|
||||
secretTagDAL: Pick<TSecretTagDALFactory, "saveTagsToSecret" | "saveTagsToSecretV2">;
|
||||
@@ -63,6 +64,7 @@ type TSecretSnapshotServiceFactoryDep = {
|
||||
licenseService: Pick<TLicenseServiceFactory, "isValidLicense">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
|
||||
};
|
||||
|
||||
export type TSecretSnapshotServiceFactory = ReturnType<typeof secretSnapshotServiceFactory>;
|
||||
@@ -84,7 +86,8 @@ export const secretSnapshotServiceFactory = ({
|
||||
snapshotSecretV2BridgeDAL,
|
||||
secretVersionV2TagBridgeDAL,
|
||||
kmsService,
|
||||
projectBotService
|
||||
projectBotService,
|
||||
folderCommitService
|
||||
}: TSecretSnapshotServiceFactoryDep) => {
|
||||
const projectSecretSnapshotCount = async ({
|
||||
environment,
|
||||
@@ -403,6 +406,18 @@ export const secretSnapshotServiceFactory = ({
|
||||
.filter((el) => el.isRotatedSecret)
|
||||
.map((el) => el.secretId);
|
||||
|
||||
const deletedSecretsChanges = new Map(); // secretId -> version info
|
||||
const deletedFoldersChanges = new Map(); // folderId -> version info
|
||||
const addedSecretsChanges = new Map(); // secretId -> version info
|
||||
const addedFoldersChanges = new Map(); // folderId -> version info
|
||||
const commitChanges: {
|
||||
type: string;
|
||||
secretVersionId?: string;
|
||||
folderVersionId?: string;
|
||||
isUpdate?: boolean;
|
||||
folderId?: string;
|
||||
}[] = [];
|
||||
|
||||
// this will remove all secrets in current folder except rotated secrets which we ignore
|
||||
const deletedTopLevelSecs = await secretV2BridgeDAL.delete(
|
||||
{
|
||||
@@ -424,7 +439,35 @@ export const secretSnapshotServiceFactory = ({
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
deletedTopLevelSecs.map(async (sec) => {
|
||||
const version = await secretVersionV2BridgeDAL.findOne({ secretId: sec.id, version: sec.version }, tx);
|
||||
deletedSecretsChanges.set(sec.id, {
|
||||
id: sec.id,
|
||||
version: sec.version,
|
||||
// Store the version ID if available from the snapshot
|
||||
versionId: version?.id
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const deletedTopLevelSecsGroupById = groupBy(deletedTopLevelSecs, (item) => item.id);
|
||||
|
||||
const deletedFoldersData = await folderDAL.delete({ parentId: snapshot.folderId, isReserved: false }, tx);
|
||||
|
||||
await Promise.all(
|
||||
deletedFoldersData.map(async (folder) => {
|
||||
const version = await folderVersionDAL.findOne({ folderId: folder.id, version: folder.version }, tx);
|
||||
deletedFoldersChanges.set(folder.id, {
|
||||
id: folder.id,
|
||||
version: folder.version,
|
||||
// Store the version ID if available
|
||||
versionId: version?.id
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// this will remove all secrets and folders on child
|
||||
// due to sql foreign key and link list connection removing the folders removes everything below too
|
||||
const deletedFolders = await folderDAL.delete({ parentId: snapshot.folderId, isReserved: false }, tx);
|
||||
@@ -489,14 +532,21 @@ export const secretSnapshotServiceFactory = ({
|
||||
});
|
||||
await secretTagDAL.saveTagsToSecretV2(secretTagsToBeInsert, tx);
|
||||
const folderVersions = await folderVersionDAL.insertMany(
|
||||
folders.map(({ version, name, id, envId }) => ({
|
||||
folders.map(({ version, name, id, envId, description }) => ({
|
||||
name,
|
||||
version,
|
||||
folderId: id,
|
||||
envId
|
||||
envId,
|
||||
description
|
||||
})),
|
||||
tx
|
||||
);
|
||||
|
||||
// Track added folders
|
||||
folderVersions.forEach((fv) => {
|
||||
addedFoldersChanges.set(fv.folderId, fv);
|
||||
});
|
||||
|
||||
const userActorId = actor === ActorType.USER ? actorId : undefined;
|
||||
const identityActorId = actor !== ActorType.USER ? actorId : undefined;
|
||||
const actorType = actor || ActorType.PLATFORM;
|
||||
@@ -511,6 +561,11 @@ export const secretSnapshotServiceFactory = ({
|
||||
})),
|
||||
tx
|
||||
);
|
||||
|
||||
secretVersions.forEach((sv) => {
|
||||
addedSecretsChanges.set(sv.secretId, sv);
|
||||
});
|
||||
|
||||
await secretVersionV2TagBridgeDAL.insertMany(
|
||||
secretVersions.flatMap(({ secretId, id }) =>
|
||||
secretVerTagToBeInsert?.[secretId]?.length
|
||||
@@ -522,6 +577,70 @@ export const secretSnapshotServiceFactory = ({
|
||||
),
|
||||
tx
|
||||
);
|
||||
|
||||
// Compute commit changes
|
||||
// Handle secrets
|
||||
deletedSecretsChanges.forEach((deletedInfo, secretId) => {
|
||||
const addedSecret = addedSecretsChanges.get(secretId);
|
||||
if (addedSecret) {
|
||||
// Secret was deleted and re-added - this is an update only if versions are different
|
||||
if (deletedInfo.versionId !== addedSecret.id) {
|
||||
commitChanges.push({
|
||||
type: CommitType.ADD, // In the commit system, updates are tracked as "add" with isUpdate=true
|
||||
secretVersionId: addedSecret.id,
|
||||
isUpdate: true
|
||||
});
|
||||
}
|
||||
// Remove from addedSecrets since we've handled it
|
||||
addedSecretsChanges.delete(secretId);
|
||||
} else if (deletedInfo.versionId) {
|
||||
// Secret was only deleted
|
||||
commitChanges.push({
|
||||
type: CommitType.DELETE,
|
||||
secretVersionId: deletedInfo.versionId
|
||||
});
|
||||
}
|
||||
});
|
||||
// Add remaining new secrets (not updates)
|
||||
addedSecretsChanges.forEach((addedSecret) => {
|
||||
commitChanges.push({
|
||||
type: CommitType.ADD,
|
||||
secretVersionId: addedSecret.id
|
||||
});
|
||||
});
|
||||
|
||||
// Handle folders
|
||||
deletedFoldersChanges.forEach((deletedInfo, folderId) => {
|
||||
const addedFolder = addedFoldersChanges.get(folderId);
|
||||
if (addedFolder) {
|
||||
// Folder was deleted and re-added - this is an update only if versions are different
|
||||
if (deletedInfo.versionId !== addedFolder.id) {
|
||||
commitChanges.push({
|
||||
type: CommitType.ADD,
|
||||
folderVersionId: addedFolder.id,
|
||||
isUpdate: true
|
||||
});
|
||||
}
|
||||
// Remove from addedFolders since we've handled it
|
||||
addedFoldersChanges.delete(folderId);
|
||||
} else if (deletedInfo.versionId) {
|
||||
// Folder was only deleted
|
||||
commitChanges.push({
|
||||
type: CommitType.DELETE,
|
||||
folderVersionId: deletedInfo.versionId,
|
||||
folderId: deletedInfo.id
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add remaining new folders (not updates)
|
||||
addedFoldersChanges.forEach((addedFolder) => {
|
||||
commitChanges.push({
|
||||
type: CommitType.ADD,
|
||||
folderVersionId: addedFolder.id
|
||||
});
|
||||
});
|
||||
|
||||
const newSnapshot = await snapshotDAL.create(
|
||||
{
|
||||
folderId: snapshot.folderId,
|
||||
@@ -550,6 +669,22 @@ export const secretSnapshotServiceFactory = ({
|
||||
})),
|
||||
tx
|
||||
);
|
||||
if (commitChanges.length > 0) {
|
||||
await folderCommitService.createCommit(
|
||||
{
|
||||
actor: {
|
||||
type: actorType,
|
||||
metadata: {
|
||||
id: userActorId || identityActorId
|
||||
}
|
||||
},
|
||||
message: "Rollback to snapshot",
|
||||
folderId: snapshot.folderId,
|
||||
changes: commitChanges
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
return { ...newSnapshot, snapshotSecrets, snapshotFolders };
|
||||
});
|
||||
@@ -609,11 +744,12 @@ export const secretSnapshotServiceFactory = ({
|
||||
});
|
||||
await secretTagDAL.saveTagsToSecret(secretTagsToBeInsert, tx);
|
||||
const folderVersions = await folderVersionDAL.insertMany(
|
||||
folders.map(({ version, name, id, envId }) => ({
|
||||
folders.map(({ version, name, id, envId, description }) => ({
|
||||
name,
|
||||
version,
|
||||
folderId: id,
|
||||
envId
|
||||
envId,
|
||||
description
|
||||
})),
|
||||
tx
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user