Merge branch 'main' into fix-approval-policy-org-check

This commit is contained in:
x032205
2025-10-03 00:21:40 -04:00
1067 changed files with 37661 additions and 8260 deletions
@@ -0,0 +1,342 @@
import { packRules } from "@casl/ability/extra";
import { z } from "zod";
import { ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import {
backfillPermissionV1SchemaToV2Schema,
ProjectPermissionV1Schema
} from "@app/ee/services/permission/project-permission";
import { PROJECT_ROLE } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { SanitizedRoleSchemaV1 } from "@app/server/routes/sanitizedSchemas";
import { AuthMode } from "@app/services/auth/auth-type";
import { ProjectRoleServiceIdentifierType } from "@app/services/project-role/project-role-types";
export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/:projectSlug/roles",
config: {
rateLimit: writeLimit
},
schema: {
description: "Create a project role",
security: [
{
bearerAuth: []
}
],
params: z.object({
projectSlug: z.string().trim().describe(PROJECT_ROLE.CREATE.projectSlug)
}),
body: z.object({
slug: slugSchema({ max: 64 })
.refine(
(val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole),
"Please choose a different slug, the slug you have entered is reserved"
)
.describe(PROJECT_ROLE.CREATE.slug),
name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name),
description: z.string().trim().nullish().describe(PROJECT_ROLE.CREATE.description),
permissions: ProjectPermissionV1Schema.array().describe(PROJECT_ROLE.CREATE.permissions)
}),
response: {
200: z.object({
role: SanitizedRoleSchemaV1
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const stringifiedPermissions = JSON.stringify(
packRules(backfillPermissionV1SchemaToV2Schema(req.body.permissions, true))
);
const role = await server.services.projectRole.createRole({
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actor: req.permission.type,
filter: {
type: ProjectRoleServiceIdentifierType.SLUG,
projectSlug: req.params.projectSlug
},
data: {
...req.body,
permissions: stringifiedPermissions
}
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: role.projectId,
event: {
type: EventType.CREATE_PROJECT_ROLE,
metadata: {
roleId: role.id,
slug: req.body.slug,
name: req.body.name,
description: req.body.description,
permissions: stringifiedPermissions
}
}
});
return { role };
}
});
server.route({
method: "PATCH",
url: "/:projectSlug/roles/:roleId",
config: {
rateLimit: writeLimit
},
schema: {
description: "Update a project role",
security: [
{
bearerAuth: []
}
],
params: z.object({
projectSlug: z.string().trim().describe(PROJECT_ROLE.UPDATE.projectSlug),
roleId: z.string().trim().describe(PROJECT_ROLE.UPDATE.roleId)
}),
body: z.object({
slug: slugSchema({ max: 64 })
.refine(
(val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole),
"Please choose a different slug, the slug you have entered is reserved"
)
.describe(PROJECT_ROLE.UPDATE.slug)
.optional(),
name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name),
description: z.string().trim().nullish().describe(PROJECT_ROLE.UPDATE.description),
permissions: ProjectPermissionV1Schema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional()
}),
response: {
200: z.object({
role: SanitizedRoleSchemaV1
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const stringifiedPermissions = req.body.permissions
? JSON.stringify(packRules(backfillPermissionV1SchemaToV2Schema(req.body.permissions, true)))
: undefined;
const role = await server.services.projectRole.updateRole({
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actor: req.permission.type,
roleId: req.params.roleId,
data: {
...req.body,
permissions: stringifiedPermissions
}
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: role.projectId,
event: {
type: EventType.UPDATE_PROJECT_ROLE,
metadata: {
roleId: role.id,
slug: req.body.slug,
name: req.body.name,
description: req.body.description,
permissions: stringifiedPermissions
}
}
});
return { role };
}
});
server.route({
method: "DELETE",
url: "/:projectSlug/roles/:roleId",
config: {
rateLimit: writeLimit
},
schema: {
description: "Delete a project role",
security: [
{
bearerAuth: []
}
],
params: z.object({
projectSlug: z.string().trim().describe(PROJECT_ROLE.DELETE.projectSlug),
roleId: z.string().trim().describe(PROJECT_ROLE.DELETE.roleId)
}),
response: {
200: z.object({
role: SanitizedRoleSchemaV1
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const role = await server.services.projectRole.deleteRole({
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actor: req.permission.type,
roleId: req.params.roleId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: role.projectId,
event: {
type: EventType.DELETE_PROJECT_ROLE,
metadata: {
roleId: role.id,
slug: role.slug,
name: role.name
}
}
});
return { role };
}
});
server.route({
method: "GET",
url: "/:projectSlug/roles",
config: {
rateLimit: readLimit
},
schema: {
description: "List project role",
security: [
{
bearerAuth: []
}
],
params: z.object({
projectSlug: z.string().trim().describe(PROJECT_ROLE.LIST.projectSlug)
}),
response: {
200: z.object({
roles: ProjectRolesSchema.omit({ permissions: true, version: true }).array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const roles = await server.services.projectRole.listRoles({
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actor: req.permission.type,
filter: {
type: ProjectRoleServiceIdentifierType.SLUG,
projectSlug: req.params.projectSlug
}
});
return { roles };
}
});
server.route({
method: "GET",
url: "/:projectSlug/roles/slug/:slug",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
projectSlug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.projectSlug),
slug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.roleSlug)
}),
response: {
200: z.object({
role: SanitizedRoleSchemaV1.omit({ version: true })
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const role = await server.services.projectRole.getRoleBySlug({
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actor: req.permission.type,
filter: {
type: ProjectRoleServiceIdentifierType.SLUG,
projectSlug: req.params.projectSlug
},
roleSlug: req.params.slug
});
return { role };
}
});
server.route({
method: "GET",
url: "/:projectId/permissions",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
projectId: z.string().trim()
}),
response: {
200: z.object({
data: z.object({
membership: z.object({
id: z.string(),
roles: z
.object({
role: z.string()
})
.array()
}),
assumedPrivilegeDetails: z
.object({
actorId: z.string(),
actorType: z.string(),
actorName: z.string(),
actorEmail: z.string().optional()
})
.optional(),
permissions: z.any().array()
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { permissions, membership, assumedPrivilegeDetails } = await server.services.projectRole.getUserPermission(
req.permission.id,
req.params.projectId,
req.permission.authMethod,
req.permission.orgId
);
return {
data: {
permissions,
membership,
assumedPrivilegeDetails
}
};
}
});
};
@@ -0,0 +1,195 @@
import { z } from "zod";
import { AuditLogsSchema, SecretSnapshotsSchema } from "@app/db/schemas";
import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags, AUDIT_LOGS, PROJECTS } from "@app/lib/api-docs";
import { getLastMidnightDateISO, removeTrailingSlash } from "@app/lib/fn";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/:workspaceId/secret-snapshots",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.Projects],
description: "Return project secret snapshots ids",
security: [
{
bearerAuth: []
}
],
params: z.object({
workspaceId: z.string().trim().describe(PROJECTS.GET_SNAPSHOTS.projectId)
}),
querystring: z.object({
environment: z.string().trim().describe(PROJECTS.GET_SNAPSHOTS.environment),
path: z.string().trim().default("/").transform(removeTrailingSlash).describe(PROJECTS.GET_SNAPSHOTS.path),
offset: z.coerce.number().default(0).describe(PROJECTS.GET_SNAPSHOTS.offset),
limit: z.coerce.number().default(20).describe(PROJECTS.GET_SNAPSHOTS.limit)
}),
response: {
200: z.object({
secretSnapshots: SecretSnapshotsSchema.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const secretSnapshots = await server.services.snapshot.listSnapshots({
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
projectId: req.params.workspaceId,
...req.query
});
return { secretSnapshots };
}
});
server.route({
method: "GET",
url: "/:workspaceId/secret-snapshots/count",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim()
}),
querystring: z.object({
environment: z.string().trim(),
path: z.string().trim().default("/").transform(removeTrailingSlash)
}),
response: {
200: z.object({
count: z.number()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const count = await server.services.snapshot.projectSecretSnapshotCount({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.params.workspaceId,
environment: req.query.environment,
path: req.query.path
});
return { count };
}
});
/*
* Daniel: This endpoint is no longer is use.
* We are keeping it for now because it has been exposed in our public api docs for a while, so by removing it we are likely to break users workflows.
*
* Please refer to the new endpoint, GET /api/v1/organization/audit-logs, for the same (and more) functionality.
*/
server.route({
method: "GET",
url: "/:workspaceId/audit-logs",
config: {
rateLimit: readLimit
},
schema: {
description: "Return audit logs",
security: [
{
bearerAuth: []
}
],
params: z.object({
workspaceId: z.string().trim().describe(AUDIT_LOGS.EXPORT.projectId)
}),
querystring: z
.object({
eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType),
userAgentType: z.nativeEnum(UserAgentType).optional().describe(AUDIT_LOGS.EXPORT.userAgentType),
startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate),
endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate),
offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset),
limit: z.coerce.number().max(1000).default(20).describe(AUDIT_LOGS.EXPORT.limit),
actor: z.string().optional().describe(AUDIT_LOGS.EXPORT.actor)
})
.superRefine((el, ctx) => {
if (el.endDate && el.startDate) {
const startDate = new Date(el.startDate);
const endDate = new Date(el.endDate);
const maxAllowedDate = new Date(startDate);
maxAllowedDate.setMonth(maxAllowedDate.getMonth() + 3);
if (endDate < startDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["endDate"],
message: "End date cannot be before start date"
});
}
if (endDate > maxAllowedDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["endDate"],
message: "Dates must be within 3 months"
});
}
}
}),
response: {
200: z.object({
auditLogs: AuditLogsSchema.omit({
eventMetadata: true,
eventType: true,
actor: true,
actorMetadata: true
})
.merge(
z.object({
project: z
.object({
name: z.string(),
slug: z.string()
})
.optional(),
event: z.object({
type: z.string(),
metadata: z.any()
}),
actor: z.object({
type: z.string(),
metadata: z.any()
})
})
)
.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const auditLogs = await server.services.auditLog.listAuditLogs({
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
actor: req.permission.type,
filter: {
...req.query,
projectId: req.params.workspaceId,
endDate: req.query.endDate || new Date().toISOString(),
startDate: req.query.startDate || getLastMidnightDateISO(),
auditLogActorId: req.query.actor,
eventType: req.query.eventType ? [req.query.eventType] : undefined
}
});
return { auditLogs };
}
});
};
@@ -0,0 +1,293 @@
import { nanoid } from "nanoid";
import { z } from "zod";
import { ApproverType, BypasserType } from "@app/ee/services/access-approval-policy/access-approval-policy-types";
import { removeTrailingSlash } from "@app/lib/fn";
import { EnforcementLevel } from "@app/lib/types";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { sapPubSchema } from "@app/server/routes/sanitizedSchemas";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerDeprecatedSecretApprovalPolicyRouter = async (server: FastifyZodProvider) => {
server.route({
url: "/",
method: "POST",
config: {
rateLimit: writeLimit
},
schema: {
body: z
.object({
workspaceId: z.string(),
name: z.string().optional(),
environment: z.string().optional(),
environments: z.string().array().optional(),
secretPath: z
.string()
.min(1, { message: "Secret path cannot be empty" })
.transform((val) => removeTrailingSlash(val)),
approvers: z
.discriminatedUnion("type", [
z.object({ type: z.literal(ApproverType.Group), id: z.string() }),
z.object({
type: z.literal(ApproverType.User),
id: z.string().optional(),
username: z.string().optional()
})
])
.array()
.min(1, { message: "At least one approver should be provided" })
.max(100, "Cannot have more than 100 approvers"),
bypassers: z
.discriminatedUnion("type", [
z.object({ type: z.literal(BypasserType.Group), id: z.string() }),
z.object({
type: z.literal(BypasserType.User),
id: z.string().optional(),
username: z.string().optional()
})
])
.array()
.max(100, "Cannot have more than 100 bypassers")
.optional(),
approvals: z.number().min(1).default(1),
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
allowedSelfApprovals: z.boolean().default(true)
})
.refine((data) => data.environment || data.environments, "At least one environment should be provided"),
response: {
200: z.object({
approval: sapPubSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const approval = await server.services.secretApprovalPolicy.createSecretApprovalPolicy({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.body.workspaceId,
...req.body,
name: req.body.name ?? `${req.body.environment || req.body.environments?.join(",")}-${nanoid(3)}`,
enforcementLevel: req.body.enforcementLevel
});
return { approval };
}
});
server.route({
url: "/:sapId",
method: "PATCH",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
sapId: z.string()
}),
body: z.object({
name: z.string().optional(),
approvers: z
.discriminatedUnion("type", [
z.object({ type: z.literal(ApproverType.Group), id: z.string() }),
z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), username: z.string().optional() })
])
.array()
.min(1, { message: "At least one approver should be provided" })
.max(100, "Cannot have more than 100 approvers"),
bypassers: z
.discriminatedUnion("type", [
z.object({ type: z.literal(BypasserType.Group), id: z.string() }),
z.object({ type: z.literal(BypasserType.User), id: z.string().optional(), username: z.string().optional() })
])
.array()
.max(100, "Cannot have more than 100 bypassers")
.optional(),
approvals: z.number().min(1).default(1),
secretPath: z
.string()
.trim()
.min(1, { message: "Secret path cannot be empty" })
.optional()
.transform((val) => (val ? removeTrailingSlash(val) : undefined)),
enforcementLevel: z.nativeEnum(EnforcementLevel).optional(),
allowedSelfApprovals: z.boolean().default(true),
environments: z.array(z.string()).optional()
}),
response: {
200: z.object({
approval: sapPubSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const approval = await server.services.secretApprovalPolicy.updateSecretApprovalPolicy({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.body,
secretPolicyId: req.params.sapId
});
return { approval };
}
});
server.route({
url: "/:sapId",
method: "DELETE",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
sapId: z.string()
}),
response: {
200: z.object({
approval: sapPubSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const approval = await server.services.secretApprovalPolicy.deleteSecretApprovalPolicy({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
secretPolicyId: req.params.sapId
});
return { approval };
}
});
server.route({
url: "/",
method: "GET",
config: {
rateLimit: readLimit
},
schema: {
querystring: z.object({
workspaceId: z.string().trim()
}),
response: {
200: z.object({
approvals: sapPubSchema
.extend({
approvers: z
.object({
id: z.string().nullable().optional(),
type: z.nativeEnum(ApproverType)
})
.array(),
bypassers: z
.object({
id: z.string().nullable().optional(),
type: z.nativeEnum(BypasserType)
})
.array()
})
.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const approvals = await server.services.secretApprovalPolicy.getSecretApprovalPolicyByProjectId({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.query.workspaceId
});
return { approvals };
}
});
server.route({
url: "/:sapId",
method: "GET",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
sapId: z.string()
}),
response: {
200: z.object({
approval: sapPubSchema.extend({
approvers: z
.object({
id: z.string().nullable().optional(),
type: z.nativeEnum(ApproverType),
username: z.string().nullable().optional()
})
.array(),
bypassers: z
.object({
id: z.string().nullable().optional(),
type: z.nativeEnum(BypasserType),
username: z.string().nullable().optional()
})
.array()
})
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const approval = await server.services.secretApprovalPolicy.getSecretApprovalPolicyById({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.params
});
return { approval };
}
});
server.route({
url: "/board",
method: "GET",
config: {
rateLimit: readLimit
},
schema: {
querystring: z.object({
workspaceId: z.string().trim(),
environment: z.string().trim(),
secretPath: z.string().trim().transform(removeTrailingSlash)
}),
response: {
200: z.object({
policy: sapPubSchema
.extend({
userApprovers: z.object({ userId: z.string().nullable().optional() }).array()
})
.optional()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const policy = await server.services.secretApprovalPolicy.getSecretApprovalPolicyOfFolder({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.query.workspaceId,
...req.query
});
return { policy };
}
});
};
+19 -6
View File
@@ -5,6 +5,9 @@ import { registerAccessApprovalRequestRouter } from "./access-approval-request-r
import { registerAssumePrivilegeRouter } from "./assume-privilege-router";
import { AUDIT_LOG_STREAM_REGISTER_ROUTER_MAP, registerAuditLogStreamRouter } from "./audit-log-stream-routers";
import { registerCaCrlRouter } from "./certificate-authority-crl-router";
import { registerDeprecatedProjectRoleRouter } from "./deprecated-project-role-router";
import { registerDeprecatedProjectRouter } from "./deprecated-project-router";
import { registerDeprecatedSecretApprovalPolicyRouter } from "./deprecated-secret-approval-policy-router";
import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router";
import { registerKubernetesDynamicSecretLeaseRouter } from "./dynamic-secret-lease-routers/kubernetes-lease-router";
import { registerDynamicSecretRouter } from "./dynamic-secret-router";
@@ -27,7 +30,6 @@ import { registerRateLimitRouter } from "./rate-limit-router";
import { registerRelayRouter } from "./relay-router";
import { registerSamlRouter } from "./saml-router";
import { registerScimRouter } from "./scim-router";
import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router";
import { registerSecretApprovalRequestRouter } from "./secret-approval-request-router";
import { registerSecretRotationProviderRouter } from "./secret-rotation-provider-router";
import { registerSecretRotationRouter } from "./secret-rotation-router";
@@ -47,18 +49,29 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
// org role starts with organization
await server.register(registerOrgRoleRouter, { prefix: "/organization" });
await server.register(registerLicenseRouter, { prefix: "/organizations" });
// depreciated in favour of infisical workspace
await server.register(
async (projectRouter) => {
await projectRouter.register(registerProjectRoleRouter);
await projectRouter.register(registerProjectRouter);
await projectRouter.register(registerTrustedIpRouter);
await projectRouter.register(registerAssumePrivilegeRouter);
await projectRouter.register(registerDeprecatedProjectRoleRouter);
await projectRouter.register(registerDeprecatedProjectRouter);
},
{ prefix: "/workspace" }
);
await server.register(
async (projectRouter) => {
await projectRouter.register(registerProjectRoleRouter);
await projectRouter.register(registerTrustedIpRouter);
await projectRouter.register(registerAssumePrivilegeRouter);
await projectRouter.register(registerProjectRouter);
},
{ prefix: "/projects" }
);
await server.register(registerSnapshotRouter, { prefix: "/secret-snapshot" });
await server.register(registerPITRouter, { prefix: "/pit" });
await server.register(registerSecretApprovalPolicyRouter, { prefix: "/secret-approvals" });
await server.register(registerDeprecatedSecretApprovalPolicyRouter, { prefix: "/secret-approvals" });
await server.register(registerSecretApprovalRequestRouter, {
prefix: "/secret-approval-requests"
});
@@ -1,6 +1,7 @@
import { z } from "zod";
import { OrgMembershipRole, OrgMembershipsSchema, OrgRolesSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
@@ -42,6 +43,22 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
req.permission.authMethod,
req.permission.orgId
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
event: {
type: EventType.CREATE_ORG_ROLE,
metadata: {
roleId: role.id,
slug: req.body.slug,
name: req.body.name,
description: req.body.description,
permissions: JSON.stringify(req.body.permissions)
}
}
});
return { role };
}
});
@@ -116,6 +133,22 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
req.permission.authMethod,
req.permission.orgId
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
event: {
type: EventType.UPDATE_ORG_ROLE,
metadata: {
roleId: role.id,
slug: req.body.slug,
name: req.body.name,
description: req.body.description,
permissions: req.body.permissions ? JSON.stringify(req.body.permissions) : undefined
}
}
});
return { role };
}
});
@@ -146,6 +179,16 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => {
req.permission.authMethod,
req.permission.orgId
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
event: {
type: EventType.DELETE_ORG_ROLE,
metadata: { roleId: role.id, slug: role.slug, name: role.name }
}
});
return { role };
}
});
+101 -39
View File
@@ -2,26 +2,27 @@ import { packRules } from "@casl/ability/extra";
import { z } from "zod";
import { ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas";
import {
backfillPermissionV1SchemaToV2Schema,
ProjectPermissionV1Schema
} from "@app/ee/services/permission/project-permission";
import { PROJECT_ROLE } from "@app/lib/api-docs";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { checkForInvalidPermissionCombination } from "@app/ee/services/permission/permission-fns";
import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission";
import { ApiDocsTags, PROJECT_ROLE } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { SanitizedRoleSchemaV1 } from "@app/server/routes/sanitizedSchemas";
import { SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas";
import { AuthMode } from "@app/services/auth/auth-type";
import { ProjectRoleServiceIdentifierType } from "@app/services/project-role/project-role-types";
export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/:projectSlug/roles",
url: "/:projectId/roles",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.ProjectRoles],
description: "Create a project role",
security: [
{
@@ -29,10 +30,10 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
}
],
params: z.object({
projectSlug: z.string().trim().describe(PROJECT_ROLE.CREATE.projectSlug)
projectId: z.string().trim().describe(PROJECT_ROLE.CREATE.projectId)
}),
body: z.object({
slug: slugSchema({ max: 64 })
slug: slugSchema({ min: 1, max: 64 })
.refine(
(val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole),
"Please choose a different slug, the slug you have entered is reserved"
@@ -40,28 +41,48 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
.describe(PROJECT_ROLE.CREATE.slug),
name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name),
description: z.string().trim().nullish().describe(PROJECT_ROLE.CREATE.description),
permissions: ProjectPermissionV1Schema.array().describe(PROJECT_ROLE.CREATE.permissions)
permissions: ProjectPermissionV2Schema.array()
.describe(PROJECT_ROLE.CREATE.permissions)
.refine(checkForInvalidPermissionCombination)
}),
response: {
200: z.object({
role: SanitizedRoleSchemaV1
role: SanitizedRoleSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const stringifiedPermissions = JSON.stringify(packRules(req.body.permissions));
const role = await server.services.projectRole.createRole({
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actor: req.permission.type,
filter: {
type: ProjectRoleServiceIdentifierType.SLUG,
projectSlug: req.params.projectSlug
type: ProjectRoleServiceIdentifierType.ID,
projectId: req.params.projectId
},
data: {
...req.body,
permissions: JSON.stringify(packRules(backfillPermissionV1SchemaToV2Schema(req.body.permissions, true)))
permissions: stringifiedPermissions
}
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: role.projectId,
event: {
type: EventType.CREATE_PROJECT_ROLE,
metadata: {
roleId: role.id,
slug: req.body.slug,
name: req.body.name,
description: req.body.description,
permissions: stringifiedPermissions
}
}
});
@@ -71,11 +92,13 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
server.route({
method: "PATCH",
url: "/:projectSlug/roles/:roleId",
url: "/:projectId/roles/:roleId",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.ProjectRoles],
description: "Update a project role",
security: [
{
@@ -83,29 +106,33 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
}
],
params: z.object({
projectSlug: z.string().trim().describe(PROJECT_ROLE.UPDATE.projectSlug),
projectId: z.string().trim().describe(PROJECT_ROLE.UPDATE.projectId),
roleId: z.string().trim().describe(PROJECT_ROLE.UPDATE.roleId)
}),
body: z.object({
slug: slugSchema({ max: 64 })
slug: slugSchema({ min: 1, max: 64 })
.refine(
(val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole),
"Please choose a different slug, the slug you have entered is reserved"
)
.describe(PROJECT_ROLE.UPDATE.slug)
.optional(),
.optional()
.describe(PROJECT_ROLE.UPDATE.slug),
name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name),
description: z.string().trim().nullish().describe(PROJECT_ROLE.UPDATE.description),
permissions: ProjectPermissionV1Schema.array().describe(PROJECT_ROLE.UPDATE.permissions).optional()
permissions: ProjectPermissionV2Schema.array()
.describe(PROJECT_ROLE.UPDATE.permissions)
.optional()
.superRefine(checkForInvalidPermissionCombination)
}),
response: {
200: z.object({
role: SanitizedRoleSchemaV1
role: SanitizedRoleSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const stringifiedPermissions = req.body.permissions ? JSON.stringify(packRules(req.body.permissions)) : undefined;
const role = await server.services.projectRole.updateRole({
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
@@ -114,22 +141,39 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
roleId: req.params.roleId,
data: {
...req.body,
permissions: req.body.permissions
? JSON.stringify(packRules(backfillPermissionV1SchemaToV2Schema(req.body.permissions, true)))
: undefined
permissions: stringifiedPermissions
}
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: role.projectId,
event: {
type: EventType.UPDATE_PROJECT_ROLE,
metadata: {
roleId: role.id,
slug: req.body.slug,
name: req.body.name,
description: req.body.description,
permissions: stringifiedPermissions
}
}
});
return { role };
}
});
server.route({
method: "DELETE",
url: "/:projectSlug/roles/:roleId",
url: "/:projectId/roles/:roleId",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.ProjectRoles],
description: "Delete a project role",
security: [
{
@@ -137,12 +181,12 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
}
],
params: z.object({
projectSlug: z.string().trim().describe(PROJECT_ROLE.DELETE.projectSlug),
projectId: z.string().trim().describe(PROJECT_ROLE.DELETE.projectId),
roleId: z.string().trim().describe(PROJECT_ROLE.DELETE.roleId)
}),
response: {
200: z.object({
role: SanitizedRoleSchemaV1
role: SanitizedRoleSchema
})
}
},
@@ -155,17 +199,34 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
actor: req.permission.type,
roleId: req.params.roleId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: role.projectId,
event: {
type: EventType.DELETE_PROJECT_ROLE,
metadata: {
roleId: role.id,
slug: role.slug,
name: role.name
}
}
});
return { role };
}
});
server.route({
method: "GET",
url: "/:projectSlug/roles",
url: "/:projectId/roles",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.ProjectRoles],
description: "List project role",
security: [
{
@@ -173,7 +234,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
}
],
params: z.object({
projectSlug: z.string().trim().describe(PROJECT_ROLE.LIST.projectSlug)
projectId: z.string().trim().describe(PROJECT_ROLE.LIST.projectId)
}),
response: {
200: z.object({
@@ -189,8 +250,8 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
actorOrgId: req.permission.orgId,
actor: req.permission.type,
filter: {
type: ProjectRoleServiceIdentifierType.SLUG,
projectSlug: req.params.projectSlug
type: ProjectRoleServiceIdentifierType.ID,
projectId: req.params.projectId
}
});
return { roles };
@@ -199,18 +260,20 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/:projectSlug/roles/slug/:slug",
url: "/:projectId/roles/slug/:roleSlug",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.ProjectRoles],
params: z.object({
projectSlug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.projectSlug),
slug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.roleSlug)
projectId: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.projectId),
roleSlug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.roleSlug)
}),
response: {
200: z.object({
role: SanitizedRoleSchemaV1.omit({ version: true })
role: SanitizedRoleSchema.omit({ version: true })
})
}
},
@@ -222,12 +285,11 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
actorOrgId: req.permission.orgId,
actor: req.permission.type,
filter: {
type: ProjectRoleServiceIdentifierType.SLUG,
projectSlug: req.params.projectSlug
type: ProjectRoleServiceIdentifierType.ID,
projectId: req.params.projectId
},
roleSlug: req.params.slug
roleSlug: req.params.roleSlug
});
return { role };
}
});
+28 -153
View File
@@ -1,9 +1,9 @@
import { z } from "zod";
import { AuditLogsSchema, SecretSnapshotsSchema } from "@app/db/schemas";
import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags, AUDIT_LOGS, PROJECTS } from "@app/lib/api-docs";
import { getLastMidnightDateISO, removeTrailingSlash } from "@app/lib/fn";
import { SecretSnapshotsSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags, PROJECTS } from "@app/lib/api-docs";
import { removeTrailingSlash } from "@app/lib/fn";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
@@ -12,7 +12,7 @@ import { KmsType } from "@app/services/kms/kms-types";
export const registerProjectRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/:workspaceId/secret-snapshots",
url: "/:projectId/secret-snapshots",
config: {
rateLimit: readLimit
},
@@ -26,7 +26,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
}
],
params: z.object({
workspaceId: z.string().trim().describe(PROJECTS.GET_SNAPSHOTS.workspaceId)
projectId: z.string().trim().describe(PROJECTS.GET_SNAPSHOTS.projectId)
}),
querystring: z.object({
environment: z.string().trim().describe(PROJECTS.GET_SNAPSHOTS.environment),
@@ -47,7 +47,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
projectId: req.params.workspaceId,
projectId: req.params.projectId,
...req.query
});
return { secretSnapshots };
@@ -56,13 +56,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/:workspaceId/secret-snapshots/count",
url: "/:projectId/secret-snapshots/count",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim()
projectId: z.string().trim()
}),
querystring: z.object({
environment: z.string().trim(),
@@ -81,7 +81,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.params.workspaceId,
projectId: req.params.projectId,
environment: req.query.environment,
path: req.query.path
});
@@ -89,140 +89,15 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
}
});
/*
* Daniel: This endpoint is no longer is use.
* We are keeping it for now because it has been exposed in our public api docs for a while, so by removing it we are likely to break users workflows.
*
* Please refer to the new endpoint, GET /api/v1/organization/audit-logs, for the same (and more) functionality.
*/
server.route({
method: "GET",
url: "/:workspaceId/audit-logs",
config: {
rateLimit: readLimit
},
schema: {
description: "Return audit logs",
security: [
{
bearerAuth: []
}
],
params: z.object({
workspaceId: z.string().trim().describe(AUDIT_LOGS.EXPORT.projectId)
}),
querystring: z
.object({
eventType: z.nativeEnum(EventType).optional().describe(AUDIT_LOGS.EXPORT.eventType),
userAgentType: z.nativeEnum(UserAgentType).optional().describe(AUDIT_LOGS.EXPORT.userAgentType),
startDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.startDate),
endDate: z.string().datetime().optional().describe(AUDIT_LOGS.EXPORT.endDate),
offset: z.coerce.number().default(0).describe(AUDIT_LOGS.EXPORT.offset),
limit: z.coerce.number().max(1000).default(20).describe(AUDIT_LOGS.EXPORT.limit),
actor: z.string().optional().describe(AUDIT_LOGS.EXPORT.actor)
})
.superRefine((el, ctx) => {
if (el.endDate && el.startDate) {
const startDate = new Date(el.startDate);
const endDate = new Date(el.endDate);
const maxAllowedDate = new Date(startDate);
maxAllowedDate.setMonth(maxAllowedDate.getMonth() + 3);
if (endDate < startDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["endDate"],
message: "End date cannot be before start date"
});
}
if (endDate > maxAllowedDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["endDate"],
message: "Dates must be within 3 months"
});
}
}
}),
response: {
200: z.object({
auditLogs: AuditLogsSchema.omit({
eventMetadata: true,
eventType: true,
actor: true,
actorMetadata: true
})
.merge(
z.object({
project: z
.object({
name: z.string(),
slug: z.string()
})
.optional(),
event: z.object({
type: z.string(),
metadata: z.any()
}),
actor: z.object({
type: z.string(),
metadata: z.any()
})
})
)
.array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const auditLogs = await server.services.auditLog.listAuditLogs({
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
actor: req.permission.type,
filter: {
...req.query,
projectId: req.params.workspaceId,
endDate: req.query.endDate || new Date().toISOString(),
startDate: req.query.startDate || getLastMidnightDateISO(),
auditLogActorId: req.query.actor,
eventType: req.query.eventType ? [req.query.eventType] : undefined
}
});
return { auditLogs };
}
});
server.route({
method: "GET",
url: "/:workspaceId/audit-logs/filters/actors",
url: "/:projectId/kms",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim()
}),
response: {
200: z.object({
actors: z.string().array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async () => ({ actors: [] })
});
server.route({
method: "GET",
url: "/:workspaceId/kms",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim()
projectId: z.string().trim()
}),
response: {
200: z.object({
@@ -241,7 +116,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.params.workspaceId
projectId: req.params.projectId
});
return kmsKey;
@@ -250,13 +125,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
server.route({
method: "PATCH",
url: "/:workspaceId/kms",
url: "/:projectId/kms",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim()
projectId: z.string().trim()
}),
body: z.object({
kms: z.discriminatedUnion("type", [
@@ -281,13 +156,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.params.workspaceId,
projectId: req.params.projectId,
...req.body
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.params.workspaceId,
projectId: req.params.projectId,
event: {
type: EventType.UPDATE_PROJECT_KMS,
metadata: {
@@ -307,13 +182,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/:workspaceId/kms/backup",
url: "/:projectId/kms/backup",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim()
projectId: z.string().trim()
}),
response: {
200: z.object({
@@ -328,12 +203,12 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.params.workspaceId
projectId: req.params.projectId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.params.workspaceId,
projectId: req.params.projectId,
event: {
type: EventType.GET_PROJECT_KMS_BACKUP,
metadata: {}
@@ -346,13 +221,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/:workspaceId/kms/backup",
url: "/:projectId/kms/backup",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim()
projectId: z.string().trim()
}),
body: z.object({
backup: z.string().min(1)
@@ -374,13 +249,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.params.workspaceId,
projectId: req.params.projectId,
backup: req.body.backup
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.params.workspaceId,
projectId: req.params.projectId,
event: {
type: EventType.LOAD_PROJECT_KMS_BACKUP,
metadata: {}
@@ -393,13 +268,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/:workspaceId/migrate-v3",
url: "/:projectId/migrate-v3",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim()
projectId: z.string().trim()
}),
response: {
@@ -415,7 +290,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.params.workspaceId
projectId: req.params.projectId
});
return migration;
+53 -7
View File
@@ -1,9 +1,10 @@
import { z } from "zod";
import { RelaysSchema } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
import { writeLimit } from "@app/server/config/rateLimiter";
import { UnauthorizedError } from "@app/lib/errors";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { slugSchema } from "@app/server/lib/schemas";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
@@ -89,14 +90,59 @@ export const registerRelayRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
throw new BadRequestError({
message: "Org relay registration is not yet supported"
});
return server.services.relay.registerRelay({
...req.body,
identityId: req.permission.id,
orgId: req.permission.orgId
orgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod
});
}
});
server.route({
method: "GET",
url: "/",
schema: {
response: {
200: RelaysSchema.array()
}
},
config: {
rateLimit: readLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
return server.services.relay.getRelays({
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
}
});
server.route({
method: "DELETE",
url: "/:id",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
id: z.string()
}),
response: {
200: RelaysSchema
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
return server.services.relay.deleteRelay({
id: req.params.id,
actorId: req.permission.id,
actor: req.permission.type,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
}
});
+10 -5
View File
@@ -286,7 +286,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
entryPoint: z.string(),
issuer: z.string(),
cert: z.string(),
lastUsed: z.date().nullable().optional()
lastUsed: z.date().nullable().optional(),
enableGroupSync: z.boolean().optional()
})
}
},
@@ -325,14 +326,15 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
isActive: z.boolean().describe(SamlSso.CREATE_CONFIG.isActive),
entryPoint: z.string().trim().describe(SamlSso.CREATE_CONFIG.entryPoint),
issuer: z.string().trim().describe(SamlSso.CREATE_CONFIG.issuer),
cert: z.string().trim().describe(SamlSso.CREATE_CONFIG.cert)
cert: z.string().trim().describe(SamlSso.CREATE_CONFIG.cert),
enableGroupSync: z.boolean().optional().describe(SamlSso.CREATE_CONFIG.enableGroupSync)
}),
response: {
200: SanitizedSamlConfigSchema
}
},
handler: async (req) => {
const { isActive, authProvider, issuer, entryPoint, cert } = req.body;
const { isActive, authProvider, issuer, entryPoint, cert, enableGroupSync } = req.body;
const { permission } = req;
return server.services.saml.createSamlCfg({
@@ -341,6 +343,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
issuer,
entryPoint,
idpCert: cert,
enableGroupSync,
actor: permission.type,
actorId: permission.id,
actorAuthMethod: permission.authMethod,
@@ -372,7 +375,8 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
isActive: z.boolean().describe(SamlSso.UPDATE_CONFIG.isActive),
entryPoint: z.string().trim().describe(SamlSso.UPDATE_CONFIG.entryPoint),
issuer: z.string().trim().describe(SamlSso.UPDATE_CONFIG.issuer),
cert: z.string().trim().describe(SamlSso.UPDATE_CONFIG.cert)
cert: z.string().trim().describe(SamlSso.UPDATE_CONFIG.cert),
enableGroupSync: z.boolean().optional().describe(SamlSso.UPDATE_CONFIG.enableGroupSync)
})
.partial()
.merge(z.object({ organizationId: z.string().trim().describe(SamlSso.UPDATE_CONFIG.organizationId) })),
@@ -381,7 +385,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
}
},
handler: async (req) => {
const { isActive, authProvider, issuer, entryPoint, cert } = req.body;
const { isActive, authProvider, issuer, entryPoint, cert, enableGroupSync } = req.body;
const { permission } = req;
return server.services.saml.updateSamlCfg({
@@ -390,6 +394,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => {
issuer,
entryPoint,
idpCert: cert,
enableGroupSync,
actor: permission.type,
actorId: permission.id,
actorAuthMethod: permission.authMethod,
@@ -27,7 +27,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
},
schema: {
querystring: z.object({
workspaceId: z.string().trim(),
projectId: z.string().trim(),
environment: z.string().trim().optional(),
committer: z.string().trim().optional(),
search: z.string().trim().optional(),
@@ -80,7 +80,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
...req.query,
projectId: req.query.workspaceId
projectId: req.query.projectId
});
return { approvals, totalCount };
}
@@ -94,7 +94,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
},
schema: {
querystring: z.object({
workspaceId: z.string().trim(),
projectId: z.string().trim(),
policyId: z.string().trim().optional()
}),
response: {
@@ -113,7 +113,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.query.workspaceId,
projectId: req.query.projectId,
policyId: req.query.policyId
});
return { approvals };
@@ -320,10 +320,20 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
.array(),
secretPath: z.string(),
commits: secretRawSchema
.omit({ _id: true, environment: true, workspace: true, type: true, version: true, secretValue: true })
.omit({
_id: true,
environment: true,
workspace: true,
type: true,
version: true,
secretValue: true,
secretComment: true
})
.extend({
secretValueHidden: z.boolean(),
secretValue: z.string().optional(),
secretComment: z.string().optional(),
skipMultilineEncoding: z.boolean().nullish(),
isRotatedSecret: z.boolean().optional(),
op: z.string(),
tags: SanitizedTagSchema.array().optional(),
@@ -348,7 +358,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
secretValueHidden: z.boolean(),
secretComment: z.string().optional(),
tags: SanitizedTagSchema.array().optional(),
secretMetadata: ResourceMetadataSchema.nullish()
secretMetadata: ResourceMetadataSchema.nullish(),
skipMultilineEncoding: z.boolean().nullish()
})
.optional()
})
+2 -2
View File
@@ -34,7 +34,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
secretName: z.string().trim().describe(RAW_SECRETS.GET_ACCESS_LIST.secretName)
}),
querystring: z.object({
workspaceId: z.string().trim().describe(RAW_SECRETS.GET_ACCESS_LIST.workspaceId),
projectId: z.string().trim().describe(RAW_SECRETS.GET_ACCESS_LIST.projectId),
environment: z.string().trim().describe(RAW_SECRETS.GET_ACCESS_LIST.environment),
secretPath: z
.string()
@@ -54,7 +54,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { secretName } = req.params;
const { secretPath, environment, workspaceId: projectId } = req.query;
const { secretPath, environment, projectId } = req.query;
return server.services.secret.getSecretAccessList({
actorId: req.permission.id,
+12 -12
View File
@@ -9,13 +9,13 @@ import { AuthMode } from "@app/services/auth/auth-type";
export const registerTrustedIpRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/:workspaceId/trusted-ips",
url: "/:projectId/trusted-ips",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim()
projectId: z.string().trim()
}),
response: {
200: z.object({
@@ -27,7 +27,7 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => {
handler: async (req) => {
const trustedIps = await server.services.trustedIp.listIpsByProjectId({
actorAuthMethod: req.permission.authMethod,
projectId: req.params.workspaceId,
projectId: req.params.projectId,
actor: req.permission.type,
actorId: req.permission.id,
actorOrgId: req.permission.orgId
@@ -38,13 +38,13 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/:workspaceId/trusted-ips",
url: "/:projectId/trusted-ips",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim()
projectId: z.string().trim()
}),
body: z.object({
ipAddress: z.string().trim(),
@@ -61,7 +61,7 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => {
handler: async (req) => {
const { trustedIp, project } = await server.services.trustedIp.addProjectIp({
actorAuthMethod: req.permission.authMethod,
projectId: req.params.workspaceId,
projectId: req.params.projectId,
actor: req.permission.type,
actorId: req.permission.id,
actorOrgId: req.permission.orgId,
@@ -86,13 +86,13 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => {
server.route({
method: "PATCH",
url: "/:workspaceId/trusted-ips/:trustedIpId",
url: "/:projectId/trusted-ips/:trustedIpId",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim(),
projectId: z.string().trim(),
trustedIpId: z.string().trim()
}),
body: z.object({
@@ -108,7 +108,7 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => {
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { trustedIp, project } = await server.services.trustedIp.updateProjectIp({
projectId: req.params.workspaceId,
projectId: req.params.projectId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
@@ -135,13 +135,13 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => {
server.route({
method: "DELETE",
url: "/:workspaceId/trusted-ips/:trustedIpId",
url: "/:projectId/trusted-ips/:trustedIpId",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
workspaceId: z.string().trim(),
projectId: z.string().trim(),
trustedIpId: z.string().trim()
}),
response: {
@@ -153,7 +153,7 @@ export const registerTrustedIpRouter = async (server: FastifyZodProvider) => {
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { trustedIp, project } = await server.services.trustedIp.deleteProjectIp({
projectId: req.params.workspaceId,
projectId: req.params.projectId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
@@ -2,6 +2,7 @@ import { packRules } from "@casl/ability/extra";
import { z } from "zod";
import { ProjectMembershipRole, ProjectRolesSchema } from "@app/db/schemas";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { checkForInvalidPermissionCombination } from "@app/ee/services/permission/permission-fns";
import { ProjectPermissionV2Schema } from "@app/ee/services/permission/project-permission";
import { ApiDocsTags, PROJECT_ROLE } from "@app/lib/api-docs";
@@ -12,7 +13,7 @@ import { SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas";
import { AuthMode } from "@app/services/auth/auth-type";
import { ProjectRoleServiceIdentifierType } from "@app/services/project-role/project-role-types";
export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
export const registerDeprecatedProjectRoleRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/:projectId/roles",
@@ -52,6 +53,8 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const stringifiedPermissions = JSON.stringify(packRules(req.body.permissions));
const role = await server.services.projectRole.createRole({
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
@@ -63,9 +66,26 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
},
data: {
...req.body,
permissions: JSON.stringify(packRules(req.body.permissions))
permissions: stringifiedPermissions
}
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: role.projectId,
event: {
type: EventType.CREATE_PROJECT_ROLE,
metadata: {
roleId: role.id,
slug: req.body.slug,
name: req.body.name,
description: req.body.description,
permissions: stringifiedPermissions
}
}
});
return { role };
}
});
@@ -112,6 +132,7 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const stringifiedPermissions = req.body.permissions ? JSON.stringify(packRules(req.body.permissions)) : undefined;
const role = await server.services.projectRole.updateRole({
actorAuthMethod: req.permission.authMethod,
actorId: req.permission.id,
@@ -120,9 +141,26 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
roleId: req.params.roleId,
data: {
...req.body,
permissions: req.body.permissions ? JSON.stringify(packRules(req.body.permissions)) : undefined
permissions: stringifiedPermissions
}
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: role.projectId,
event: {
type: EventType.UPDATE_PROJECT_ROLE,
metadata: {
roleId: role.id,
slug: req.body.slug,
name: req.body.name,
description: req.body.description,
permissions: stringifiedPermissions
}
}
});
return { role };
}
});
@@ -161,6 +199,21 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => {
actor: req.permission.type,
roleId: req.params.roleId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: role.projectId,
event: {
type: EventType.DELETE_PROJECT_ROLE,
metadata: {
roleId: role.id,
slug: role.slug,
name: role.name
}
}
});
return { role };
}
});
+6 -3
View File
@@ -7,15 +7,16 @@ import {
SECRET_SCANNING_REGISTER_ROUTER_MAP
} from "@app/ee/routes/v2/secret-scanning-v2-routers";
import { registerDeprecatedProjectRoleRouter } from "./deprecated-project-role-router";
import { registerGatewayV2Router } from "./gateway-router";
import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router";
import { registerProjectRoleRouter } from "./project-role-router";
import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router";
export const registerV2EERoutes = async (server: FastifyZodProvider) => {
// org role starts with organization
await server.register(
async (projectRouter) => {
await projectRouter.register(registerProjectRoleRouter);
// this has been depreciated and moved to /api/v1/projects
await projectRouter.register(registerDeprecatedProjectRoleRouter);
},
{ prefix: "/workspace" }
);
@@ -26,6 +27,8 @@ export const registerV2EERoutes = async (server: FastifyZodProvider) => {
await server.register(registerGatewayV2Router, { prefix: "/gateways" });
await server.register(registerSecretApprovalPolicyRouter, { prefix: "/secret-approvals" });
await server.register(
async (secretRotationV2Router) => {
// register generic secret rotation endpoints
@@ -19,7 +19,7 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
schema: {
body: z
.object({
workspaceId: z.string(),
projectId: z.string(),
name: z.string().optional(),
environment: z.string().optional(),
environments: z.string().array().optional(),
@@ -69,7 +69,6 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.body.workspaceId,
...req.body,
name: req.body.name ?? `${req.body.environment || req.body.environments?.join(",")}-${nanoid(3)}`,
enforcementLevel: req.body.enforcementLevel
@@ -174,7 +173,7 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
},
schema: {
querystring: z.object({
workspaceId: z.string().trim()
projectId: z.string().trim()
}),
response: {
200: z.object({
@@ -204,7 +203,7 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.query.workspaceId
projectId: req.query.projectId
});
return { approvals };
}
@@ -263,7 +262,7 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
},
schema: {
querystring: z.object({
workspaceId: z.string().trim(),
projectId: z.string().trim(),
environment: z.string().trim(),
secretPath: z.string().trim().transform(removeTrailingSlash)
}),
@@ -284,7 +283,6 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
projectId: req.query.workspaceId,
...req.query
});
return { policy };
@@ -9,6 +9,7 @@ import { registerMySqlCredentialsRotationRouter } from "./mysql-credentials-rota
import { registerOktaClientSecretRotationRouter } from "./okta-client-secret-rotation-router";
import { registerOracleDBCredentialsRotationRouter } from "./oracledb-credentials-rotation-router";
import { registerPostgresCredentialsRotationRouter } from "./postgres-credentials-rotation-router";
import { registerRedisCredentialsRotationRouter } from "./redis-credentials-rotation-router";
export * from "./secret-rotation-v2-router";
@@ -24,5 +25,6 @@ export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record<
[SecretRotation.AzureClientSecret]: registerAzureClientSecretRotationRouter,
[SecretRotation.AwsIamUserSecret]: registerAwsIamUserSecretRotationRouter,
[SecretRotation.LdapPassword]: registerLdapPasswordRotationRouter,
[SecretRotation.OktaClientSecret]: registerOktaClientSecretRotationRouter
[SecretRotation.OktaClientSecret]: registerOktaClientSecretRotationRouter,
[SecretRotation.RedisCredentials]: registerRedisCredentialsRotationRouter
};
@@ -0,0 +1,19 @@
import {
CreateRedisCredentialsRotationSchema,
RedisCredentialsRotationGeneratedCredentialsSchema,
RedisCredentialsRotationSchema,
UpdateRedisCredentialsRotationSchema
} from "@app/ee/services/secret-rotation-v2/redis-credentials";
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints";
export const registerRedisCredentialsRotationRouter = async (server: FastifyZodProvider) =>
registerSecretRotationEndpoints({
type: SecretRotation.RedisCredentials,
server,
responseSchema: RedisCredentialsRotationSchema,
createSchema: CreateRedisCredentialsRotationSchema,
updateSchema: UpdateRedisCredentialsRotationSchema,
generatedCredentialsSchema: RedisCredentialsRotationGeneratedCredentialsSchema
});
@@ -10,6 +10,7 @@ import { MySqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-
import { OktaClientSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/okta-client-secret";
import { OracleDBCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/oracledb-credentials";
import { PostgresCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials";
import { RedisCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/redis-credentials";
import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema";
import { ApiDocsTags, SecretRotations } from "@app/lib/api-docs";
import { readLimit } from "@app/server/config/rateLimiter";
@@ -25,7 +26,8 @@ const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [
AzureClientSecretRotationListItemSchema,
AwsIamUserSecretRotationListItemSchema,
LdapPasswordRotationListItemSchema,
OktaClientSecretRotationListItemSchema
OktaClientSecretRotationListItemSchema,
RedisCredentialsRotationListItemSchema
]);
export const registerSecretRotationV2Router = async (server: FastifyZodProvider) => {
@@ -35,7 +35,7 @@ import { ApprovalStatus, TAccessApprovalRequestServiceFactory } from "./access-a
type TSecretApprovalRequestServiceFactoryDep = {
additionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "create" | "findById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "invalidateProjectPermissionCache">;
accessApprovalPolicyApproverDAL: Pick<TAccessApprovalPolicyApproverDALFactory, "find">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
projectDAL: Pick<
@@ -758,6 +758,8 @@ export const accessApprovalRequestServiceFactory = ({
{ privilegeId: privilegeIdToSet, status: ApprovalStatus.APPROVED },
tx
);
await permissionService.invalidateProjectPermissionCache(accessApprovalRequest.projectId, tx);
}
}
@@ -777,6 +779,20 @@ export const accessApprovalRequestServiceFactory = ({
.map((appUser) => appUser.email)
.filter((email): email is string => !!email);
const approvalPath = `/projects/secret-management/${project.id}/approval`;
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
await notificationService.createUserNotifications(
approverUsersForEmail.map((approver) => ({
userId: approver.id,
orgId: actorOrgId,
type: NotificationType.ACCESS_POLICY_BYPASSED,
title: "Secret Access Policy Bypassed",
body: `**${actingUser.firstName} ${actingUser.lastName}** (${actingUser.email}) has accessed a secret in **${policy.secretPath || "/"}** in the **${environment?.name || permissionEnvironment}** environment for project **${project.name}** without obtaining the required approval.`,
link: approvalPath
}))
);
if (recipientEmails.length > 0) {
await smtpService.sendMail({
recipients: recipientEmails,
@@ -788,7 +804,7 @@ export const accessApprovalRequestServiceFactory = ({
bypassReason: bypassReason || "No reason provided",
secretPath: policy.secretPath || "/",
environment: environment?.name || permissionEnvironment,
approvalUrl: `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval`,
approvalUrl,
requestType: "access"
},
template: SmtpTemplates.AccessSecretRequestBypassed
@@ -146,7 +146,7 @@ export enum EventType {
MOVE_SECRETS = "move-secrets",
DELETE_SECRET = "delete-secret",
DELETE_SECRETS = "delete-secrets",
GET_WORKSPACE_KEY = "get-workspace-key",
GET_PROJECT_KEY = "get-project-key",
AUTHORIZE_INTEGRATION = "authorize-integration",
UPDATE_INTEGRATION_AUTH = "update-integration-auth",
UNAUTHORIZE_INTEGRATION = "unauthorize-integration",
@@ -199,6 +199,7 @@ export enum EventType {
CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret",
REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret",
CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts",
CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS = "clear-identity-ldap-auth-lockouts",
GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret",
GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID = "get-identity-universal-auth-client-secret-by-id",
@@ -249,9 +250,9 @@ export enum EventType {
UPDATE_ENVIRONMENT = "update-environment",
DELETE_ENVIRONMENT = "delete-environment",
GET_ENVIRONMENT = "get-environment",
ADD_WORKSPACE_MEMBER = "add-workspace-member",
ADD_BATCH_WORKSPACE_MEMBER = "add-workspace-members",
REMOVE_WORKSPACE_MEMBER = "remove-workspace-member",
ADD_PROJECT_MEMBER = "add-project-member",
ADD_BATCH_PROJECT_MEMBER = "add-project-members",
REMOVE_PROJECT_MEMBER = "remove-project-member",
CREATE_FOLDER = "create-folder",
UPDATE_FOLDER = "update-folder",
DELETE_FOLDER = "delete-folder",
@@ -264,8 +265,8 @@ export enum EventType {
CREATE_SECRET_IMPORT = "create-secret-import",
UPDATE_SECRET_IMPORT = "update-secret-import",
DELETE_SECRET_IMPORT = "delete-secret-import",
UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role",
UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions",
UPDATE_USER_PROJECT_ROLE = "update-user-project-role",
UPDATE_USER_PROJECT_DENIED_PERMISSIONS = "update-user-project-denied-permissions",
SECRET_APPROVAL_MERGED = "secret-approval-merged",
SECRET_APPROVAL_REQUEST = "secret-approval-request",
SECRET_APPROVAL_CLOSED = "secret-approval-closed",
@@ -392,6 +393,8 @@ export enum EventType {
CREATE_APP_CONNECTION = "create-app-connection",
UPDATE_APP_CONNECTION = "update-app-connection",
DELETE_APP_CONNECTION = "delete-app-connection",
GET_APP_CONNECTION_USAGE = "get-app-connection-usage",
MIGRATE_APP_CONNECTION = "migrate-app-connection",
CREATE_SHARED_SECRET = "create-shared-secret",
CREATE_SECRET_REQUEST = "create-secret-request",
DELETE_SHARED_SECRET = "delete-shared-secret",
@@ -404,6 +407,14 @@ export enum EventType {
SECRET_SYNC_SYNC_SECRETS = "secret-sync-sync-secrets",
SECRET_SYNC_IMPORT_SECRETS = "secret-sync-import-secrets",
SECRET_SYNC_REMOVE_SECRETS = "secret-sync-remove-secrets",
GET_PKI_SYNCS = "get-pki-syncs",
GET_PKI_SYNC = "get-pki-sync",
CREATE_PKI_SYNC = "create-pki-sync",
UPDATE_PKI_SYNC = "update-pki-sync",
DELETE_PKI_SYNC = "delete-pki-sync",
PKI_SYNC_SYNC_CERTIFICATES = "pki-sync-sync-certificates",
PKI_SYNC_IMPORT_CERTIFICATES = "pki-sync-import-certificates",
PKI_SYNC_REMOVE_CERTIFICATES = "pki-sync-remove-certificates",
OIDC_GROUP_MEMBERSHIP_MAPPING_ASSIGN_USER = "oidc-group-membership-mapping-assign-user",
OIDC_GROUP_MEMBERSHIP_MAPPING_REMOVE_USER = "oidc-group-membership-mapping-remove-user",
CREATE_KMIP_CLIENT = "create-kmip-client",
@@ -475,9 +486,21 @@ export enum EventType {
UPDATE_PROJECT = "update-project",
DELETE_PROJECT = "delete-project",
CREATE_PROJECT_ROLE = "create-project-role",
UPDATE_PROJECT_ROLE = "update-project-role",
DELETE_PROJECT_ROLE = "delete-project-role",
CREATE_ORG_ROLE = "create-org-role",
UPDATE_ORG_ROLE = "update-org-role",
DELETE_ORG_ROLE = "delete-org-role",
CREATE_SECRET_REMINDER = "create-secret-reminder",
GET_SECRET_REMINDER = "get-secret-reminder",
DELETE_SECRET_REMINDER = "delete-secret-reminder"
DELETE_SECRET_REMINDER = "delete-secret-reminder",
DASHBOARD_LIST_SECRETS = "dashboard-list-secrets",
DASHBOARD_GET_SECRET_VALUE = "dashboard-get-secret-value",
DASHBOARD_GET_SECRET_VERSION_VALUE = "dashboard-get-secret-version-value"
}
export const filterableSecretEvents: EventType[] = [
@@ -588,6 +611,7 @@ interface CreateSecretEvent {
secretKey: string;
secretVersion: number;
secretMetadata?: TSecretMetadata;
secretTags?: string[];
};
}
@@ -602,6 +626,7 @@ interface CreateSecretBatchEvent {
secretPath?: string;
secretVersion: number;
secretMetadata?: TSecretMetadata;
secretTags?: string[];
}>;
};
}
@@ -615,6 +640,7 @@ interface UpdateSecretEvent {
secretKey: string;
secretVersion: number;
secretMetadata?: TSecretMetadata;
secretTags?: string[];
};
}
@@ -629,6 +655,7 @@ interface UpdateSecretBatchEvent {
secretVersion: number;
secretMetadata?: TSecretMetadata;
secretPath?: string;
secretTags?: string[];
}>;
};
}
@@ -664,8 +691,8 @@ interface DeleteSecretBatchEvent {
};
}
interface GetWorkspaceKeyEvent {
type: EventType.GET_WORKSPACE_KEY;
interface GetProjectKeyEvent {
type: EventType.GET_PROJECT_KEY;
metadata: {
keyId: string;
};
@@ -1370,6 +1397,10 @@ interface AddIdentityLdapAuthEvent {
allowedFields?: TAllowedFields[];
url: string;
templateId?: string | null;
lockoutEnabled: boolean;
lockoutThreshold: number;
lockoutDurationSeconds: number;
lockoutCounterResetSeconds: number;
};
}
@@ -1384,6 +1415,10 @@ interface UpdateIdentityLdapAuthEvent {
allowedFields?: TAllowedFields[];
url?: string;
templateId?: string | null;
lockoutEnabled?: boolean;
lockoutThreshold?: number;
lockoutDurationSeconds?: number;
lockoutCounterResetSeconds?: number;
};
}
@@ -1401,6 +1436,13 @@ interface RevokeIdentityLdapAuthEvent {
};
}
interface ClearIdentityLdapAuthLockoutsEvent {
type: EventType.CLEAR_IDENTITY_LDAP_AUTH_LOCKOUTS;
metadata: {
identityId: string;
};
}
interface LoginIdentityOidcAuthEvent {
type: EventType.LOGIN_IDENTITY_OIDC_AUTH;
metadata: {
@@ -1557,24 +1599,24 @@ interface DeleteEnvironmentEvent {
};
}
interface AddWorkspaceMemberEvent {
type: EventType.ADD_WORKSPACE_MEMBER;
interface AddProjectMemberEvent {
type: EventType.ADD_PROJECT_MEMBER;
metadata: {
userId: string;
email: string;
};
}
interface AddBatchWorkspaceMemberEvent {
type: EventType.ADD_BATCH_WORKSPACE_MEMBER;
interface AddBatchProjectMemberEvent {
type: EventType.ADD_BATCH_PROJECT_MEMBER;
metadata: Array<{
userId: string;
email: string;
}>;
}
interface RemoveWorkspaceMemberEvent {
type: EventType.REMOVE_WORKSPACE_MEMBER;
interface RemoveProjectMemberEvent {
type: EventType.REMOVE_PROJECT_MEMBER;
metadata: {
userId: string;
email: string;
@@ -1713,7 +1755,7 @@ interface DeleteSecretImportEvent {
}
interface UpdateUserRole {
type: EventType.UPDATE_USER_WORKSPACE_ROLE;
type: EventType.UPDATE_USER_PROJECT_ROLE;
metadata: {
userId: string;
email: string;
@@ -1723,7 +1765,7 @@ interface UpdateUserRole {
}
interface UpdateUserDeniedPermissions {
type: EventType.UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS;
type: EventType.UPDATE_USER_PROJECT_DENIED_PERMISSIONS;
metadata: {
userId: string;
email: string;
@@ -2781,14 +2823,31 @@ interface GetAppConnectionEvent {
};
}
interface GetAppConnectionUsageEvent {
type: EventType.GET_APP_CONNECTION_USAGE;
metadata: {
connectionId: string;
};
}
interface MigrateAppConnectionEvent {
type: EventType.MIGRATE_APP_CONNECTION;
metadata: {
connectionId: string;
};
}
interface CreateAppConnectionEvent {
type: EventType.CREATE_APP_CONNECTION;
metadata: Omit<TCreateAppConnectionDTO, "credentials"> & { connectionId: string };
metadata: Omit<TCreateAppConnectionDTO, "credentials" | "projectId"> & { connectionId: string };
}
interface UpdateAppConnectionEvent {
type: EventType.UPDATE_APP_CONNECTION;
metadata: Omit<TUpdateAppConnectionDTO, "credentials"> & { connectionId: string; credentialsUpdated: boolean };
metadata: Omit<TUpdateAppConnectionDTO, "credentials" | "projectId"> & {
connectionId: string;
credentialsUpdated: boolean;
};
}
interface DeleteAppConnectionEvent {
@@ -2908,6 +2967,77 @@ interface SecretSyncRemoveSecretsEvent {
};
}
interface GetPkiSyncsEvent {
type: EventType.GET_PKI_SYNCS;
metadata: {
projectId: string;
};
}
interface GetPkiSyncEvent {
type: EventType.GET_PKI_SYNC;
metadata: {
destination: string;
syncId: string;
};
}
interface CreatePkiSyncEvent {
type: EventType.CREATE_PKI_SYNC;
metadata: {
pkiSyncId: string;
name: string;
destination: string;
};
}
interface UpdatePkiSyncEvent {
type: EventType.UPDATE_PKI_SYNC;
metadata: {
pkiSyncId: string;
name: string;
};
}
interface DeletePkiSyncEvent {
type: EventType.DELETE_PKI_SYNC;
metadata: {
pkiSyncId: string;
name: string;
destination: string;
};
}
interface PkiSyncSyncCertificatesEvent {
type: EventType.PKI_SYNC_SYNC_CERTIFICATES;
metadata: {
syncId: string;
syncMessage: string | null;
jobId: string;
jobRanAt: Date;
};
}
interface PkiSyncImportCertificatesEvent {
type: EventType.PKI_SYNC_IMPORT_CERTIFICATES;
metadata: {
syncId: string;
importMessage: string | null;
jobId: string;
jobRanAt: Date;
};
}
interface PkiSyncRemoveCertificatesEvent {
type: EventType.PKI_SYNC_REMOVE_CERTIFICATES;
metadata: {
syncId: string;
removeMessage: string | null;
jobId: string;
jobRanAt: Date;
};
}
interface OidcGroupMembershipMappingAssignUserEvent {
type: EventType.OIDC_GROUP_MEMBERSHIP_MAPPING_ASSIGN_USER;
metadata: {
@@ -3467,6 +3597,96 @@ interface ProjectDeleteEvent {
};
}
interface DashboardListSecretsEvent {
type: EventType.DASHBOARD_LIST_SECRETS;
metadata: {
environment: string;
secretPath: string;
numberOfSecrets: number;
secretIds: string[];
};
}
interface DashboardGetSecretValueEvent {
type: EventType.DASHBOARD_GET_SECRET_VALUE;
metadata: {
secretId: string;
secretKey: string;
environment: string;
secretPath: string;
};
}
interface DashboardGetSecretVersionValueEvent {
type: EventType.DASHBOARD_GET_SECRET_VERSION_VALUE;
metadata: {
secretId: string;
version: string;
};
}
interface ProjectRoleCreateEvent {
type: EventType.CREATE_PROJECT_ROLE;
metadata: {
roleId: string;
slug: string;
name: string;
description?: string | null;
permissions: string;
};
}
interface ProjectRoleUpdateEvent {
type: EventType.UPDATE_PROJECT_ROLE;
metadata: {
roleId: string;
slug?: string;
name?: string;
description?: string | null;
permissions?: string;
};
}
interface ProjectRoleDeleteEvent {
type: EventType.DELETE_PROJECT_ROLE;
metadata: {
roleId: string;
slug: string;
name: string;
};
}
interface OrgRoleCreateEvent {
type: EventType.CREATE_ORG_ROLE;
metadata: {
roleId: string;
slug: string;
name: string;
description?: string | null;
permissions: string;
};
}
interface OrgRoleUpdateEvent {
type: EventType.UPDATE_ORG_ROLE;
metadata: {
roleId: string;
slug?: string;
name?: string;
description?: string | null;
permissions?: string;
};
}
interface OrgRoleDeleteEvent {
type: EventType.DELETE_ORG_ROLE;
metadata: {
roleId: string;
slug: string;
name: string;
};
}
export type Event =
| GetSecretsEvent
| GetSecretEvent
@@ -3477,7 +3697,7 @@ export type Event =
| MoveSecretsEvent
| DeleteSecretEvent
| DeleteSecretBatchEvent
| GetWorkspaceKeyEvent
| GetProjectKeyEvent
| AuthorizeIntegrationEvent
| UpdateIntegrationAuthEvent
| UnauthorizeIntegrationEvent
@@ -3562,13 +3782,14 @@ export type Event =
| UpdateIdentityLdapAuthEvent
| GetIdentityLdapAuthEvent
| RevokeIdentityLdapAuthEvent
| ClearIdentityLdapAuthLockoutsEvent
| CreateEnvironmentEvent
| GetEnvironmentEvent
| UpdateEnvironmentEvent
| DeleteEnvironmentEvent
| AddWorkspaceMemberEvent
| AddBatchWorkspaceMemberEvent
| RemoveWorkspaceMemberEvent
| AddProjectMemberEvent
| AddBatchProjectMemberEvent
| RemoveProjectMemberEvent
| CreateFolderEvent
| UpdateFolderEvent
| DeleteFolderEvent
@@ -3697,6 +3918,8 @@ export type Event =
| CreateAppConnectionEvent
| UpdateAppConnectionEvent
| DeleteAppConnectionEvent
| GetAppConnectionUsageEvent
| MigrateAppConnectionEvent
| GetSshHostGroupEvent
| CreateSshHostGroupEvent
| UpdateSshHostGroupEvent
@@ -3715,6 +3938,14 @@ export type Event =
| SecretSyncSyncSecretsEvent
| SecretSyncImportSecretsEvent
| SecretSyncRemoveSecretsEvent
| GetPkiSyncsEvent
| GetPkiSyncEvent
| CreatePkiSyncEvent
| UpdatePkiSyncEvent
| DeletePkiSyncEvent
| PkiSyncSyncCertificatesEvent
| PkiSyncImportCertificatesEvent
| PkiSyncRemoveCertificatesEvent
| OidcGroupMembershipMappingAssignUserEvent
| OidcGroupMembershipMappingRemoveUserEvent
| CreateKmipClientEvent
@@ -3780,4 +4011,13 @@ export type Event =
| ProjectDeleteEvent
| SecretReminderCreateEvent
| SecretReminderGetEvent
| SecretReminderDeleteEvent;
| SecretReminderDeleteEvent
| DashboardListSecretsEvent
| DashboardGetSecretValueEvent
| DashboardGetSecretVersionValueEvent
| ProjectRoleCreateEvent
| ProjectRoleUpdateEvent
| ProjectRoleDeleteEvent
| OrgRoleCreateEvent
| OrgRoleUpdateEvent
| OrgRoleDeleteEvent;
@@ -16,7 +16,7 @@ import {
PutUserPolicyCommand,
RemoveUserFromGroupCommand
} from "@aws-sdk/client-iam";
import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts";
import { AssumeRoleCommand, GetSessionTokenCommand, STSClient } from "@aws-sdk/client-sts";
import { z } from "zod";
import { CustomAWSHasher } from "@app/lib/aws/hashing";
@@ -26,9 +26,12 @@ import { BadRequestError, UnauthorizedError } from "@app/lib/errors";
import { sanitizeString } from "@app/lib/fn";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { AwsIamAuthType, DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models";
import { AwsIamAuthType, AwsIamCredentialType, DynamicSecretAwsIamSchema, TDynamicProviderFns } from "./models";
import { compileUsernameTemplate } from "./templateUtils";
// AWS STS duration constants (in seconds)
const AWS_STS_MIN_DURATION = 900;
const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => {
const randomUsername = alphaNumericNanoId(32);
if (!usernameTemplate) return randomUsername;
@@ -120,6 +123,58 @@ export const AwsIamProvider = (): TDynamicProviderFns => {
const validateConnection = async (inputs: unknown, { projectId }: { projectId: string }) => {
const providerInputs = await validateProviderInputs(inputs);
try {
if (providerInputs.credentialType === AwsIamCredentialType.TemporaryCredentials) {
if (providerInputs.method === AwsIamAuthType.AccessKey) {
const stsClient = new STSClient({
region: providerInputs.region,
useFipsEndpoint: crypto.isFipsModeEnabled(),
sha256: CustomAWSHasher,
credentials: {
accessKeyId: providerInputs.accessKey,
secretAccessKey: providerInputs.secretAccessKey
}
});
await stsClient.send(new GetSessionTokenCommand({ DurationSeconds: AWS_STS_MIN_DURATION }));
return true;
}
if (providerInputs.method === AwsIamAuthType.AssumeRole) {
const appCfg = getConfig();
const stsClient = new STSClient({
region: providerInputs.region,
useFipsEndpoint: crypto.isFipsModeEnabled(),
sha256: CustomAWSHasher,
credentials:
appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID && appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY
? {
accessKeyId: appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID,
secretAccessKey: appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY
}
: undefined
});
await stsClient.send(
new AssumeRoleCommand({
RoleArn: providerInputs.roleArn,
RoleSessionName: `infisical-validation-${crypto.nativeCrypto.randomUUID()}`,
DurationSeconds: AWS_STS_MIN_DURATION,
ExternalId: projectId
})
);
return true;
}
if (providerInputs.method === AwsIamAuthType.IRSA) {
const stsClient = new STSClient({
region: providerInputs.region,
useFipsEndpoint: crypto.isFipsModeEnabled(),
sha256: CustomAWSHasher
});
await stsClient.send(new GetSessionTokenCommand({ DurationSeconds: AWS_STS_MIN_DURATION }));
return true;
}
}
const client = await $getClient(providerInputs, projectId);
const isConnected = await client
.send(new GetUserCommand({}))
@@ -137,7 +192,7 @@ export const AwsIamProvider = (): TDynamicProviderFns => {
});
return isConnected;
} catch (err) {
const sensitiveTokens = [];
const sensitiveTokens: string[] = [];
if (providerInputs.method === AwsIamAuthType.AccessKey) {
sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey);
}
@@ -163,102 +218,269 @@ export const AwsIamProvider = (): TDynamicProviderFns => {
};
metadata: { projectId: string };
}) => {
const { inputs, usernameTemplate, metadata, identity } = data;
const { inputs, usernameTemplate, metadata, identity, expireAt } = data;
const providerInputs = await validateProviderInputs(inputs);
const client = await $getClient(providerInputs, metadata.projectId);
const username = generateUsername(usernameTemplate, identity);
const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs;
const awsTags = [{ Key: "createdBy", Value: "infisical-dynamic-secret" }];
if (providerInputs.credentialType === AwsIamCredentialType.TemporaryCredentials) {
try {
let stsClient: STSClient;
let entityId: string;
if (providerInputs.tags && Array.isArray(providerInputs.tags)) {
const additionalTags = providerInputs.tags.map((tag) => ({
Key: tag.key,
Value: tag.value
}));
awsTags.push(...additionalTags);
const currentTime = Date.now();
const requestedDuration = Math.floor((expireAt - currentTime) / 1000);
if (requestedDuration <= 0) {
throw new BadRequestError({ message: "Expiration time must be in the future" });
}
let durationSeconds: number;
if (providerInputs.method === AwsIamAuthType.AssumeRole) {
durationSeconds = requestedDuration;
const appCfg = getConfig();
stsClient = new STSClient({
region: providerInputs.region,
useFipsEndpoint: crypto.isFipsModeEnabled(),
sha256: CustomAWSHasher,
credentials:
appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID && appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY
? {
accessKeyId: appCfg.DYNAMIC_SECRET_AWS_ACCESS_KEY_ID,
secretAccessKey: appCfg.DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY
}
: undefined
});
const assumeRoleRes = await stsClient.send(
new AssumeRoleCommand({
RoleArn: providerInputs.roleArn,
RoleSessionName: `infisical-temp-cred-${crypto.nativeCrypto.randomUUID()}`,
DurationSeconds: durationSeconds,
ExternalId: metadata.projectId
})
);
if (
!assumeRoleRes.Credentials?.AccessKeyId ||
!assumeRoleRes.Credentials?.SecretAccessKey ||
!assumeRoleRes.Credentials?.SessionToken
) {
throw new BadRequestError({ message: "Failed to assume role - verify credentials and role configuration" });
}
entityId = `assume-role-${alphaNumericNanoId(8)}`;
return {
entityId,
data: {
ACCESS_KEY: assumeRoleRes.Credentials.AccessKeyId,
SECRET_ACCESS_KEY: assumeRoleRes.Credentials.SecretAccessKey,
SESSION_TOKEN: assumeRoleRes.Credentials.SessionToken
}
};
}
if (providerInputs.method === AwsIamAuthType.AccessKey) {
durationSeconds = requestedDuration;
stsClient = new STSClient({
region: providerInputs.region,
useFipsEndpoint: crypto.isFipsModeEnabled(),
sha256: CustomAWSHasher,
credentials: {
accessKeyId: providerInputs.accessKey,
secretAccessKey: providerInputs.secretAccessKey
}
});
const sessionTokenRes = await stsClient.send(
new GetSessionTokenCommand({
DurationSeconds: durationSeconds
})
);
if (
!sessionTokenRes.Credentials?.AccessKeyId ||
!sessionTokenRes.Credentials?.SecretAccessKey ||
!sessionTokenRes.Credentials?.SessionToken
) {
throw new BadRequestError({ message: "Failed to get session token - verify credentials and permissions" });
}
entityId = `session-token-${alphaNumericNanoId(8)}`;
return {
entityId,
data: {
ACCESS_KEY: sessionTokenRes.Credentials.AccessKeyId,
SECRET_ACCESS_KEY: sessionTokenRes.Credentials.SecretAccessKey,
SESSION_TOKEN: sessionTokenRes.Credentials.SessionToken
}
};
}
if (providerInputs.method === AwsIamAuthType.IRSA) {
durationSeconds = requestedDuration;
stsClient = new STSClient({
region: providerInputs.region,
useFipsEndpoint: crypto.isFipsModeEnabled(),
sha256: CustomAWSHasher
});
const sessionTokenRes = await stsClient.send(
new GetSessionTokenCommand({
DurationSeconds: durationSeconds
})
);
if (
!sessionTokenRes.Credentials?.AccessKeyId ||
!sessionTokenRes.Credentials?.SecretAccessKey ||
!sessionTokenRes.Credentials?.SessionToken
) {
throw new BadRequestError({
message: "Failed to get session token - verify IRSA credentials and permissions"
});
}
entityId = `irsa-session-${alphaNumericNanoId(8)}`;
return {
entityId,
data: {
ACCESS_KEY: sessionTokenRes.Credentials.AccessKeyId,
SECRET_ACCESS_KEY: sessionTokenRes.Credentials.SecretAccessKey,
SESSION_TOKEN: sessionTokenRes.Credentials.SessionToken
}
};
}
throw new BadRequestError({ message: "Unsupported authentication method for temporary credentials" });
} catch (err) {
const sensitiveTokens: string[] = [];
if (providerInputs.method === AwsIamAuthType.AccessKey) {
sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey);
}
if (providerInputs.method === AwsIamAuthType.AssumeRole) {
sensitiveTokens.push(providerInputs.roleArn);
}
let errorMessage = (err as Error)?.message || "Unknown error";
if (err && typeof err === "object" && "name" in err && "$metadata" in err) {
const awsError = err as { name?: string; message?: string; $metadata?: object };
if (awsError.name) {
errorMessage = `${awsError.name}: ${errorMessage}`;
}
}
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: errorMessage,
tokens: sensitiveTokens
});
throw new BadRequestError({
message: `Failed to create temporary credentials: ${sanitizedErrorMessage}`
});
}
}
try {
const createUserRes = await client.send(
new CreateUserCommand({
Path: awsPath,
PermissionsBoundary: permissionBoundaryPolicyArn || undefined,
Tags: awsTags,
UserName: username
})
);
if (providerInputs.credentialType === AwsIamCredentialType.IamUser) {
const client = await $getClient(providerInputs, metadata.projectId);
if (!createUserRes.User) throw new BadRequestError({ message: "Failed to create AWS IAM User" });
if (userGroups) {
await Promise.all(
userGroups
.split(",")
.filter(Boolean)
.map((group) =>
client.send(new AddUserToGroupCommand({ UserName: createUserRes?.User?.UserName, GroupName: group }))
)
);
const username = generateUsername(usernameTemplate, identity);
const { policyArns, userGroups, policyDocument, awsPath, permissionBoundaryPolicyArn } = providerInputs;
const awsTags = [{ Key: "createdBy", Value: "infisical-dynamic-secret" }];
if (providerInputs.tags && Array.isArray(providerInputs.tags)) {
const additionalTags = providerInputs.tags.map((tag) => ({
Key: tag.key,
Value: tag.value
}));
awsTags.push(...additionalTags);
}
if (policyArns) {
await Promise.all(
policyArns
.split(",")
.filter(Boolean)
.map((policyArn) =>
client.send(
new AttachUserPolicyCommand({ UserName: createUserRes?.User?.UserName, PolicyArn: policyArn })
)
)
);
}
if (policyDocument) {
await client.send(
new PutUserPolicyCommand({
UserName: createUserRes.User.UserName,
PolicyName: `infisical-dynamic-policy-${alphaNumericNanoId(4)}`,
PolicyDocument: policyDocument
try {
const createUserRes = await client.send(
new CreateUserCommand({
Path: awsPath,
PermissionsBoundary: permissionBoundaryPolicyArn || undefined,
Tags: awsTags,
UserName: username
})
);
}
const createAccessKeyRes = await client.send(
new CreateAccessKeyCommand({
UserName: createUserRes.User.UserName
})
);
if (!createAccessKeyRes.AccessKey)
throw new BadRequestError({ message: "Failed to create AWS IAM User access key" });
return {
entityId: username,
data: {
ACCESS_KEY: createAccessKeyRes.AccessKey.AccessKeyId,
SECRET_ACCESS_KEY: createAccessKeyRes.AccessKey.SecretAccessKey,
USERNAME: username
if (!createUserRes.User) throw new BadRequestError({ message: "Failed to create AWS IAM User" });
if (userGroups) {
await Promise.all(
userGroups
.split(",")
.filter(Boolean)
.map((group) =>
client.send(new AddUserToGroupCommand({ UserName: createUserRes?.User?.UserName, GroupName: group }))
)
);
}
};
} catch (err) {
const sensitiveTokens = [username];
if (providerInputs.method === AwsIamAuthType.AccessKey) {
sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey);
if (policyArns) {
await Promise.all(
policyArns
.split(",")
.filter(Boolean)
.map((policyArn) =>
client.send(
new AttachUserPolicyCommand({ UserName: createUserRes?.User?.UserName, PolicyArn: policyArn })
)
)
);
}
if (policyDocument) {
await client.send(
new PutUserPolicyCommand({
UserName: createUserRes.User.UserName,
PolicyName: `infisical-dynamic-policy-${alphaNumericNanoId(4)}`,
PolicyDocument: policyDocument
})
);
}
const createAccessKeyRes = await client.send(
new CreateAccessKeyCommand({
UserName: createUserRes.User.UserName
})
);
if (!createAccessKeyRes.AccessKey)
throw new BadRequestError({ message: "Failed to create AWS IAM User access key" });
return {
entityId: username,
data: {
ACCESS_KEY: createAccessKeyRes.AccessKey.AccessKeyId,
SECRET_ACCESS_KEY: createAccessKeyRes.AccessKey.SecretAccessKey,
USERNAME: username
}
};
} catch (err) {
const sensitiveTokens = [username];
if (providerInputs.method === AwsIamAuthType.AccessKey) {
sensitiveTokens.push(providerInputs.accessKey, providerInputs.secretAccessKey);
}
if (providerInputs.method === AwsIamAuthType.AssumeRole) {
sensitiveTokens.push(providerInputs.roleArn);
}
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: (err as Error)?.message,
tokens: sensitiveTokens
});
throw new BadRequestError({
message: `Failed to create lease from provider: ${sanitizedErrorMessage}`
});
}
if (providerInputs.method === AwsIamAuthType.AssumeRole) {
sensitiveTokens.push(providerInputs.roleArn);
}
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: (err as Error)?.message,
tokens: sensitiveTokens
});
throw new BadRequestError({
message: `Failed to create lease from provider: ${sanitizedErrorMessage}`
});
}
throw new BadRequestError({ message: "Invalid credential type specified" });
};
const revoke = async (inputs: unknown, entityId: string, metadata: { projectId: string }) => {
const providerInputs = await validateProviderInputs(inputs);
if (providerInputs.credentialType === AwsIamCredentialType.TemporaryCredentials) {
return { entityId };
}
const client = await $getClient(providerInputs, metadata.projectId);
const username = entityId;
@@ -0,0 +1,541 @@
import handlebars from "handlebars";
import knex from "knex";
import RE2 from "re2";
import { z } from "zod";
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError } from "@app/lib/errors";
import { sanitizeString } from "@app/lib/fn";
import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars";
import { TGatewayServiceFactory } from "../../gateway/gateway-service";
import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service";
import { verifyHostInputValidity } from "../dynamic-secret-fns";
import { DynamicSecretAzureSqlDBSchema, PasswordRequirements, SqlProviders, TDynamicProviderFns } from "./models";
import { compileUsernameTemplate } from "./templateUtils";
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
const DEFAULT_PASSWORD_REQUIREMENTS = {
length: 48,
required: {
lowercase: 1,
uppercase: 1,
digits: 1,
symbols: 0
},
allowedSymbols: "-_.~!*"
};
const generatePassword = (requirements?: PasswordRequirements) => {
const finalReqs = requirements || DEFAULT_PASSWORD_REQUIREMENTS;
try {
const { length, required, allowedSymbols } = finalReqs;
const chars = {
lowercase: "abcdefghijklmnopqrstuvwxyz",
uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
digits: "0123456789",
symbols: allowedSymbols || "-_.~!*"
};
const parts: string[] = [];
if (required.lowercase > 0) {
parts.push(
...Array(required.lowercase)
.fill(0)
.map(() => chars.lowercase[crypto.randomInt(chars.lowercase.length)])
);
}
if (required.uppercase > 0) {
parts.push(
...Array(required.uppercase)
.fill(0)
.map(() => chars.uppercase[crypto.randomInt(chars.uppercase.length)])
);
}
if (required.digits > 0) {
parts.push(
...Array(required.digits)
.fill(0)
.map(() => chars.digits[crypto.randomInt(chars.digits.length)])
);
}
if (required.symbols > 0) {
parts.push(
...Array(required.symbols)
.fill(0)
.map(() => chars.symbols[crypto.randomInt(chars.symbols.length)])
);
}
const requiredTotal = Object.values(required).reduce<number>((a, b) => a + b, 0);
const remainingLength = Math.max(length - requiredTotal, 0);
const allowedChars = Object.entries(chars)
.filter(([key]) => required[key as keyof typeof required] > 0)
.map(([, value]) => value)
.join("");
parts.push(
...Array(remainingLength)
.fill(0)
.map(() => allowedChars[crypto.randomInt(allowedChars.length)])
);
// shuffle the array to mix up the characters
for (let i = parts.length - 1; i > 0; i -= 1) {
const j = crypto.randomInt(i + 1);
[parts[i], parts[j]] = [parts[j], parts[i]];
}
return parts.join("");
} catch (error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
throw new Error(`Failed to generate password: ${message}`);
}
};
const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => {
const randomUsername = alphaNumericNanoId(32);
if (!usernameTemplate) return randomUsername;
return compileUsernameTemplate({
usernameTemplate,
randomUsername,
identity
});
};
type TAzureSqlDatabaseProviderDTO = {
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">;
};
export const AzureSqlDatabaseProvider = ({
gatewayService,
gatewayV2Service
}: TAzureSqlDatabaseProviderDTO): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretAzureSqlDBSchema.parseAsync(inputs);
const [hostIp] = await verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.gatewayId));
validateHandlebarTemplate("Azure SQL master creation", providerInputs.masterCreationStatement, {
allowedExpressions: (val) => ["username", "password", "expiration", "database"].includes(val)
});
validateHandlebarTemplate("Azure SQL creation", providerInputs.creationStatement, {
allowedExpressions: (val) => ["username", "password", "expiration", "database"].includes(val)
});
if (providerInputs.renewStatement) {
validateHandlebarTemplate("Azure SQL renew", providerInputs.renewStatement, {
allowedExpressions: (val) => ["username", "expiration", "database"].includes(val)
});
}
validateHandlebarTemplate("Azure SQL revoke", providerInputs.revocationStatement, {
allowedExpressions: (val) => ["username", "database"].includes(val)
});
return { ...providerInputs, hostIp };
};
const $getClient = async (
providerInputs: z.infer<typeof DynamicSecretAzureSqlDBSchema> & { hostIp: string; originalHost: string },
targetDatabase?: string
) => {
const ssl = providerInputs.ca
? { rejectUnauthorized: false, ca: providerInputs.ca, servername: providerInputs.host }
: undefined;
/*
We route through the gateway by setting connection.host = "localhost".
Azure SQL identifies the logical server from the TDS login name when the host
isn't the Azure FQDN. Therefore, when using the gateway, ensure username is
"user@<azure-server-name>" so Azure opens the correct logical server.
Direct connections to the Azure FQDN usually don't require this suffix.
*/
const isAzureSql = new RE2(/\.database\.windows\.net$/i).test(providerInputs.originalHost);
const azureServerLabel =
isAzureSql && providerInputs.gatewayId ? providerInputs.originalHost?.split(".")[0] : undefined;
const effectiveUser =
isAzureSql && !providerInputs.username.includes("@") && azureServerLabel
? `${providerInputs.username}@${azureServerLabel}`
: providerInputs.username;
const db = knex({
client: SqlProviders.MsSQL,
connection: {
database: targetDatabase || providerInputs.database,
port: providerInputs.port,
host: providerInputs.host,
user: effectiveUser,
password: providerInputs.password,
ssl,
// @ts-expect-error this is because of knexjs type signature issue. This is directly passed to driver
// https://github.com/knex/knex/blob/b6507a7129d2b9fafebf5f831494431e64c6a8a0/lib/dialects/mssql/index.js#L66
// https://github.com/tediousjs/tedious/blob/ebb023ed90969a7ec0e4b036533ad52739d921f7/test/config.ci.ts#L19
options: {
...(providerInputs.sslEnabled !== undefined ? { encrypt: providerInputs.sslEnabled } : {}),
trustServerCertificate: !providerInputs.ca,
cryptoCredentialsDetails: providerInputs.ca ? { ca: providerInputs.ca } : {}
}
},
acquireConnectionTimeout: EXTERNAL_REQUEST_TIMEOUT,
pool: { min: 0, max: 7 }
});
return db;
};
const gatewayProxyWrapper = async (
providerInputs: z.infer<typeof DynamicSecretAzureSqlDBSchema>,
gatewayCallback: (host: string, port: number) => Promise<void>
) => {
const gatewayV2ConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({
gatewayId: providerInputs.gatewayId as string,
targetHost: providerInputs.host,
targetPort: providerInputs.port
});
if (gatewayV2ConnectionDetails) {
return withGatewayV2Proxy(
async (port) => {
await gatewayCallback("localhost", port);
},
{
relayHost: gatewayV2ConnectionDetails.relayHost,
gateway: gatewayV2ConnectionDetails.gateway,
relay: gatewayV2ConnectionDetails.relay,
protocol: GatewayProxyProtocol.Tcp
}
);
}
const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(providerInputs.gatewayId as string);
const [relayHost, relayPort] = relayDetails.relayAddress.split(":");
await withGatewayProxy(
async (port) => {
await gatewayCallback("localhost", port);
},
{
protocol: GatewayProxyProtocol.Tcp,
targetHost: providerInputs.host,
targetPort: providerInputs.port,
relayHost,
relayPort: Number(relayPort),
identityId: relayDetails.identityId,
orgId: relayDetails.orgId,
tlsOptions: {
ca: relayDetails.certChain,
cert: relayDetails.certificate,
key: relayDetails.privateKey.toString()
}
}
);
};
const validateConnection = async (inputs: unknown) => {
const providerInputs = await validateProviderInputs(inputs);
let isConnected = false;
const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => {
const db = await $getClient({
...providerInputs,
port,
host,
hostIp: providerInputs.hostIp,
originalHost: providerInputs.host
});
try {
isConnected = await db.raw("SELECT 1").then(() => true);
} catch (err) {
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: (err as Error)?.message,
tokens: [providerInputs.username]
});
throw new BadRequestError({
message: `Failed to connect with provider: ${sanitizedErrorMessage}`
});
} finally {
await db.destroy();
}
};
if (providerInputs.gatewayId) {
await gatewayProxyWrapper(providerInputs, gatewayCallback);
} else {
await gatewayCallback();
}
return isConnected;
};
const create = async (data: {
inputs: unknown;
expireAt: number;
usernameTemplate?: string | null;
identity?: { name: string };
}) => {
const { inputs, expireAt, usernameTemplate, identity } = data;
const providerInputs = await validateProviderInputs(inputs);
const { database, masterDatabase } = providerInputs;
const username = generateUsername(usernameTemplate, identity);
const password = generatePassword(providerInputs.passwordRequirements);
const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => {
const expiration = new Date(expireAt).toISOString();
const masterDb = await $getClient(
{
...providerInputs,
port,
host,
originalHost: providerInputs.host
},
masterDatabase
);
try {
const masterCreationStatement = handlebars.compile(providerInputs.masterCreationStatement, { noEscape: true })({
username,
password,
expiration,
database
});
const masterQueries = masterCreationStatement.toString().split(";").filter(Boolean);
await masterDb.transaction(async (tx) => {
for (const query of masterQueries) {
// eslint-disable-next-line
await tx.raw(query);
}
});
} catch (err) {
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: (err as Error)?.message,
tokens: [username, password, database]
});
throw new BadRequestError({
message: `Failed to create login in master database: ${sanitizedErrorMessage}`
});
} finally {
await masterDb.destroy();
}
const targetDb = await $getClient({
...providerInputs,
port,
host,
originalHost: providerInputs.host
});
try {
const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({
username,
password,
expiration,
database
});
const queries = creationStatement.toString().split(";").filter(Boolean);
await targetDb.transaction(async (tx) => {
for (const query of queries) {
// eslint-disable-next-line
await tx.raw(query);
}
});
} catch (err) {
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: (err as Error)?.message,
tokens: [username, password, database]
});
throw new BadRequestError({
message: `Failed to create user in target database: ${sanitizedErrorMessage}`
});
} finally {
await targetDb.destroy();
}
};
if (providerInputs.gatewayId) {
await gatewayProxyWrapper(providerInputs, gatewayCallback);
} else {
await gatewayCallback();
}
return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } };
};
const revoke = async (inputs: unknown, entityId: string) => {
const providerInputs = await validateProviderInputs(inputs);
const username = entityId;
const { database, masterDatabase } = providerInputs;
const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => {
const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, database });
const queries = revokeStatement.toString().split(";").filter(Boolean);
const userDropQueries = queries.filter((query) => query.toLowerCase().includes("drop user"));
const loginDropQueries = queries.filter((query) => query.toLowerCase().includes("drop login"));
if (userDropQueries.length > 0) {
const targetDb = await $getClient({
...providerInputs,
port,
host,
originalHost: providerInputs.host
});
try {
await targetDb.transaction(async (tx) => {
for (const query of userDropQueries) {
// eslint-disable-next-line
await tx.raw(query.trim());
}
});
} catch (err) {
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: (err as Error)?.message,
tokens: [username, database]
});
throw new BadRequestError({
message: `Failed to drop user from target database: ${sanitizedErrorMessage}`
});
} finally {
await targetDb.destroy();
}
}
if (loginDropQueries.length > 0) {
const masterDb = await $getClient(
{
...providerInputs,
port,
host,
originalHost: providerInputs.host
},
masterDatabase
);
try {
await masterDb.transaction(async (tx) => {
for (const query of loginDropQueries) {
// eslint-disable-next-line
await tx.raw(query.trim());
}
});
} catch (err) {
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: (err as Error)?.message,
tokens: [username, database]
});
throw new BadRequestError({
message: `Failed to drop login from master database: ${sanitizedErrorMessage}`
});
} finally {
await masterDb.destroy();
}
}
const otherQueries = queries.filter(
(query) => !query.toLowerCase().includes("drop user") && !query.toLowerCase().includes("drop login")
);
if (otherQueries.length > 0) {
const targetDb = await $getClient({
...providerInputs,
port,
host,
originalHost: providerInputs.host
});
try {
await targetDb.transaction(async (tx) => {
for (const query of otherQueries) {
// eslint-disable-next-line
await tx.raw(query.trim());
}
});
} catch (err) {
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: (err as Error)?.message,
tokens: [username, database]
});
throw new BadRequestError({
message: `Failed to execute revocation statement: ${sanitizedErrorMessage}`
});
} finally {
await targetDb.destroy();
}
}
};
if (providerInputs.gatewayId) {
await gatewayProxyWrapper(providerInputs, gatewayCallback);
} else {
await gatewayCallback();
}
return { entityId: username };
};
const renew = async (inputs: unknown, entityId: string, expireAt: number) => {
const providerInputs = await validateProviderInputs(inputs);
if (!providerInputs.renewStatement) return { entityId };
const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => {
const db = await $getClient({
...providerInputs,
port,
host,
originalHost: providerInputs.host
});
const expiration = new Date(expireAt).toISOString();
const { database } = providerInputs;
const renewStatement = handlebars.compile(providerInputs.renewStatement)({
username: entityId,
expiration,
database
});
try {
if (renewStatement) {
const queries = renewStatement.toString().split(";").filter(Boolean);
await db.transaction(async (tx) => {
for (const query of queries) {
// eslint-disable-next-line
await tx.raw(query);
}
});
}
} catch (err) {
const sanitizedErrorMessage = sanitizeString({
unsanitizedString: (err as Error)?.message,
tokens: [database]
});
throw new BadRequestError({
message: `Failed to renew lease from provider: ${sanitizedErrorMessage}`
});
} finally {
await db.destroy();
}
};
if (providerInputs.gatewayId) {
await gatewayProxyWrapper(providerInputs, gatewayCallback);
} else {
await gatewayCallback();
}
return { entityId };
};
return {
validateProviderInputs,
validateConnection,
create,
revoke,
renew
};
};
@@ -5,6 +5,7 @@ import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service";
import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache";
import { AwsIamProvider } from "./aws-iam";
import { AzureEntraIDProvider } from "./azure-entra-id";
import { AzureSqlDatabaseProvider } from "./azure-sql-database";
import { CassandraProvider } from "./cassandra";
import { CouchbaseProvider } from "./couchbase";
import { ElasticSearchProvider } from "./elastic-search";
@@ -42,6 +43,7 @@ export const buildDynamicSecretProviders = ({
[DynamicSecretProviders.ElasticSearch]: ElasticSearchProvider(),
[DynamicSecretProviders.RabbitMq]: RabbitMqProvider(),
[DynamicSecretProviders.AzureEntraID]: AzureEntraIDProvider(),
[DynamicSecretProviders.AzureSqlDatabase]: AzureSqlDatabaseProvider({ gatewayService, gatewayV2Service }),
[DynamicSecretProviders.Ldap]: LdapProvider(),
[DynamicSecretProviders.SapHana]: SapHanaProvider(),
[DynamicSecretProviders.Snowflake]: SnowflakeProvider(),
@@ -32,6 +32,11 @@ export enum AwsIamAuthType {
IRSA = "irsa"
}
export enum AwsIamCredentialType {
IamUser = "iam-user",
TemporaryCredentials = "temporary-credentials"
}
export enum ElasticSearchAuthTypes {
User = "user",
ApiKey = "api-key"
@@ -203,6 +208,7 @@ export const DynamicSecretAwsIamSchema = z.preprocess(
z.discriminatedUnion("method", [
z.object({
method: z.literal(AwsIamAuthType.AccessKey),
credentialType: z.nativeEnum(AwsIamCredentialType).default(AwsIamCredentialType.IamUser),
accessKey: z.string().trim().min(1),
secretAccessKey: z.string().trim().min(1),
region: z.string().trim().min(1),
@@ -215,6 +221,7 @@ export const DynamicSecretAwsIamSchema = z.preprocess(
}),
z.object({
method: z.literal(AwsIamAuthType.AssumeRole),
credentialType: z.nativeEnum(AwsIamCredentialType).default(AwsIamCredentialType.IamUser),
roleArn: z.string().trim().min(1, "Role ARN required"),
region: z.string().trim().min(1),
awsPath: z.string().trim().optional(),
@@ -226,6 +233,7 @@ export const DynamicSecretAwsIamSchema = z.preprocess(
}),
z.object({
method: z.literal(AwsIamAuthType.IRSA),
credentialType: z.nativeEnum(AwsIamCredentialType).default(AwsIamCredentialType.IamUser),
region: z.string().trim().min(1),
awsPath: z.string().trim().optional(),
permissionBoundaryPolicyArn: z.string().trim().optional(),
@@ -319,6 +327,44 @@ export const AzureEntraIDSchema = z.object({
clientSecret: z.string().trim().min(1)
});
export const DynamicSecretAzureSqlDBSchema = z.object({
host: z.string().trim().toLowerCase(),
port: z.number(),
database: z.string().trim(),
masterDatabase: z.string().trim().optional().default("master"),
username: z.string().trim(),
password: z.string().trim(),
passwordRequirements: z
.object({
length: z.number().min(1).max(250),
required: z
.object({
lowercase: z.number().min(0),
uppercase: z.number().min(0),
digits: z.number().min(0),
symbols: z.number().min(0)
})
.refine((data) => {
const total = Object.values(data).reduce((sum, count) => sum + count, 0);
return total <= 250;
}, "Sum of required characters cannot exceed 250"),
allowedSymbols: z.string().optional()
})
.refine((data) => {
const total = Object.values(data.required).reduce((sum, count) => sum + count, 0);
return total <= data.length;
}, "Sum of required characters cannot exceed the total length")
.optional()
.describe("Password generation requirements"),
masterCreationStatement: z.string().trim(),
creationStatement: z.string().trim(),
revocationStatement: z.string().trim(),
renewStatement: z.string().trim().optional(),
ca: z.string().optional(),
sslEnabled: z.boolean().optional(),
gatewayId: z.string().nullable().optional()
});
export const LdapSchema = z.union([
z.object({
url: z.string().trim().min(1),
@@ -602,6 +648,7 @@ export enum DynamicSecretProviders {
MongoDB = "mongo-db",
RabbitMq = "rabbit-mq",
AzureEntraID = "azure-entra-id",
AzureSqlDatabase = "azure-sql-database",
Ldap = "ldap",
SapHana = "sap-hana",
Snowflake = "snowflake",
@@ -627,6 +674,7 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal(DynamicSecretProviders.MongoDB), inputs: DynamicSecretMongoDBSchema }),
z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema }),
z.object({ type: z.literal(DynamicSecretProviders.AzureEntraID), inputs: AzureEntraIDSchema }),
z.object({ type: z.literal(DynamicSecretProviders.AzureSqlDatabase), inputs: DynamicSecretAzureSqlDBSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Ldap), inputs: LdapSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Snowflake), inputs: DynamicSecretSnowflakeSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Totp), inputs: DynamicSecretTotpSchema }),
@@ -395,7 +395,8 @@ export const gatewayV2ServiceFactory = ({
relayId: gateway.relayId,
orgId: gateway.orgId,
orgName: gateway.orgName,
gatewayId
gatewayId,
gatewayName: gateway.name
});
return {
@@ -508,7 +509,8 @@ export const gatewayV2ServiceFactory = ({
const relayCredentials = await relayService.getCredentialsForGateway({
relayName,
orgId,
gatewayId: gateway.id
gatewayId: gateway.id,
gatewayName: gateway.name
});
return {
+29 -1
View File
@@ -44,7 +44,10 @@ type TGroupServiceFactoryDep = {
projectDAL: Pick<TProjectDALFactory, "findProjectGhostUser" | "findById">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "delete" | "findLatestProjectKey" | "insertMany">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRole">;
permissionService: Pick<
TPermissionServiceFactory,
"getOrgPermission" | "getOrgPermissionByRole" | "invalidateProjectPermissionCache"
>;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
oidcConfigDAL: Pick<TOidcConfigDALFactory, "findOne">;
};
@@ -225,6 +228,15 @@ export const groupServiceFactory = ({
return updated;
});
if (role) {
const groupProjects = await groupProjectDAL.find({ groupId: group.id });
await Promise.allSettled([
...groupProjects.map((groupProject) =>
permissionService.invalidateProjectPermissionCache(groupProject.projectId)
)
]);
}
return updatedGroup;
};
@@ -247,11 +259,17 @@ export const groupServiceFactory = ({
message: "Failed to delete group due to plan restriction. Upgrade plan to delete group."
});
const groupProjects = await groupProjectDAL.find({ groupId: id });
const [group] = await groupDAL.delete({
id,
orgId: actorOrgId
});
await Promise.allSettled([
...groupProjects.map((groupProject) => permissionService.invalidateProjectPermissionCache(groupProject.projectId))
]);
return group;
};
@@ -398,6 +416,11 @@ export const groupServiceFactory = ({
projectBotDAL
});
const groupProjects = await groupProjectDAL.find({ groupId: group.id });
await Promise.allSettled([
...groupProjects.map((groupProject) => permissionService.invalidateProjectPermissionCache(groupProject.projectId))
]);
return users[0];
};
@@ -479,6 +502,11 @@ export const groupServiceFactory = ({
projectKeyDAL
});
const groupProjects = await groupProjectDAL.find({ groupId: group.id });
await Promise.allSettled([
...groupProjects.map((groupProject) => permissionService.invalidateProjectPermissionCache(groupProject.projectId))
]);
return users[0];
};
@@ -28,7 +28,7 @@ type TIdentityProjectAdditionalPrivilegeV2ServiceFactoryDep = {
identityProjectAdditionalPrivilegeDAL: TIdentityProjectAdditionalPrivilegeV2DALFactory;
identityProjectDAL: Pick<TIdentityProjectDALFactory, "findOne" | "findById">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "invalidateProjectPermissionCache">;
};
export type TIdentityProjectAdditionalPrivilegeV2ServiceFactory = ReturnType<
@@ -115,6 +115,8 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
permissions: packedPermission
});
await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId);
return {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
@@ -132,6 +134,9 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime),
temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs)
});
await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId);
return {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
@@ -224,6 +229,9 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""),
temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || ""))
});
await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId);
return {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
@@ -239,6 +247,9 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
temporaryRange: null,
temporaryMode: null
});
await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId);
return {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
@@ -294,6 +305,9 @@ export const identityProjectAdditionalPrivilegeV2ServiceFactory = ({
});
const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id);
await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId);
return {
...deletedPrivilege,
permissions: unpackPermissions(deletedPrivilege.permissions)
@@ -31,7 +31,7 @@ type TIdentityProjectAdditionalPrivilegeServiceFactoryDep = {
identityProjectAdditionalPrivilegeDAL: TIdentityProjectAdditionalPrivilegeDALFactory;
identityProjectDAL: Pick<TIdentityProjectDALFactory, "findOne" | "findById">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "invalidateProjectPermissionCache">;
};
export type TIdentityProjectAdditionalPrivilegeServiceFactory = ReturnType<
@@ -129,6 +129,9 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
slug,
permissions: packedPermission
});
await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId);
return {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
@@ -146,6 +149,9 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime),
temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs)
});
await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId);
return {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
@@ -250,6 +256,9 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
temporaryAccessStartTime: new Date(temporaryAccessStartTime || ""),
temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || ""))
});
await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId);
return {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
@@ -265,6 +274,9 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
temporaryRange: null,
temporaryMode: null
});
await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId);
return {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
@@ -338,9 +350,11 @@ export const identityProjectAdditionalPrivilegeServiceFactory = ({
}
const deletedPrivilege = await identityProjectAdditionalPrivilegeDAL.deleteById(identityPrivilege.id);
await permissionService.invalidateProjectPermissionCache(identityProjectMembership.projectId);
return {
...deletedPrivilege,
permissions: unpackPermissions(deletedPrivilege.permissions)
};
};
@@ -31,6 +31,7 @@ export const getDefaultOnPremFeatures = () => {
caCrl: false,
sshHostGroups: false,
enterpriseSecretSyncs: false,
enterpriseCertificateSyncs: false,
enterpriseAppConnections: true,
machineIdentityAuthTemplates: false
};
@@ -62,6 +62,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
sshHostGroups: false,
secretScanning: false,
enterpriseSecretSyncs: false,
enterpriseCertificateSyncs: false,
enterpriseAppConnections: false,
fips: false,
eventSubscriptions: false,
@@ -160,7 +160,10 @@ export const licenseServiceFactory = ({
}
if (isValidOfflineLicense) {
onPremFeatures = contents.license.features;
onPremFeatures = {
...contents.license.features,
slug: "enterprise"
};
instanceType = InstanceType.EnterpriseOnPremOffline;
logger.info(`Instance type: ${InstanceType.EnterpriseOnPremOffline}`);
isValidLicense = true;
@@ -458,17 +461,16 @@ export const licenseServiceFactory = ({
};
const fetchPlanTableFromServer = async (customerId: string | null | undefined) => {
if (!customerId) {
throw new NotFoundError({ message: "Organization customer ID is required for plan table retrieval" });
}
const baseUrl = `/api/license-server/v1/customers/${customerId}`;
const baseUrl = `/api/license-server/v1/customers`;
if (instanceType === InstanceType.Cloud) {
if (!customerId) {
throw new NotFoundError({ message: "Organization customer ID is required for plan table retrieval" });
}
const { data } = await licenseServerCloudApi.request.get<{
head: { name: string }[];
rows: { name: string; allowed: boolean }[];
}>(`${baseUrl}/cloud-plan/table`);
}>(`${baseUrl}/${customerId}/cloud-plan/table`);
return data;
}
@@ -24,7 +24,7 @@ export type TOfflineLicense = {
export type TFeatureSet = {
_id: null;
slug: null;
slug: string | null;
tier: -1;
workspaceLimit: null;
workspacesUsed: number;
@@ -75,6 +75,7 @@ export type TFeatureSet = {
sshHostGroups: false;
secretScanning: false;
enterpriseSecretSyncs: false;
enterpriseCertificateSyncs: false;
enterpriseAppConnections: false;
machineIdentityAuthTemplates: false;
fips: false;
@@ -2,6 +2,7 @@ import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability"
import {
ProjectPermissionActions,
ProjectPermissionAppConnectionActions,
ProjectPermissionAuditLogsActions,
ProjectPermissionCertificateActions,
ProjectPermissionCmekActions,
@@ -12,6 +13,7 @@ import {
ProjectPermissionKmipActions,
ProjectPermissionMemberActions,
ProjectPermissionPkiSubscriberActions,
ProjectPermissionPkiSyncActions,
ProjectPermissionPkiTemplateActions,
ProjectPermissionSecretActions,
ProjectPermissionSecretEventActions,
@@ -208,6 +210,19 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.SecretSyncs
);
can(
[
ProjectPermissionPkiSyncActions.Create,
ProjectPermissionPkiSyncActions.Edit,
ProjectPermissionPkiSyncActions.Delete,
ProjectPermissionPkiSyncActions.Read,
ProjectPermissionPkiSyncActions.SyncCertificates,
ProjectPermissionPkiSyncActions.ImportCertificates,
ProjectPermissionPkiSyncActions.RemoveCertificates
],
ProjectPermissionSub.PkiSyncs
);
can(
[
ProjectPermissionKmipActions.CreateClients,
@@ -264,6 +279,17 @@ const buildAdminPermissionRules = () => {
ProjectPermissionSub.SecretEvents
);
can(
[
ProjectPermissionAppConnectionActions.Create,
ProjectPermissionAppConnectionActions.Edit,
ProjectPermissionAppConnectionActions.Delete,
ProjectPermissionAppConnectionActions.Read,
ProjectPermissionAppConnectionActions.Connect
],
ProjectPermissionSub.AppConnections
);
return rules;
};
@@ -450,6 +476,19 @@ const buildMemberPermissionRules = () => {
ProjectPermissionSub.SecretSyncs
);
can(
[
ProjectPermissionPkiSyncActions.Create,
ProjectPermissionPkiSyncActions.Edit,
ProjectPermissionPkiSyncActions.Delete,
ProjectPermissionPkiSyncActions.Read,
ProjectPermissionPkiSyncActions.SyncCertificates,
ProjectPermissionPkiSyncActions.ImportCertificates,
ProjectPermissionPkiSyncActions.RemoveCertificates
],
ProjectPermissionSub.PkiSyncs
);
can(
[
ProjectPermissionSecretScanningDataSourceActions.Read,
@@ -477,6 +516,8 @@ const buildMemberPermissionRules = () => {
ProjectPermissionSub.SecretEvents
);
can(ProjectPermissionAppConnectionActions.Connect, ProjectPermissionSub.AppConnections);
return rules;
};
@@ -512,6 +553,7 @@ const buildViewerPermissionRules = () => {
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates);
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates);
can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs);
can(ProjectPermissionPkiSyncActions.Read, ProjectPermissionSub.PkiSyncs);
can(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits);
can(
@@ -58,6 +58,13 @@ export enum OrgPermissionGatewayActions {
AttachGateways = "attach-gateways"
}
export enum OrgPermissionRelayActions {
CreateRelays = "create-relays",
ListRelays = "list-relays",
EditRelays = "edit-relays",
DeleteRelays = "delete-relays"
}
export enum OrgPermissionIdentityActions {
Read = "read",
Create = "create",
@@ -87,6 +94,7 @@ export enum OrgPermissionBillingActions {
export enum OrgPermissionSubjects {
Workspace = "workspace",
Project = "project",
Role = "role",
Member = "member",
Settings = "settings",
@@ -108,6 +116,7 @@ export enum OrgPermissionSubjects {
AppConnections = "app-connections",
Kmip = "kmip",
Gateway = "gateway",
Relay = "relay",
SecretShare = "secret-share"
}
@@ -117,6 +126,7 @@ export type AppConnectionSubjectFields = {
export type OrgPermissionSet =
| [OrgPermissionActions.Create, OrgPermissionSubjects.Workspace]
| [OrgPermissionActions.Create, OrgPermissionSubjects.Project]
| [OrgPermissionActions, OrgPermissionSubjects.Role]
| [OrgPermissionActions, OrgPermissionSubjects.Member]
| [OrgPermissionActions, OrgPermissionSubjects.Settings]
@@ -134,6 +144,7 @@ export type OrgPermissionSet =
| [OrgPermissionAuditLogsActions, OrgPermissionSubjects.AuditLogs]
| [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates]
| [OrgPermissionGatewayActions, OrgPermissionSubjects.Gateway]
| [OrgPermissionRelayActions, OrgPermissionSubjects.Relay]
| [
OrgPermissionAppConnectionActions,
(
@@ -166,6 +177,10 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [
subject: z.literal(OrgPermissionSubjects.Workspace).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_ENUM([OrgPermissionActions.Create]).describe("Describe what action an entity can take.")
}),
z.object({
subject: z.literal(OrgPermissionSubjects.Project).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_ENUM([OrgPermissionActions.Create]).describe("Describe what action an entity can take.")
}),
z.object({
subject: z.literal(OrgPermissionSubjects.Role).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.")
@@ -273,6 +288,12 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionGatewayActions).describe(
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(OrgPermissionSubjects.Relay).describe("The entity this permission pertains to."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionRelayActions).describe(
"Describe what action an entity can take."
)
})
]);
@@ -280,6 +301,7 @@ const buildAdminPermission = () => {
const { can, rules } = new AbilityBuilder<MongoAbility<OrgPermissionSet>>(createMongoAbility);
// ws permissions
can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Project);
// role permission
can(OrgPermissionActions.Read, OrgPermissionSubjects.Role);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Role);
@@ -376,6 +398,11 @@ const buildAdminPermission = () => {
can(OrgPermissionGatewayActions.DeleteGateways, OrgPermissionSubjects.Gateway);
can(OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway);
can(OrgPermissionRelayActions.ListRelays, OrgPermissionSubjects.Relay);
can(OrgPermissionRelayActions.CreateRelays, OrgPermissionSubjects.Relay);
can(OrgPermissionRelayActions.EditRelays, OrgPermissionSubjects.Relay);
can(OrgPermissionRelayActions.DeleteRelays, OrgPermissionSubjects.Relay);
can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole);
can(OrgPermissionKmipActions.Setup, OrgPermissionSubjects.Kmip);
@@ -413,6 +440,7 @@ const buildMemberPermission = () => {
const { can, rules } = new AbilityBuilder<MongoAbility<OrgPermissionSet>>(createMongoAbility);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace);
can(OrgPermissionActions.Create, OrgPermissionSubjects.Project);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Member);
can(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups);
can(OrgPermissionActions.Read, OrgPermissionSubjects.Role);
@@ -437,6 +465,10 @@ const buildMemberPermission = () => {
can(OrgPermissionGatewayActions.CreateGateways, OrgPermissionSubjects.Gateway);
can(OrgPermissionGatewayActions.AttachGateways, OrgPermissionSubjects.Gateway);
can(OrgPermissionRelayActions.ListRelays, OrgPermissionSubjects.Relay);
can(OrgPermissionRelayActions.CreateRelays, OrgPermissionSubjects.Relay);
can(OrgPermissionRelayActions.EditRelays, OrgPermissionSubjects.Relay);
can(OrgPermissionMachineIdentityAuthTemplateActions.ListTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate);
can(
OrgPermissionMachineIdentityAuthTemplateActions.UnlinkTemplates,
@@ -1,5 +1,6 @@
import { MongoAbility, RawRuleOf } from "@casl/ability";
import { MongoQuery } from "@ucast/mongo2js";
import { Knex } from "knex";
import { ActionProjectType } from "@app/db/schemas";
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
@@ -283,4 +284,5 @@ export type TPermissionServiceFactory = {
projectId: string;
checkPermissions: ProjectPermissionSet;
}) => Promise<boolean>;
invalidateProjectPermissionCache: (projectId: string, tx?: Knex) => Promise<void>;
};
@@ -3,6 +3,7 @@ import { PackRule, unpackRules } from "@casl/ability/extra";
import { requestContext } from "@fastify/request-context";
import { MongoQuery } from "@ucast/mongo2js";
import handlebars from "handlebars";
import { Knex } from "knex";
import {
ActionProjectType,
@@ -20,9 +21,11 @@ import {
projectViewerPermission,
sshHostBootstrapPermissions
} from "@app/ee/services/permission/default-roles";
import { KeyStorePrefixes, KeyStoreTtls, TKeyStoreFactory } from "@app/keystore/keystore";
import { conditionsMatcher } from "@app/lib/casl";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { objectify } from "@app/lib/fn";
import { logger } from "@app/lib/logger";
import { ActorType } from "@app/services/auth/auth-type";
import { TOrgRoleDALFactory } from "@app/services/org/org-role-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
@@ -49,6 +52,7 @@ type TPermissionServiceFactoryDep = {
serviceTokenDAL: Pick<TServiceTokenDALFactory, "findById">;
projectDAL: Pick<TProjectDALFactory, "findById">;
permissionDAL: TPermissionDALFactory;
keyStore: TKeyStoreFactory;
};
export const permissionServiceFactory = ({
@@ -56,7 +60,8 @@ export const permissionServiceFactory = ({
orgRoleDAL,
projectRoleDAL,
serviceTokenDAL,
projectDAL
projectDAL,
keyStore
}: TPermissionServiceFactoryDep): TPermissionServiceFactory => {
const buildOrgPermission = (orgUserRoles: TBuildOrgPermissionDTO) => {
const rules = orgUserRoles
@@ -83,6 +88,68 @@ export const permissionServiceFactory = ({
});
};
const invalidateProjectPermissionCache = async (projectId: string, tx?: Knex) => {
const projectPermissionDalVersionKey = KeyStorePrefixes.ProjectPermissionDalVersion(projectId);
await keyStore.pgIncrementBy(projectPermissionDalVersionKey, {
incr: 1,
tx,
expiry: KeyStoreTtls.ProjectPermissionDalVersionTtl
});
};
const calculateProjectPermissionTtl = (membership: unknown): number => {
const now = new Date();
let minTtl = KeyStoreTtls.ProjectPermissionCacheInSeconds;
const getMinEndTime = (items: Array<{ temporaryAccessEndTime?: Date | null; isTemporary?: boolean }>) => {
return items
.filter((item) => item.isTemporary && item.temporaryAccessEndTime)
.map((item) => item.temporaryAccessEndTime!)
.filter((endTime) => endTime > now)
.reduce((min, endTime) => (!min || endTime < min ? endTime : min), null as Date | null);
};
const roleTimes: Date[] = [];
const additionalPrivilegeTimes: Date[] = [];
if (
membership &&
typeof membership === "object" &&
"roles" in membership &&
Array.isArray((membership as Record<string, unknown>).roles)
) {
const roles = (membership as Record<string, unknown>).roles as Array<{
temporaryAccessEndTime?: Date | null;
isTemporary?: boolean;
}>;
const minRoleEndTime = getMinEndTime(roles);
if (minRoleEndTime) roleTimes.push(minRoleEndTime);
}
if (
membership &&
typeof membership === "object" &&
"additionalPrivileges" in membership &&
Array.isArray((membership as Record<string, unknown>).additionalPrivileges)
) {
const additionalPrivileges = (membership as Record<string, unknown>).additionalPrivileges as Array<{
temporaryAccessEndTime?: Date | null;
isTemporary?: boolean;
}>;
const minAdditionalEndTime = getMinEndTime(additionalPrivileges);
if (minAdditionalEndTime) additionalPrivilegeTimes.push(minAdditionalEndTime);
}
const allEndTimes = [...roleTimes, ...additionalPrivilegeTimes];
if (allEndTimes.length > 0) {
const nearestEndTime = allEndTimes.reduce((min, endTime) => (!min || endTime < min ? endTime : min));
const timeUntilExpiry = Math.floor((nearestEndTime.getTime() - now.getTime()) / 1000);
minTtl = Math.min(minTtl, Math.max(1, timeUntilExpiry));
}
return minTtl;
};
const buildProjectPermissionRules = (projectUserRoles: TBuildProjectPermissionDTO) => {
const rules = projectUserRoles
.map(({ role, permissions }) => {
@@ -577,35 +644,94 @@ export const permissionServiceFactory = ({
actorId = assumedPrivilegeDetailsCtx.actorId;
}
if (actor === ActorType.SERVICE) {
return getServiceTokenProjectPermission({
serviceTokenId: actorId,
projectId,
actorOrgId,
actionProjectType
}) as Promise<TProjectPermissionRT<T>>;
}
const cachedProjectPermissionVersion = await keyStore.pgGetIntItem(
KeyStorePrefixes.ProjectPermissionDalVersion(projectId)
);
const projectPermissionVersion = Number(cachedProjectPermissionVersion || 0);
const cacheKey = KeyStorePrefixes.ProjectPermission(
projectId,
projectPermissionVersion,
actor,
actorId,
actionProjectType || ActionProjectType.Any
);
try {
const cachedData = await keyStore.getItem(cacheKey);
if (cachedData) {
const parsed = JSON.parse(cachedData) as {
rules: RawRuleOf<MongoAbility<ProjectPermissionSet>>[];
membership: {
roles?: Array<{ role: string; customRoleSlug?: string }>;
[key: string]: unknown;
};
};
const permission = createMongoAbility<ProjectPermissionSet>(parsed.rules, {
conditionsMatcher
});
return {
permission,
membership: parsed.membership,
hasRole: (role: string) =>
parsed.membership.roles?.findIndex(
({ role: slug, customRoleSlug }) => role === slug || slug === customRoleSlug
) !== -1
} as TProjectPermissionRT<T>;
}
} catch (error) {
logger.error(error, "Failed to get project permission");
}
let result: TProjectPermissionRT<T>;
switch (actor) {
case ActorType.USER:
return getUserProjectPermission({
result = (await getUserProjectPermission({
userId: actorId,
projectId,
authMethod: actorAuthMethod,
userOrgId: actorOrgId,
actionProjectType
}) as Promise<TProjectPermissionRT<T>>;
case ActorType.SERVICE:
return getServiceTokenProjectPermission({
serviceTokenId: actorId,
projectId,
actorOrgId,
actionProjectType
}) as Promise<TProjectPermissionRT<T>>;
})) as TProjectPermissionRT<T>;
break;
case ActorType.IDENTITY:
return getIdentityProjectPermission({
result = (await getIdentityProjectPermission({
identityId: actorId,
projectId,
identityOrgId: actorOrgId,
actionProjectType
}) as Promise<TProjectPermissionRT<T>>;
})) as TProjectPermissionRT<T>;
break;
default:
throw new BadRequestError({
message: "Invalid actor provided",
name: "Get project permission"
});
}
try {
const cacheData = {
rules: result.permission.rules,
membership: result.membership
};
const ttl = calculateProjectPermissionTtl(result.membership);
await keyStore.setItemWithExpiry(cacheKey, ttl, JSON.stringify(cacheData));
} catch (error) {
logger.error(error, "Failed to cache project permission");
}
return result;
};
const getProjectPermissionByRole: TPermissionServiceFactory["getProjectPermissionByRole"] = async (
@@ -668,6 +794,7 @@ export const permissionServiceFactory = ({
getProjectPermissionByRole,
buildOrgPermission,
buildProjectPermissionRules,
checkGroupProjectPermission
checkGroupProjectPermission,
invalidateProjectPermissionCache
};
};
@@ -120,6 +120,16 @@ export enum ProjectPermissionSecretSyncActions {
RemoveSecrets = "remove-secrets"
}
export enum ProjectPermissionPkiSyncActions {
Read = "read",
Create = "create",
Edit = "edit",
Delete = "delete",
SyncCertificates = "sync-certificates",
ImportCertificates = "import-certificates",
RemoveCertificates = "remove-certificates"
}
export enum ProjectPermissionSecretRotationActions {
Read = "read",
ReadGeneratedCredentials = "read-generated-credentials",
@@ -147,6 +157,14 @@ export enum ProjectPermissionSecretScanningDataSourceActions {
ReadResources = "read-data-source-resources"
}
export enum ProjectPermissionAppConnectionActions {
Read = "read-app-connections",
Create = "create-app-connections",
Edit = "edit-app-connections",
Delete = "delete-app-connections",
Connect = "connect-app-connections"
}
export enum ProjectPermissionSecretScanningFindingActions {
Read = "read-findings",
Update = "update-findings"
@@ -204,11 +222,13 @@ export enum ProjectPermissionSub {
Kms = "kms",
Cmek = "cmek",
SecretSyncs = "secret-syncs",
PkiSyncs = "pki-syncs",
Kmip = "kmip",
SecretScanningDataSources = "secret-scanning-data-sources",
SecretScanningFindings = "secret-scanning-findings",
SecretScanningConfigs = "secret-scanning-configs",
SecretEvents = "secret-events"
SecretEvents = "secret-events",
AppConnections = "app-connections"
}
export type SecretSubjectFields = {
@@ -235,6 +255,10 @@ export type SecretSyncSubjectFields = {
secretPath: string;
};
export type PkiSyncSubjectFields = {
subscriberName: string;
};
export type DynamicSecretSubjectFields = {
environment: string;
secretPath: string;
@@ -272,6 +296,10 @@ export type PkiSubscriberSubjectFields = {
// (dangtony98): consider adding [commonName] as a subject field in the future
};
export type AppConnectionSubjectFields = {
connectionId: string;
};
export type ProjectPermissionSet =
| [
ProjectPermissionSecretActions,
@@ -295,6 +323,10 @@ export type ProjectPermissionSet =
ProjectPermissionSecretSyncActions,
ProjectPermissionSub.SecretSyncs | (ForcedSubject<ProjectPermissionSub.SecretSyncs> & SecretSyncSubjectFields)
]
| [
ProjectPermissionPkiSyncActions,
ProjectPermissionSub.PkiSyncs | (ForcedSubject<ProjectPermissionSub.PkiSyncs> & PkiSyncSubjectFields)
]
| [
ProjectPermissionActions,
(
@@ -365,6 +397,13 @@ export type ProjectPermissionSet =
| [
ProjectPermissionSecretEventActions,
ProjectPermissionSub.SecretEvents | (ForcedSubject<ProjectPermissionSub.SecretEvents> & SecretEventSubjectFields)
]
| [
ProjectPermissionAppConnectionActions,
(
| ProjectPermissionSub.AppConnections
| (ForcedSubject<ProjectPermissionSub.AppConnections> & AppConnectionSubjectFields)
)
];
const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'";
@@ -460,6 +499,22 @@ const SecretSyncConditionV2Schema = z
})
.partial();
const PkiSyncConditionSchema = z
.object({
subscriberName: z.union([
z.string(),
z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
})
.partial()
])
})
.partial();
const SecretImportConditionSchema = z
.object({
environment: z.union([
@@ -580,6 +635,21 @@ const PkiTemplateConditionSchema = z
})
.partial();
const AppConnectionConditionSchema = z
.object({
connectionId: z.union([
z.string(),
z
.object({
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN]
})
.partial()
])
})
.partial();
const GeneralPermissionSchema = [
z.object({
subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."),
@@ -760,6 +830,16 @@ const GeneralPermissionSchema = [
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretScanningConfigActions).describe(
"Describe what action an entity can take."
)
}),
z.object({
subject: z.literal(ProjectPermissionSub.AppConnections).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionAppConnectionActions).describe(
"Describe what action an entity can take."
),
conditions: AppConnectionConditionSchema.describe(
"When specified, only matching conditions will be allowed to access given resource."
).optional()
})
];
@@ -898,6 +978,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
z.object({
subject: z.literal(ProjectPermissionSub.PkiSyncs).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPkiSyncActions).describe(
"Describe what action an entity can take."
),
conditions: PkiSyncConditionSchema.describe(
"When specified, only matching conditions will be allowed to access given resource."
).optional()
}),
z.object({
subject: z.literal(ProjectPermissionSub.SecretEvents).describe("The entity this permission pertains to."),
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
+4 -2
View File
@@ -754,7 +754,8 @@ export const pitServiceFactory = ({
secrets: newSecrets.map((secret) => ({
secretId: secret.id,
secretKey: secret.secretKey,
secretVersion: secret.version
secretVersion: secret.version,
secretTags: secret.tags?.map((tag) => tag.name)
}))
}
});
@@ -781,7 +782,8 @@ export const pitServiceFactory = ({
secrets: updatedSecrets.map((secret) => ({
secretId: secret.id,
secretKey: secret.secretKey,
secretVersion: secret.version
secretVersion: secret.version,
secretTags: secret.tags?.map((tag) => tag.name)
}))
}
});
@@ -27,7 +27,7 @@ import {
type TProjectUserAdditionalPrivilegeServiceFactoryDep = {
projectUserAdditionalPrivilegeDAL: TProjectUserAdditionalPrivilegeDALFactory;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findById" | "findOne">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "invalidateProjectPermissionCache">;
accessApprovalRequestDAL: Pick<TAccessApprovalRequestDALFactory, "update">;
};
@@ -115,6 +115,9 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
slug,
permissions: packedPermission
});
await permissionService.invalidateProjectPermissionCache(projectMembership.projectId);
return {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
@@ -133,6 +136,9 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
temporaryAccessStartTime: new Date(dto.temporaryAccessStartTime),
temporaryAccessEndTime: new Date(new Date(dto.temporaryAccessStartTime).getTime() + relativeTempAllocatedTimeInMs)
});
await permissionService.invalidateProjectPermissionCache(projectMembership.projectId);
return {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
@@ -230,6 +236,8 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
temporaryAccessEndTime: new Date(new Date(temporaryAccessStartTime || "").getTime() + ms(temporaryRange || ""))
});
await permissionService.invalidateProjectPermissionCache(projectMembership.projectId);
return {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
@@ -245,6 +253,9 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
temporaryRange: null,
temporaryMode: null
});
await permissionService.invalidateProjectPermissionCache(projectMembership.projectId);
return {
...additionalPrivilege,
permissions: unpackPermissions(additionalPrivilege.permissions)
@@ -291,6 +302,9 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({
}
);
const deletedPrivilege = await projectUserAdditionalPrivilegeDAL.deleteById(userPrivilege.id);
await permissionService.invalidateProjectPermissionCache(projectMembership.projectId);
return {
...deletedPrivilege,
permissions: unpackPermissions(deletedPrivilege.permissions)
@@ -0,0 +1 @@
export const RELAY_CONNECTING_GATEWAY_INFO = "1.3.6.1.4.1.12345.100.3";
+130 -9
View File
@@ -1,9 +1,13 @@
import { isIP } from "node:net";
import { ForbiddenError } from "@casl/ability";
import * as x509 from "@peculiar/x509";
import { TRelays } from "@app/db/schemas";
import { PgSqlLock } from "@app/keystore/keystore";
import { crypto } from "@app/lib/crypto";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
import { constructPemChainFromCerts, prependCertToPemChain } from "@app/services/certificate/certificate-fns";
import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types";
import {
@@ -14,11 +18,15 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { verifyHostInputValidity } from "../dynamic-secret/dynamic-secret-fns";
import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionRelayActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service-types";
import { createSshCert, createSshKeyPair } from "../ssh/ssh-certificate-authority-fns";
import { SshCertType } from "../ssh/ssh-certificate-authority-types";
import { SshCertKeyAlgorithm } from "../ssh-certificate/ssh-certificate-types";
import { TInstanceRelayConfigDALFactory } from "./instance-relay-config-dal";
import { TOrgRelayConfigDALFactory } from "./org-relay-config-dal";
import { RELAY_CONNECTING_GATEWAY_INFO } from "./relay-constants";
import { TRelayDALFactory } from "./relay-dal";
export type TRelayServiceFactory = ReturnType<typeof relayServiceFactory>;
@@ -29,12 +37,16 @@ export const relayServiceFactory = ({
instanceRelayConfigDAL,
orgRelayConfigDAL,
relayDAL,
kmsService
kmsService,
licenseService,
permissionService
}: {
instanceRelayConfigDAL: TInstanceRelayConfigDALFactory;
orgRelayConfigDAL: TOrgRelayConfigDALFactory;
relayDAL: TRelayDALFactory;
kmsService: TKmsServiceFactory;
licenseService: TLicenseServiceFactory;
permissionService: TPermissionServiceFactory;
}) => {
const $getInstanceCAs = async () => {
const instanceConfig = await instanceRelayConfigDAL.transaction(async (tx) => {
@@ -639,8 +651,9 @@ export const relayServiceFactory = ({
true
),
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.SERVER_AUTH]], true),
// san
new x509.SubjectAlternativeNameExtension([{ type: "ip", value: host }], false)
new x509.SubjectAlternativeNameExtension([{ type: isIP(host) ? "ip" : "dns", value: host }], false)
];
const relayServerSerialNumber = createSerialNumber();
@@ -689,6 +702,7 @@ export const relayServiceFactory = ({
const $generateRelayClientCredentials = async ({
gatewayId,
gatewayName,
orgId,
orgName,
relayPkiClientCaCertificate,
@@ -697,6 +711,7 @@ export const relayServiceFactory = ({
relayPkiServerCaCertificateChain
}: {
gatewayId: string;
gatewayName: string;
orgId: string;
orgName: string;
relayPkiClientCaCertificate: Buffer;
@@ -727,6 +742,16 @@ export const relayServiceFactory = ({
const clientCertPrivateKey = crypto.nativeCrypto.KeyObject.from(clientKeys.privateKey);
const clientCertSerialNumber = createSerialNumber();
const connectingGatewayInfoExtension = new x509.Extension(
RELAY_CONNECTING_GATEWAY_INFO,
false,
Buffer.from(
JSON.stringify({
name: gatewayName
})
)
);
// Build standard extensions
const extensions: x509.Extension[] = [
new x509.BasicConstraintsExtension(false),
@@ -740,7 +765,8 @@ export const relayServiceFactory = ({
x509.KeyUsageFlags[CertKeyUsage.KEY_AGREEMENT],
true
),
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.CLIENT_AUTH]], true)
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage[CertExtendedKeyUsage.CLIENT_AUTH]], true),
connectingGatewayInfoExtension
];
const clientCert = await x509.X509CertificateGenerator.create({
@@ -768,11 +794,13 @@ export const relayServiceFactory = ({
const getCredentialsForGateway = async ({
relayName,
orgId,
gatewayId
gatewayId,
gatewayName
}: {
relayName: string;
orgId: string;
gatewayId: string;
gatewayName: string;
}) => {
let relay: TRelays | null = await relayDAL.findOne({
orgId,
@@ -819,10 +847,10 @@ export const relayServiceFactory = ({
const relayClientSshCert = await createSshCert({
caPrivateKey: orgCAs.relaySshClientCaPrivateKey.toString("utf8"),
clientPublicKey: relayClientSshPublicKey,
keyId: `relay-client-${relay.id}`,
principals: [gatewayId],
keyId: `client-${relayName}`,
principals: [gatewayId, gatewayName],
certType: SshCertType.USER,
requestedTtl: "30d"
requestedTtl: "1d"
});
return {
@@ -837,12 +865,14 @@ export const relayServiceFactory = ({
relayId,
orgId,
orgName,
gatewayId
gatewayId,
gatewayName
}: {
relayId: string;
orgId: string;
orgName: string;
gatewayId: string;
gatewayName: string;
}) => {
const relay = await relayDAL.findOne({
id: relayId
@@ -860,6 +890,7 @@ export const relayServiceFactory = ({
const instanceCAs = await $getInstanceCAs();
const relayCertificateCredentials = await $generateRelayClientCredentials({
gatewayId,
gatewayName,
orgId,
orgName,
relayPkiClientCaCertificate: instanceCAs.instanceRelayPkiClientCaCertificate,
@@ -877,6 +908,7 @@ export const relayServiceFactory = ({
const orgCAs = await $getOrgCAs(orgId);
const relayCertificateCredentials = await $generateRelayClientCredentials({
gatewayId,
gatewayName,
orgId,
orgName,
relayPkiClientCaCertificate: orgCAs.relayPkiClientCaCertificate,
@@ -895,11 +927,13 @@ export const relayServiceFactory = ({
host,
name,
identityId,
actorAuthMethod,
orgId
}: {
host: string;
name: string;
identityId?: string;
actorAuthMethod?: ActorAuthMethod;
orgId?: string;
}) => {
let relay: TRelays;
@@ -908,6 +942,27 @@ export const relayServiceFactory = ({
await verifyHostInputValidity(host);
if (isOrgRelay) {
const orgLicensePlan = await licenseService.getPlan(orgId);
if (!orgLicensePlan.gateway) {
throw new BadRequestError({
message:
"Relay registration failed due to organization plan restrictions. Please upgrade your instance to Infisical's Enterprise plan."
});
}
const { permission } = await permissionService.getOrgPermission(
ActorType.IDENTITY,
identityId,
orgId,
actorAuthMethod!,
orgId
);
ForbiddenError.from(permission).throwUnlessCan(
OrgPermissionRelayActions.CreateRelays,
OrgPermissionSubjects.Relay
);
relay = await relayDAL.transaction(async (tx) => {
const existingRelay = await relayDAL.findOne(
{
@@ -995,9 +1050,75 @@ export const relayServiceFactory = ({
});
};
const getRelays = async ({
actorId,
actor,
actorAuthMethod,
actorOrgId
}: {
actorId: string;
actor: ActorType;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
}) => {
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
actorOrgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionRelayActions.ListRelays, OrgPermissionSubjects.Relay);
const instanceRelays = await relayDAL.find({
orgId: null
});
const orgRelays = await relayDAL.find({
orgId: actorOrgId
});
return [...instanceRelays, ...orgRelays];
};
const deleteRelay = async ({
id,
actorId,
actor,
actorAuthMethod,
actorOrgId
}: {
id: string;
actorId: string;
actor: ActorType;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
}) => {
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
actorOrgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionRelayActions.DeleteRelays, OrgPermissionSubjects.Relay);
const relay = await relayDAL.findById(id);
if (!relay || relay.orgId !== actorOrgId || relay.orgId === null) {
throw new NotFoundError({ message: "Relay not found" });
}
const deletedRelay = await relayDAL.deleteById(id);
return deletedRelay;
};
return {
registerRelay,
getCredentialsForGateway,
getCredentialsForClient
getCredentialsForClient,
getRelays,
deleteRelay
};
};
@@ -1,6 +1,17 @@
/* eslint-disable no-await-in-loop */
import { ForbiddenError } from "@casl/ability";
import { Knex } from "knex";
import RE2 from "re2";
import { OrgMembershipStatus, TableName, TSamlConfigs, TSamlConfigsUpdate, TUsers } from "@app/db/schemas";
import {
OrgMembershipRole,
OrgMembershipStatus,
TableName,
TGroups,
TSamlConfigs,
TSamlConfigsUpdate,
TUsers
} from "@app/db/schemas";
import { throwOnPlanSeatLimitReached } from "@app/ee/services/license/license-fns";
import { getConfig } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto";
@@ -8,12 +19,16 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/
import { AuthTokenType } from "@app/services/auth/auth-type";
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
import { TokenType } from "@app/services/auth-token/auth-token-types";
import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal";
import { TIdentityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
import { TOrgDALFactory } from "@app/services/org/org-dal";
import { getDefaultOrgMembershipRole } from "@app/services/org/org-role-fns";
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { getServerCfg } from "@app/services/super-admin/super-admin-service";
import { LoginMethod } from "@app/services/super-admin/super-admin-types";
@@ -22,17 +37,30 @@ import { normalizeUsername } from "@app/services/user/user-fns";
import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
import { UserAliasType } from "@app/services/user-alias/user-alias-types";
import { TGroupDALFactory } from "../group/group-dal";
import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "../group/group-fns";
import { TUserGroupMembershipDALFactory } from "../group/user-group-membership-dal";
import { TLicenseServiceFactory } from "../license/license-service";
import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission";
import { TPermissionServiceFactory } from "../permission/permission-service-types";
import { TSamlConfigDALFactory } from "./saml-config-dal";
import { TSamlConfigServiceFactory } from "./saml-config-types";
import { SamlProviders, TSamlConfigServiceFactory } from "./saml-config-types";
// SAML providers that support group sync
const GROUP_SYNC_SUPPORTED_PROVIDERS = [SamlProviders.GOOGLE_SAML] as SamlProviders[];
type TSamlConfigServiceFactoryDep = {
samlConfigDAL: Pick<TSamlConfigDALFactory, "create" | "findOne" | "update" | "findById">;
userDAL: Pick<
TUserDALFactory,
"create" | "findOne" | "transaction" | "updateById" | "findById" | "findUserEncKeyByUserId"
| "create"
| "findOne"
| "find"
| "transaction"
| "updateById"
| "findById"
| "findUserEncKeyByUserId"
| "findUserEncKeyByUserIdsBatch"
>;
userAliasDAL: Pick<TUserAliasDALFactory, "create" | "findOne">;
orgDAL: Pick<
@@ -41,6 +69,15 @@ type TSamlConfigServiceFactoryDep = {
>;
identityMetadataDAL: Pick<TIdentityMetadataDALFactory, "delete" | "insertMany" | "transaction">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "create">;
groupDAL: Pick<TGroupDALFactory, "create" | "findOne" | "find" | "transaction">;
userGroupMembershipDAL: Pick<
TUserGroupMembershipDALFactory,
"find" | "delete" | "transaction" | "insertMany" | "filterProjectsByUserMembership"
>;
groupProjectDAL: Pick<TGroupProjectDALFactory, "find">;
projectDAL: Pick<TProjectDALFactory, "findById" | "findProjectGhostUser">;
projectBotDAL: Pick<TProjectBotDALFactory, "findOne">;
projectKeyDAL: Pick<TProjectKeyDALFactory, "find" | "delete" | "findLatestProjectKey" | "insertMany">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan" | "updateSubscriptionOrgMemberCount">;
tokenService: Pick<TAuthTokenServiceFactory, "createTokenForUser">;
@@ -54,6 +91,12 @@ export const samlConfigServiceFactory = ({
orgMembershipDAL,
userDAL,
userAliasDAL,
groupDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectDAL,
projectBotDAL,
projectKeyDAL,
permissionService,
licenseService,
tokenService,
@@ -61,6 +104,139 @@ export const samlConfigServiceFactory = ({
identityMetadataDAL,
kmsService
}: TSamlConfigServiceFactoryDep): TSamlConfigServiceFactory => {
const parseSamlGroups = (groupsValue: string): string[] => {
let samlGroups: string[] = [];
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const parsed = JSON.parse(groupsValue);
if (Array.isArray(parsed)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
samlGroups = parsed;
} else if (typeof parsed === "string") {
samlGroups = parsed
.split(",")
.map((g) => g.trim())
.filter(Boolean);
}
} catch {
samlGroups = groupsValue
.split(",")
.map((g) => g.trim())
.filter(Boolean);
}
return samlGroups;
};
const syncUserGroupMemberships = async ({
userId,
orgId,
samlGroups,
tx
}: {
userId: string;
orgId: string;
samlGroups: string[];
tx?: Knex;
}) => {
const processGroupSync = async (transaction: Knex) => {
const currentGroupMemberships = await userGroupMembershipDAL.find(
{
userId
},
{ tx: transaction }
);
const orgGroups = await groupDAL.find({ orgId }, { tx: transaction });
const orgGroupsMap = new Map(orgGroups.map((g: TGroups) => [g.name, g]));
const orgGroupIds = new Set(orgGroups.map((g) => g.id));
const currentOrgGroupMemberships = currentGroupMemberships.filter((m) => orgGroupIds.has(m.groupId));
const currentGroupNames = new Set(
currentOrgGroupMemberships
.map((m) => {
const group = orgGroups.find((g) => g.id === m.groupId);
return group?.name;
})
.filter(Boolean)
);
const targetGroupNames = new Set(samlGroups);
const groupsToAdd = samlGroups.filter((groupName) => !currentGroupNames.has(groupName));
const groupsToRemove = Array.from(currentGroupNames).filter(
(groupName) => groupName && !targetGroupNames.has(groupName)
);
// eslint-disable-next-line no-await-in-loop
for (const groupName of groupsToAdd) {
if (!orgGroupsMap.has(groupName)) {
const newGroup = await groupDAL.create(
{
name: groupName,
slug: `${groupName.toLowerCase().replace(new RE2("[^a-z0-9]", "g"), "-")}-${Date.now()}`,
orgId,
role: OrgMembershipRole.NoAccess,
roleId: null
},
transaction
);
orgGroupsMap.set(groupName, newGroup);
}
}
// eslint-disable-next-line no-await-in-loop
for (const groupName of groupsToAdd) {
const group = orgGroupsMap.get(groupName);
if (group) {
try {
await addUsersToGroupByUserIds({
userIds: [userId],
group,
userDAL,
userGroupMembershipDAL,
orgDAL,
groupProjectDAL,
projectKeyDAL,
projectDAL,
projectBotDAL,
tx: transaction
});
} catch (error) {
// Continue if user already in group
}
}
}
// eslint-disable-next-line no-await-in-loop
for (const groupName of groupsToRemove) {
if (groupName) {
const group = orgGroupsMap.get(groupName);
if (group) {
try {
await removeUsersFromGroupByUserIds({
userIds: [userId],
group,
userDAL,
userGroupMembershipDAL,
groupProjectDAL,
projectKeyDAL,
tx: transaction
});
} catch (error) {
// Continue if user not in group
}
}
}
}
};
if (tx) {
await processGroupSync(tx);
} else {
await userDAL.transaction(processGroupSync);
}
};
const createSamlCfg: TSamlConfigServiceFactory["createSamlCfg"] = async ({
idpCert,
actor,
@@ -71,7 +247,8 @@ export const samlConfigServiceFactory = ({
actorId,
isActive,
entryPoint,
authProvider
authProvider,
enableGroupSync
}) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Sso);
@@ -96,6 +273,18 @@ export const samlConfigServiceFactory = ({
});
}
if (enableGroupSync && !GROUP_SYNC_SUPPORTED_PROVIDERS.includes(authProvider)) {
throw new BadRequestError({
message: "Group sync is not supported for this SAML provider."
});
}
if (enableGroupSync && !plan.groups) {
throw new BadRequestError({
message: "Failed to enable SAML group sync due to plan restriction. Upgrade plan to enable group sync."
});
}
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId
@@ -107,7 +296,8 @@ export const samlConfigServiceFactory = ({
isActive,
encryptedSamlCertificate: encryptor({ plainText: Buffer.from(idpCert) }).cipherTextBlob,
encryptedSamlEntryPoint: encryptor({ plainText: Buffer.from(entryPoint) }).cipherTextBlob,
encryptedSamlIssuer: encryptor({ plainText: Buffer.from(issuer) }).cipherTextBlob
encryptedSamlIssuer: encryptor({ plainText: Buffer.from(issuer) }).cipherTextBlob,
enableGroupSync: enableGroupSync || false
});
return samlConfig;
@@ -123,7 +313,8 @@ export const samlConfigServiceFactory = ({
issuer,
isActive,
entryPoint,
authProvider
authProvider,
enableGroupSync
}) => {
const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
@@ -147,7 +338,27 @@ export const samlConfigServiceFactory = ({
});
}
const updateQuery: TSamlConfigsUpdate = { authProvider, isActive, lastUsed: null };
if (enableGroupSync && authProvider && !GROUP_SYNC_SUPPORTED_PROVIDERS.includes(authProvider)) {
throw new BadRequestError({
message: "Group sync is not supported for this SAML provider."
});
}
if (enableGroupSync && !plan.groups) {
throw new BadRequestError({
message: "Failed to enable SAML group sync due to plan restriction. Upgrade plan to enable group sync."
});
}
const updateQuery: TSamlConfigsUpdate = {
authProvider,
isActive,
lastUsed: null
};
if (enableGroupSync !== undefined) {
updateQuery.enableGroupSync = enableGroupSync;
}
const { encryptor } = await kmsService.createCipherPairWithDataKey({
type: KmsDataKey.Organization,
orgId
@@ -250,7 +461,8 @@ export const samlConfigServiceFactory = ({
entryPoint,
issuer,
cert,
lastUsed: samlConfig.lastUsed
lastUsed: samlConfig.lastUsed,
enableGroupSync: samlConfig.enableGroupSync
};
};
@@ -282,6 +494,12 @@ export const samlConfigServiceFactory = ({
const organization = await orgDAL.findOrgById(orgId);
if (!organization) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
const samlConfig = await samlConfigDAL.findOne({ orgId });
const groupsMetadata = metadata?.find(({ key }) => key === "groups");
const plan = await licenseService.getPlan(orgId);
const shouldSyncGroups = !!samlConfig?.enableGroupSync && !!plan.groups;
let user: TUsers;
if (userAlias) {
user = await userDAL.transaction(async (tx) => {
@@ -303,7 +521,7 @@ export const samlConfigServiceFactory = ({
orgId,
role,
roleId,
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited, // if user is fully completed, then set status to accepted, otherwise set it to invited so we can update it later
status: foundUser.isAccepted ? OrgMembershipStatus.Accepted : OrgMembershipStatus.Invited,
isActive: true
},
tx
@@ -334,6 +552,17 @@ export const samlConfigServiceFactory = ({
}
}
if (shouldSyncGroups && metadata && foundUser.id) {
const samlGroups = groupsMetadata?.value ? parseSamlGroups(groupsMetadata.value) : [];
await syncUserGroupMemberships({
userId: foundUser.id,
orgId,
samlGroups,
tx
});
}
return foundUser;
});
} else {
@@ -425,6 +654,18 @@ export const samlConfigServiceFactory = ({
);
}
}
if (shouldSyncGroups && metadata && newUser.id) {
const samlGroups = groupsMetadata?.value ? parseSamlGroups(groupsMetadata.value) : [];
await syncUserGroupMemberships({
userId: newUser.id,
orgId,
samlGroups,
tx
});
}
return newUser;
});
}
@@ -17,6 +17,7 @@ export type TCreateSamlCfgDTO = {
entryPoint: string;
issuer: string;
idpCert: string;
enableGroupSync?: boolean;
} & TOrgPermission;
export type TUpdateSamlCfgDTO = Partial<{
@@ -25,6 +26,7 @@ export type TUpdateSamlCfgDTO = Partial<{
entryPoint: string;
issuer: string;
idpCert: string;
enableGroupSync?: boolean;
}> &
TOrgPermission;
@@ -71,6 +73,7 @@ export type TSamlConfigServiceFactory = {
issuer: string;
cert: string;
lastUsed: Date | null | undefined;
enableGroupSync?: boolean;
}>;
samlLogin: (arg: TSamlLoginDTO) => Promise<{
isUserCompleted: boolean;
@@ -1,5 +1,7 @@
import { TSecretApprovalRequests } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { TNotificationServiceFactory } from "@app/services/notification/notification-service";
import { NotificationType } from "@app/services/notification/notification-types";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
@@ -11,6 +13,7 @@ type TSendApprovalEmails = {
smtpService: Pick<TSmtpService, "sendMail">;
projectId: string;
secretApprovalRequest: TSecretApprovalRequests;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
};
export const sendApprovalEmailsFn = async ({
@@ -18,7 +21,8 @@ export const sendApprovalEmailsFn = async ({
projectDAL,
smtpService,
projectId,
secretApprovalRequest
secretApprovalRequest,
notificationService
}: TSendApprovalEmails) => {
const cfg = getConfig();
@@ -26,6 +30,17 @@ export const sendApprovalEmailsFn = async ({
const project = await projectDAL.findProjectWithOrg(projectId);
await notificationService.createUserNotifications(
policy.userApprovers.map((approver) => ({
userId: approver.userId,
orgId: project.orgId,
type: NotificationType.SECRET_CHANGE_REQUEST,
title: "Secret Change Request",
body: `You have a new secret change request pending your review for the project **${project.name}** in the organization **${project.organization.name}**.`,
link: `/projects/secret-management/${project.id}/approval?requestId=${secretApprovalRequest.id}`
}))
);
// now we need to go through each of the reviewers and print out all the commits that they need to approve
for await (const reviewerUser of policy.userApprovers) {
await smtpService.sendMail({
@@ -284,7 +284,8 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => {
db.ref("version").withSchema(TableName.SecretVersionV2).as("secVerVersion"),
db.ref("key").withSchema(TableName.SecretVersionV2).as("secVerKey"),
db.ref("encryptedValue").withSchema(TableName.SecretVersionV2).as("secVerValue"),
db.ref("encryptedComment").withSchema(TableName.SecretVersionV2).as("secVerComment")
db.ref("encryptedComment").withSchema(TableName.SecretVersionV2).as("secVerComment"),
db.ref("skipMultilineEncoding").withSchema(TableName.SecretVersionV2).as("secVerSkipMultilineEncoding")
)
.select(
db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
@@ -326,14 +327,22 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => {
{
key: "secretVersion",
label: "secretVersion" as const,
mapper: ({ secretVersion, secVerVersion, secVerKey, secVerValue, secVerComment }) =>
mapper: ({
secretVersion,
secVerVersion,
secVerKey,
secVerValue,
secVerComment,
secVerSkipMultilineEncoding
}) =>
secretVersion
? {
version: secVerVersion,
id: secretVersion,
key: secVerKey,
encryptedValue: secVerValue,
encryptedComment: secVerComment
encryptedComment: secVerComment,
skipMultilineEncoding: secVerSkipMultilineEncoding
}
: undefined,
childrenMapper: [
@@ -28,6 +28,8 @@ 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";
import { TProjectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal";
import { TNotificationServiceFactory } from "@app/services/notification/notification-service";
import { NotificationType } from "@app/services/notification/notification-types";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
@@ -140,6 +142,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
projectMicrosoftTeamsConfigDAL: Pick<TProjectMicrosoftTeamsConfigDALFactory, "getIntegrationDetailsByProject">;
microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "sendNotification">;
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
};
export type TSecretApprovalRequestServiceFactory = ReturnType<typeof secretApprovalRequestServiceFactory>;
@@ -172,7 +175,8 @@ export const secretApprovalRequestServiceFactory = ({
resourceMetadataDAL,
projectMicrosoftTeamsConfigDAL,
microsoftTeamsService,
folderCommitService
folderCommitService,
notificationService
}: TSecretApprovalRequestServiceFactoryDep) => {
const requestCount = async ({
projectId,
@@ -281,13 +285,10 @@ export const secretApprovalRequestServiceFactory = ({
) {
throw new ForbiddenRequestError({ message: "User has insufficient privileges" });
}
const getHasSecretReadAccess = (
shouldCheckSecretPermission: boolean | null | undefined,
environment: string,
tags: { slug: string }[],
secretPath?: string
) => {
if (shouldCheckSecretPermission) {
const getHasSecretReadAccess = (environment: string, tags: { slug: string }[], secretPath?: string) => {
const isReviewer = policy.approvers.some(({ userId }) => userId === actorId);
if (!isReviewer) {
const canRead = hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, {
environment,
secretPath: secretPath || "/",
@@ -318,44 +319,33 @@ export const secretApprovalRequestServiceFactory = ({
version: el.version,
secretMetadata: el.secretMetadata as ResourceMetadataDTO,
isRotatedSecret: el.secret?.isRotatedSecret ?? false,
secretValueHidden: !getHasSecretReadAccess(
secretApprovalRequest.policy.shouldCheckSecretPermission,
secretApprovalRequest.environment,
el.tags,
secretPath?.[0]?.path
),
secretValue: !getHasSecretReadAccess(
secretApprovalRequest.policy.shouldCheckSecretPermission,
secretApprovalRequest.environment,
el.tags,
secretPath?.[0]?.path
)
secretValueHidden: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path),
secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path)
? INFISICAL_SECRET_VALUE_HIDDEN_MASK
: el.secret && el.secret.isRotatedSecret
? undefined
: el.encryptedValue
: el.encryptedValue !== undefined && el.encryptedValue !== null
? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString()
: "",
secretComment: el.encryptedComment
? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString()
: "",
: undefined,
secretComment:
el.encryptedComment !== undefined && el.encryptedComment !== null
? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString()
: undefined,
skipMultilineEncoding:
el.skipMultilineEncoding !== undefined && el.skipMultilineEncoding !== null
? el.skipMultilineEncoding
: undefined,
secret: el.secret
? {
secretKey: el.secret.key,
id: el.secret.id,
version: el.secret.version,
secretValueHidden: !getHasSecretReadAccess(
secretApprovalRequest.policy.shouldCheckSecretPermission,
secretApprovalRequest.environment,
el.tags,
secretPath?.[0]?.path
),
secretValue: !getHasSecretReadAccess(
secretApprovalRequest.policy.shouldCheckSecretPermission,
secretApprovalRequest.environment,
el.tags,
secretPath?.[0]?.path
)
secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path)
? INFISICAL_SECRET_VALUE_HIDDEN_MASK
: el.secret.encryptedValue
? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedValue }).toString()
@@ -371,17 +361,11 @@ export const secretApprovalRequestServiceFactory = ({
id: el.secretVersion.id,
version: el.secretVersion.version,
secretValueHidden: !getHasSecretReadAccess(
secretApprovalRequest.policy.shouldCheckSecretPermission,
secretApprovalRequest.environment,
el.tags,
secretPath?.[0]?.path
),
secretValue: !getHasSecretReadAccess(
secretApprovalRequest.policy.shouldCheckSecretPermission,
secretApprovalRequest.environment,
el.tags,
secretPath?.[0]?.path
)
secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path)
? INFISICAL_SECRET_VALUE_HIDDEN_MASK
: el.secretVersion.encryptedValue
? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedValue }).toString()
@@ -390,7 +374,8 @@ export const secretApprovalRequestServiceFactory = ({
? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedComment }).toString()
: "",
tags: el.secretVersion.tags,
secretMetadata: el.oldSecretMetadata as ResourceMetadataDTO
secretMetadata: el.oldSecretMetadata as ResourceMetadataDTO,
skipMultilineEncoding: el.secretVersion.skipMultilineEncoding
}
: undefined
}));
@@ -399,12 +384,7 @@ export const secretApprovalRequestServiceFactory = ({
const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id);
secrets = encryptedSecrets.map((el) => ({
...el,
secretValueHidden: !getHasSecretReadAccess(
secretApprovalRequest.policy.shouldCheckSecretPermission,
secretApprovalRequest.environment,
el.tags,
secretPath?.[0]?.path
),
secretValueHidden: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path),
...decryptSecretWithBot(el, botKey),
secret: el.secret
? {
@@ -729,9 +709,9 @@ export const secretApprovalRequestServiceFactory = ({
tx,
inputSecrets: secretUpdationCommits.map((el) => {
const encryptedValue =
!el.secret?.isRotatedSecret && typeof el.encryptedValue !== "undefined"
!el.secret?.isRotatedSecret && el.encryptedValue !== null && el.encryptedValue !== undefined
? {
encryptedValue: el.encryptedValue as Buffer,
encryptedValue: el.encryptedValue,
references: el.encryptedValue
? getAllSecretReferencesV2Bridge(
secretManagerDecryptor({
@@ -745,9 +725,9 @@ export const secretApprovalRequestServiceFactory = ({
filter: { id: el.secretId as string, type: SecretType.Shared },
data: {
reminderRepeatDays: el.reminderRepeatDays,
encryptedComment: el.encryptedComment,
encryptedComment: el.encryptedComment !== null ? el.encryptedComment : undefined,
reminderNote: el.reminderNote,
skipMultilineEncoding: el.skipMultilineEncoding,
skipMultilineEncoding: el.skipMultilineEncoding !== null ? el.skipMultilineEncoding : undefined,
key: el.key,
tags: el?.tags.map(({ id }) => id),
secretMetadata: el.secretMetadata as ResourceMetadataDTO,
@@ -1035,6 +1015,17 @@ export const secretApprovalRequestServiceFactory = ({
}
});
await notificationService.createUserNotifications(
approverUsers.map((approver) => ({
userId: approver.id,
orgId: project.orgId,
type: NotificationType.SECRET_CHANGE_POLICY_BYPASSED,
title: "Secret Change Policy Bypassed",
body: `**${requestedByUser.firstName} ${requestedByUser.lastName}** (${requestedByUser.email}) has merged a secret to **${policy.secretPath}** in the **${env.name}** environment for project **${project.name}** without obtaining the required approval.`,
link: `/projects/secret-management/${project.id}/approval`
}))
);
await smtpService.sendMail({
recipients: approverUsers.filter((approver) => approver.email).map((approver) => approver.email!),
subjectLine: "Infisical Secret Change Policy Bypassed",
@@ -1069,7 +1060,9 @@ export const secretApprovalRequestServiceFactory = ({
// @ts-expect-error not present on v1 secrets
secretKey: secret.key as string,
// @ts-expect-error not present on v1 secrets
secretMetadata: secret.secretMetadata as ResourceMetadataDTO
secretMetadata: secret.secretMetadata as ResourceMetadataDTO,
// @ts-expect-error not present on v1 secrets
secretTags: (secret.tags as { name: string }[])?.map((tag) => tag.name)
}))
}
});
@@ -1085,7 +1078,9 @@ export const secretApprovalRequestServiceFactory = ({
// @ts-expect-error not present on v1 secrets
secretKey: secret.key as string,
// @ts-expect-error not present on v1 secrets
secretMetadata: secret.secretMetadata as ResourceMetadataDTO
secretMetadata: secret.secretMetadata as ResourceMetadataDTO,
// @ts-expect-error not present on v1 secrets
secretTags: (secret.tags as { name: string }[])?.map((tag) => tag.name)
}
});
}
@@ -1104,7 +1099,9 @@ export const secretApprovalRequestServiceFactory = ({
// @ts-expect-error not present on v1 secrets
secretKey: secret.key as string,
// @ts-expect-error not present on v1 secrets
secretMetadata: secret.secretMetadata as ResourceMetadataDTO
secretMetadata: secret.secretMetadata as ResourceMetadataDTO,
// @ts-expect-error not present on v1 secrets
secretTags: (secret.tags as { name: string }[])?.map((tag) => tag.name)
}))
}
});
@@ -1120,7 +1117,9 @@ export const secretApprovalRequestServiceFactory = ({
// @ts-expect-error not present on v1 secrets
secretKey: secret.key as string,
// @ts-expect-error not present on v1 secrets
secretMetadata: secret.secretMetadata as ResourceMetadataDTO
secretMetadata: secret.secretMetadata as ResourceMetadataDTO,
// @ts-expect-error not present on v1 secrets
secretTags: (secret.tags as { name: string }[])?.map((tag) => tag.name)
}
});
}
@@ -1446,7 +1445,8 @@ export const secretApprovalRequestServiceFactory = ({
secretApprovalPolicyDAL,
secretApprovalRequest,
smtpService,
projectId
projectId,
notificationService
});
return secretApprovalRequest;
@@ -1609,11 +1609,13 @@ export const secretApprovalRequestServiceFactory = ({
key: newSecretName || secretKey,
encryptedComment: setKnexStringValue(
secretComment,
(value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob
(value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob,
true // scott: we need to encrypt empty string on update to differentiate not updating comment vs clearing comment
),
encryptedValue: setKnexStringValue(
secretValue,
(value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob
(value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob,
true // scott: we need to encrypt empty string on update to differentiate not updating value vs clearing value
),
reminderRepeatDays,
reminderNote,
@@ -1813,7 +1815,8 @@ export const secretApprovalRequestServiceFactory = ({
secretApprovalPolicyDAL,
secretApprovalRequest,
smtpService,
projectId
projectId,
notificationService
});
return secretApprovalRequest;
};
@@ -175,7 +175,8 @@ export const ldapPasswordRotationFactory: TRotationFactory<
const encryptedCredentials = await encryptAppConnectionCredentials({
credentials: updatedCredentials,
orgId,
kmsService
kmsService,
projectId: connection.projectId
});
await appConnectionDAL.updateById(connection.id, { encryptedCredentials });
@@ -0,0 +1,4 @@
export * from "./redis-credentials-rotation-constants";
export * from "./redis-credentials-rotation-fns";
export * from "./redis-credentials-rotation-schemas";
export * from "./redis-credentials-rotation-types";
@@ -0,0 +1,15 @@
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
export const REDIS_CREDENTIALS_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = {
name: "Redis Credentials",
type: SecretRotation.RedisCredentials,
connection: AppConnection.Redis,
template: {
secretsMapping: {
username: "REDIS_USERNAME",
password: "REDIS_PASSWORD"
}
}
};
@@ -0,0 +1,194 @@
/* eslint-disable no-await-in-loop */
import Redis from "ioredis";
import {
TRotationFactory,
TRotationFactoryGetSecretsPayload,
TRotationFactoryIssueCredentials,
TRotationFactoryRevokeCredentials,
TRotationFactoryRotateCredentials
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types";
import { BadRequestError } from "@app/lib/errors";
import { verifyHostInputValidity } from "../../dynamic-secret/dynamic-secret-fns";
import { DEFAULT_PASSWORD_REQUIREMENTS, generatePassword } from "../shared/utils";
import {
TRedisCredentialsRotationGeneratedCredentials,
TRedisCredentialsRotationWithConnection
} from "./redis-credentials-rotation-types";
const redactPasswords = (e: unknown, credentials: TRedisCredentialsRotationGeneratedCredentials) => {
const error = e as Error;
if (!error?.message) return "Unknown error";
let redactedMessage = error.message;
credentials.forEach(({ password }) => {
redactedMessage = redactedMessage.replaceAll(password, "*******************");
});
return redactedMessage;
};
export const redisCredentialsRotationFactory: TRotationFactory<
TRedisCredentialsRotationWithConnection,
TRedisCredentialsRotationGeneratedCredentials
> = (secretRotation) => {
const { connection, secretsMapping, parameters } = secretRotation;
const $getClient = async () => {
const [hostIp] = await verifyHostInputValidity(connection.credentials.host);
let conn: Redis | null = null;
try {
conn = new Redis({
username: connection.credentials.username,
host: hostIp,
port: connection.credentials.port,
password: connection.credentials.password,
...(connection.credentials.sslEnabled && {
tls: {
rejectUnauthorized: connection.credentials.sslRejectUnauthorized,
ca: connection.credentials.sslCertificate
}
})
});
let result: string;
if (connection.credentials.password) {
result = await conn.auth(connection.credentials.username, connection.credentials.password, () => {});
} else {
result = await conn.auth(connection.credentials.username, () => {});
}
if (result !== "OK") {
throw new BadRequestError({ message: `Invalid credentials, Redis returned ${result} status` });
}
return conn;
} catch (err) {
if (conn) await conn.quit();
throw err;
}
};
/**
* Creates a new user and password for the redis user using ACL
*/
const $rotateAclUser = async () => {
let client: Redis | null = null;
const username = generatePassword({
length: 32,
required: {
symbols: 0,
digits: 5,
uppercase: 5,
lowercase: 5
}
});
const password = generatePassword(parameters.passwordRequirements || DEFAULT_PASSWORD_REQUIREMENTS);
try {
client = await $getClient();
// important: permissionScope is user input so we need to sanitize it, which we do by splitting the permission scope into parts and then passing them to the ACL command as separate arguments
const permissionParts = parameters.permissionScope.split(" ");
await client.call("ACL", "SETUSER", username, `>${password}`, "on", ...permissionParts);
return {
username,
password
};
} catch (error: unknown) {
throw new BadRequestError({
message: `Unable to rotate credentials: ${redactPasswords(error, [{ username, password }])}`
});
} finally {
if (client) await client.quit();
}
};
/**
* Revokes a ACL password from the Redis server using its username and password.
*/
const revokeCredential = async (username: string) => {
let client: Redis | null = null;
try {
client = await $getClient();
await client.call("ACL", "DELUSER", username);
} catch (error: unknown) {
throw new BadRequestError({
message: `Unable to revoke credential: ${redactPasswords(error, [{ username, password: username }])}`
});
} finally {
if (client) await client.quit();
}
};
/**
* Issues a new set of credentials.
*/
const issueCredentials: TRotationFactoryIssueCredentials<TRedisCredentialsRotationGeneratedCredentials> = async (
callback
) => {
const credentials = await $rotateAclUser();
return callback(credentials);
};
/**
* Revokes a list of credentials.
*/
const revokeCredentials: TRotationFactoryRevokeCredentials<TRedisCredentialsRotationGeneratedCredentials> = async (
credentials,
callback
) => {
if (!credentials?.length) return callback();
for (const { username } of credentials) {
await revokeCredential(username);
// eslint-disable-next-line no-promise-executor-return
await new Promise((resolve) => setTimeout(resolve, 1000));
}
return callback();
};
/**
* Rotates credentials by issuing new ones and revoking the old.
*/
const rotateCredentials: TRotationFactoryRotateCredentials<TRedisCredentialsRotationGeneratedCredentials> = async (
oldCredentials,
callback
) => {
const newCredentials = await $rotateAclUser();
if (oldCredentials?.username) {
await revokeCredential(oldCredentials.username);
}
return callback(newCredentials);
};
/**
* Maps the generated credentials into the secret payload format.
*/
const getSecretsPayload: TRotationFactoryGetSecretsPayload<TRedisCredentialsRotationGeneratedCredentials> = ({
username,
password
}) => [
{ key: secretsMapping.username, value: username },
{ key: secretsMapping.password, value: password }
];
return {
issueCredentials,
revokeCredentials,
rotateCredentials,
getSecretsPayload
};
};
@@ -0,0 +1,70 @@
import { z } from "zod";
import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums";
import {
BaseCreateSecretRotationSchema,
BaseSecretRotationSchema,
BaseUpdateSecretRotationSchema
} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas";
import { SecretRotations } from "@app/lib/api-docs";
import { SecretNameSchema } from "@app/server/lib/schemas";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { PasswordRequirementsSchema } from "../shared/general";
export const RedisCredentialsRotationGeneratedCredentialsSchema = z
.object({
username: z.string(),
password: z.string()
})
.array()
.min(1)
.max(2);
const RedisCredentialsRotationSecretsMappingSchema = z.object({
username: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.REDIS_CREDENTIALS.username),
password: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.REDIS_CREDENTIALS.password)
});
export const RedisCredentialsRotationParametersSchema = z.object({
passwordRequirements: PasswordRequirementsSchema.optional(),
permissionScope: z
.string()
.trim()
.min(1, "Permission scope is required")
.describe(SecretRotations.PARAMETERS.REDIS_CREDENTIALS.permissionScope)
});
export const RedisCredentialsRotationTemplateSchema = z.object({
secretsMapping: z.object({
username: z.string(),
password: z.string()
})
});
export const RedisCredentialsRotationSchema = BaseSecretRotationSchema(SecretRotation.RedisCredentials).extend({
type: z.literal(SecretRotation.RedisCredentials),
parameters: RedisCredentialsRotationParametersSchema,
secretsMapping: RedisCredentialsRotationSecretsMappingSchema
});
export const CreateRedisCredentialsRotationSchema = BaseCreateSecretRotationSchema(
SecretRotation.RedisCredentials
).extend({
parameters: RedisCredentialsRotationParametersSchema,
secretsMapping: RedisCredentialsRotationSecretsMappingSchema
});
export const UpdateRedisCredentialsRotationSchema = BaseUpdateSecretRotationSchema(
SecretRotation.RedisCredentials
).extend({
parameters: RedisCredentialsRotationParametersSchema.optional(),
secretsMapping: RedisCredentialsRotationSecretsMappingSchema.optional()
});
export const RedisCredentialsRotationListItemSchema = z.object({
name: z.literal("Redis Credentials"),
connection: z.literal(AppConnection.Redis),
type: z.literal(SecretRotation.RedisCredentials),
template: RedisCredentialsRotationTemplateSchema
});
@@ -0,0 +1,24 @@
import { z } from "zod";
import { TRedisConnection } from "@app/services/app-connection/redis";
import {
CreateRedisCredentialsRotationSchema,
RedisCredentialsRotationGeneratedCredentialsSchema,
RedisCredentialsRotationListItemSchema,
RedisCredentialsRotationSchema
} from "./redis-credentials-rotation-schemas";
export type TRedisCredentialsRotation = z.infer<typeof RedisCredentialsRotationSchema>;
export type TRedisCredentialsRotationInput = z.infer<typeof CreateRedisCredentialsRotationSchema>;
export type TRedisCredentialsRotationListItem = z.infer<typeof RedisCredentialsRotationListItemSchema>;
export type TRedisCredentialsRotationWithConnection = TRedisCredentialsRotation & {
connection: TRedisConnection;
};
export type TRedisCredentialsRotationGeneratedCredentials = z.infer<
typeof RedisCredentialsRotationGeneratedCredentialsSchema
>;
@@ -52,6 +52,7 @@ const baseSecretRotationV2Query = ({
db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"),
db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"),
db.ref("gatewayId").withSchema(TableName.AppConnection).as("connectionGatewayId"),
db.ref("projectId").withSchema(TableName.AppConnection).as("connectionProjectId"),
db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"),
db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"),
db
@@ -106,6 +107,7 @@ const expandSecretRotation = <T extends Awaited<ReturnType<typeof baseSecretRota
connectionUpdatedAt,
connectionVersion,
connectionGatewayId,
connectionProjectId,
connectionIsPlatformManagedCredentials,
...el
} = secretRotation;
@@ -126,6 +128,7 @@ const expandSecretRotation = <T extends Awaited<ReturnType<typeof baseSecretRota
updatedAt: connectionUpdatedAt,
version: connectionVersion,
gatewayId: connectionGatewayId,
projectId: connectionProjectId,
isPlatformManagedCredentials: connectionIsPlatformManagedCredentials
},
folder: {
@@ -7,7 +7,8 @@ export enum SecretRotation {
AzureClientSecret = "azure-client-secret",
AwsIamUserSecret = "aws-iam-user-secret",
LdapPassword = "ldap-password",
OktaClientSecret = "okta-client-secret"
OktaClientSecret = "okta-client-secret",
RedisCredentials = "redis-credentials"
}
export enum SecretRotationStatus {
@@ -14,6 +14,7 @@ import { MYSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mysql-credentials";
import { OKTA_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./okta-client-secret";
import { ORACLEDB_CREDENTIALS_ROTATION_LIST_OPTION } from "./oracledb-credentials";
import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials";
import { REDIS_CREDENTIALS_ROTATION_LIST_OPTION } from "./redis-credentials";
import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal";
import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums";
import { TSecretRotationV2ServiceFactory, TSecretRotationV2ServiceFactoryDep } from "./secret-rotation-v2-service";
@@ -35,7 +36,8 @@ const SECRET_ROTATION_LIST_OPTIONS: Record<SecretRotation, TSecretRotationV2List
[SecretRotation.AzureClientSecret]: AZURE_CLIENT_SECRET_ROTATION_LIST_OPTION,
[SecretRotation.AwsIamUserSecret]: AWS_IAM_USER_SECRET_ROTATION_LIST_OPTION,
[SecretRotation.LdapPassword]: LDAP_PASSWORD_ROTATION_LIST_OPTION,
[SecretRotation.OktaClientSecret]: OKTA_CLIENT_SECRET_ROTATION_LIST_OPTION
[SecretRotation.OktaClientSecret]: OKTA_CLIENT_SECRET_ROTATION_LIST_OPTION,
[SecretRotation.RedisCredentials]: REDIS_CREDENTIALS_ROTATION_LIST_OPTION
};
export const listSecretRotationOptions = () => {
@@ -10,7 +10,8 @@ export const SECRET_ROTATION_NAME_MAP: Record<SecretRotation, string> = {
[SecretRotation.AzureClientSecret]: "Azure Client Secret",
[SecretRotation.AwsIamUserSecret]: "AWS IAM User Secret",
[SecretRotation.LdapPassword]: "LDAP Password",
[SecretRotation.OktaClientSecret]: "Okta Client Secret"
[SecretRotation.OktaClientSecret]: "Okta Client Secret",
[SecretRotation.RedisCredentials]: "Redis Credentials"
};
export const SECRET_ROTATION_CONNECTION_MAP: Record<SecretRotation, AppConnection> = {
@@ -22,5 +23,6 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record<SecretRotation, AppConnectio
[SecretRotation.AzureClientSecret]: AppConnection.AzureClientSecrets,
[SecretRotation.AwsIamUserSecret]: AppConnection.AWS,
[SecretRotation.LdapPassword]: AppConnection.LDAP,
[SecretRotation.OktaClientSecret]: AppConnection.Okta
[SecretRotation.OktaClientSecret]: AppConnection.Okta,
[SecretRotation.RedisCredentials]: AppConnection.Redis
};
@@ -17,6 +17,8 @@ import {
import { getConfig } from "@app/lib/config/env";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { TNotificationServiceFactory } from "@app/services/notification/notification-service";
import { NotificationType } from "@app/services/notification/notification-types";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
@@ -28,6 +30,7 @@ type TSecretRotationV2QueueServiceFactoryDep = {
smtpService: Pick<TSmtpService, "sendMail">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers">;
projectDAL: Pick<TProjectDALFactory, "findById">;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
};
export const secretRotationV2QueueServiceFactory = async ({
@@ -36,7 +39,8 @@ export const secretRotationV2QueueServiceFactory = async ({
secretRotationV2Service,
projectMembershipDAL,
projectDAL,
smtpService
smtpService,
notificationService
}: TSecretRotationV2QueueServiceFactoryDep) => {
const appCfg = getConfig();
@@ -152,6 +156,19 @@ export const secretRotationV2QueueServiceFactory = async ({
const rotationType = SECRET_ROTATION_NAME_MAP[type as SecretRotation];
const rotationPath = `/projects/secret-management/${projectId}/secrets/${environment.slug}`;
await notificationService.createUserNotifications(
projectAdmins.map((admin) => ({
userId: admin.userId,
orgId: project.orgId,
type: NotificationType.SECRET_ROTATION_FAILED,
title: "Secret Rotation Failed",
body: `Your **${rotationType}** rotation **${rotationName}** failed to rotate.`,
link: rotationPath
}))
);
await smtpService.sendMail({
recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean),
template: SmtpTemplates.SecretRotationFailed,
@@ -165,9 +182,7 @@ export const secretRotationV2QueueServiceFactory = async ({
secretPath: folder.path,
environment: environment.name,
projectName: project.name,
rotationUrl: encodeURI(
`${appCfg.SITE_URL}/projects/secret-management/${projectId}/secrets/${environment.slug}`
)
rotationUrl: encodeURI(`${appCfg.SITE_URL}${rotationPath}`)
}
});
} catch (error) {
@@ -85,11 +85,12 @@ import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/se
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
import { awsIamUserSecretRotationFactory } from "./aws-iam-user-secret/aws-iam-user-secret-rotation-fns";
import { oktaClientSecretRotationFactory } from "./okta-client-secret/okta-client-secret-rotation-fns";
import { redisCredentialsRotationFactory } from "./redis-credentials/redis-credentials-rotation-fns";
import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal";
export type TSecretRotationV2ServiceFactoryDep = {
secretRotationV2DAL: TSecretRotationV2DALFactory;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
@@ -132,7 +133,8 @@ const SECRET_ROTATION_FACTORY_MAP: Record<SecretRotation, TRotationFactoryImplem
[SecretRotation.AzureClientSecret]: azureClientSecretRotationFactory as TRotationFactoryImplementation,
[SecretRotation.AwsIamUserSecret]: awsIamUserSecretRotationFactory as TRotationFactoryImplementation,
[SecretRotation.LdapPassword]: ldapPasswordRotationFactory as TRotationFactoryImplementation,
[SecretRotation.OktaClientSecret]: oktaClientSecretRotationFactory as TRotationFactoryImplementation
[SecretRotation.OktaClientSecret]: oktaClientSecretRotationFactory as TRotationFactoryImplementation,
[SecretRotation.RedisCredentials]: redisCredentialsRotationFactory as TRotationFactoryImplementation
};
export const secretRotationV2ServiceFactory = ({
@@ -459,7 +461,11 @@ export const secretRotationV2ServiceFactory = ({
const typeApp = SECRET_ROTATION_CONNECTION_MAP[payload.type];
// validates permission to connect and app is valid for rotation type
const connection = await appConnectionService.connectAppConnectionById(typeApp, payload.connectionId, actor);
const connection = await appConnectionService.validateAppConnectionUsageById(
typeApp,
{ connectionId: payload.connectionId, projectId },
actor
);
const rotationFactory = SECRET_ROTATION_FACTORY_MAP[payload.type](
{
@@ -66,6 +66,13 @@ import {
TPostgresCredentialsRotationListItem,
TPostgresCredentialsRotationWithConnection
} from "./postgres-credentials";
import {
TRedisCredentialsRotation,
TRedisCredentialsRotationGeneratedCredentials,
TRedisCredentialsRotationInput,
TRedisCredentialsRotationListItem,
TRedisCredentialsRotationWithConnection
} from "./redis-credentials/redis-credentials-rotation-types";
import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal";
import { SecretRotation } from "./secret-rotation-v2-enums";
@@ -78,7 +85,8 @@ export type TSecretRotationV2 =
| TAzureClientSecretRotation
| TLdapPasswordRotation
| TAwsIamUserSecretRotation
| TOktaClientSecretRotation;
| TOktaClientSecretRotation
| TRedisCredentialsRotation;
export type TSecretRotationV2WithConnection =
| TPostgresCredentialsRotationWithConnection
@@ -89,7 +97,8 @@ export type TSecretRotationV2WithConnection =
| TAzureClientSecretRotationWithConnection
| TLdapPasswordRotationWithConnection
| TAwsIamUserSecretRotationWithConnection
| TOktaClientSecretRotationWithConnection;
| TOktaClientSecretRotationWithConnection
| TRedisCredentialsRotationWithConnection;
export type TSecretRotationV2GeneratedCredentials =
| TSqlCredentialsRotationGeneratedCredentials
@@ -97,7 +106,8 @@ export type TSecretRotationV2GeneratedCredentials =
| TAzureClientSecretRotationGeneratedCredentials
| TLdapPasswordRotationGeneratedCredentials
| TAwsIamUserSecretRotationGeneratedCredentials
| TOktaClientSecretRotationGeneratedCredentials;
| TOktaClientSecretRotationGeneratedCredentials
| TRedisCredentialsRotationGeneratedCredentials;
export type TSecretRotationV2Input =
| TPostgresCredentialsRotationInput
@@ -108,7 +118,8 @@ export type TSecretRotationV2Input =
| TAzureClientSecretRotationInput
| TLdapPasswordRotationInput
| TAwsIamUserSecretRotationInput
| TOktaClientSecretRotationInput;
| TOktaClientSecretRotationInput
| TRedisCredentialsRotationInput;
export type TSecretRotationV2ListItem =
| TPostgresCredentialsRotationListItem
@@ -119,7 +130,8 @@ export type TSecretRotationV2ListItem =
| TAzureClientSecretRotationListItem
| TLdapPasswordRotationListItem
| TAwsIamUserSecretRotationListItem
| TOktaClientSecretRotationListItem;
| TOktaClientSecretRotationListItem
| TRedisCredentialsRotationListItem;
export type TSecretRotationV2TemporaryParameters = TLdapPasswordRotationInput["temporaryParameters"] | undefined;
@@ -9,6 +9,7 @@ import { MySqlCredentialsRotationSchema } from "@app/ee/services/secret-rotation
import { OktaClientSecretRotationSchema } from "@app/ee/services/secret-rotation-v2/okta-client-secret";
import { OracleDBCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/oracledb-credentials";
import { PostgresCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials";
import { RedisCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/redis-credentials";
export const SecretRotationV2Schema = z.discriminatedUnion("type", [
PostgresCredentialsRotationSchema,
@@ -19,5 +20,6 @@ export const SecretRotationV2Schema = z.discriminatedUnion("type", [
AzureClientSecretRotationSchema,
LdapPasswordRotationSchema,
AwsIamUserSecretRotationSchema,
OktaClientSecretRotationSchema
OktaClientSecretRotationSchema,
RedisCredentialsRotationSchema
]);
@@ -431,7 +431,7 @@ export const secretRotationQueueFactory = ({
numberOfSecrets: numberOfSecretsRotated,
environment: secretRotation.environment.slug,
secretPath: secretRotation.secretPath,
workspaceId: secretRotation.projectId
projectId: secretRotation.projectId
}
});
@@ -50,6 +50,7 @@ const baseSecretScanningDataSourceQuery = ({
db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"),
db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"),
db.ref("gatewayId").withSchema(TableName.AppConnection).as("connectionGatewayId"),
db.ref("projectId").withSchema(TableName.AppConnection).as("connectionProjectId"),
db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"),
db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"),
db
@@ -84,6 +85,7 @@ const expandSecretScanningDataSource = <
connectionVersion,
connectionIsPlatformManagedCredentials,
connectionGatewayId,
connectionProjectId,
...el
} = dataSource;
@@ -103,7 +105,8 @@ const expandSecretScanningDataSource = <
updatedAt: connectionUpdatedAt,
version: connectionVersion,
isPlatformManagedCredentials: connectionIsPlatformManagedCredentials,
gatewayId: connectionGatewayId
gatewayId: connectionGatewayId,
projectId: connectionProjectId
}
: undefined
};
@@ -21,6 +21,8 @@ import { decryptAppConnection } from "@app/services/app-connection/app-connectio
import { TAppConnection } from "@app/services/app-connection/app-connection-types";
import { ActorType } from "@app/services/auth/auth-type";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TNotificationServiceFactory } from "@app/services/notification/notification-service";
import { NotificationType } from "@app/services/notification/notification-types";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
@@ -52,6 +54,7 @@ type TSecretRotationV2QueueServiceFactoryDep = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
keyStore: Pick<TKeyStoreFactory, "acquireLock" | "getItem">;
notificationService: Pick<TNotificationServiceFactory, "createUserNotifications">;
};
export type TSecretScanningV2QueueServiceFactory = Awaited<ReturnType<typeof secretScanningV2QueueServiceFactory>>;
@@ -65,7 +68,8 @@ export const secretScanningV2QueueServiceFactory = async ({
kmsService,
auditLogService,
keyStore,
appConnectionDAL
appConnectionDAL,
notificationService
}: TSecretRotationV2QueueServiceFactoryDep) => {
const queueDataSourceFullScan = async (
dataSource: TSecretScanningDataSourceWithConnection,
@@ -592,16 +596,38 @@ export const secretScanningV2QueueServiceFactory = async ({
const timestamp = new Date().toISOString();
const subjectLine =
payload.status === SecretScanningScanStatus.Completed
? "Incident Alert: Secret(s) Leaked"
: `Secret Scanning Failed`;
await notificationService.createUserNotifications(
recipients.map((member) => ({
userId: member.userId,
orgId: project.orgId,
type:
payload.status === SecretScanningScanStatus.Completed
? NotificationType.SECRET_SCANNING_SECRETS_DETECTED
: NotificationType.SECRET_SCANNING_SCAN_FAILED,
title: subjectLine,
body:
payload.status === SecretScanningScanStatus.Completed
? `Uncovered **${payload.numberOfSecrets}** secret(s) ${payload.isDiffScan ? " from a recent commit to" : " in"} **${resourceName}**.`
: `Encountered an error while attempting to scan the resource **${resourceName}**: ${payload.errorMessage}`,
link:
payload.status === SecretScanningScanStatus.Completed
? `/projects/secret-scanning/${projectId}/findings?search=scanId:${payload.scanId}`
: `/projects/secret-scanning/${projectId}/data-sources/${dataSource.type}/${dataSource.id}`
}))
);
await smtpService.sendMail({
recipients: recipients.map((member) => member.user.email!).filter(Boolean),
template:
payload.status === SecretScanningScanStatus.Completed
? SmtpTemplates.SecretScanningV2SecretsDetected
: SmtpTemplates.SecretScanningV2ScanFailed,
subjectLine:
payload.status === SecretScanningScanStatus.Completed
? "Incident Alert: Secret(s) Leaked"
: `Secret Scanning Failed`,
subjectLine,
substitutions:
payload.status === SecretScanningScanStatus.Completed
? {
@@ -60,7 +60,7 @@ import { TSecretScanningV2QueueServiceFactory } from "./secret-scanning-v2-queue
export type TSecretScanningV2ServiceFactoryDep = {
secretScanningV2DAL: TSecretScanningV2DALFactory;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
@@ -252,9 +252,9 @@ export const secretScanningV2ServiceFactory = ({
let connection: TAppConnection | null = null;
if (payload.connectionId) {
// validates permission to connect and app is valid for data source
connection = await appConnectionService.connectAppConnectionById(
connection = await appConnectionService.validateAppConnectionUsageById(
SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[payload.type],
payload.connectionId,
{ connectionId: payload.connectionId, projectId: payload.projectId },
actor
);
}
@@ -373,9 +373,9 @@ export const secretScanningV2ServiceFactory = ({
let connection: TAppConnection | null = null;
if (dataSource.connectionId) {
// validates permission to connect and app is valid for data source
connection = await appConnectionService.connectAppConnectionById(
connection = await appConnectionService.validateAppConnectionUsageById(
SECRET_SCANNING_DATA_SOURCE_CONNECTION_MAP[dataSource.type],
dataSource.connectionId,
{ connectionId: dataSource.connectionId, projectId: dataSource.projectId },
actor
);
}