Merge branch 'heads/main' into daniel/aws-auth-3

This commit is contained in:
Daniel Hougaard
2025-10-22 23:23:14 +04:00
52 changed files with 1087 additions and 270 deletions
@@ -0,0 +1,49 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.PamAccount, "rotationEnabled"))) {
await knex.schema.alterTable(TableName.PamAccount, (t) => {
t.boolean("rotationEnabled").notNullable().defaultTo(false);
});
}
if (!(await knex.schema.hasColumn(TableName.PamAccount, "rotationIntervalSeconds"))) {
await knex.schema.alterTable(TableName.PamAccount, (t) => {
t.integer("rotationIntervalSeconds").nullable();
});
}
if (!(await knex.schema.hasColumn(TableName.PamAccount, "lastRotatedAt"))) {
await knex.schema.alterTable(TableName.PamAccount, (t) => {
t.timestamp("lastRotatedAt").nullable();
});
}
if (!(await knex.schema.hasColumn(TableName.PamResource, "encryptedRotationAccountCredentials"))) {
await knex.schema.alterTable(TableName.PamResource, (t) => {
t.binary("encryptedRotationAccountCredentials").nullable();
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.PamResource, "encryptedRotationAccountCredentials")) {
await knex.schema.alterTable(TableName.PamResource, (t) => {
t.dropColumn("encryptedRotationAccountCredentials");
});
}
if (await knex.schema.hasColumn(TableName.PamAccount, "rotationEnabled")) {
await knex.schema.alterTable(TableName.PamAccount, (t) => {
t.dropColumn("rotationEnabled");
});
}
if (await knex.schema.hasColumn(TableName.PamAccount, "rotationIntervalSeconds")) {
await knex.schema.alterTable(TableName.PamAccount, (t) => {
t.dropColumn("rotationIntervalSeconds");
});
}
if (await knex.schema.hasColumn(TableName.PamAccount, "lastRotatedAt")) {
await knex.schema.alterTable(TableName.PamAccount, (t) => {
t.dropColumn("lastRotatedAt");
});
}
}
+4 -1
View File
@@ -18,7 +18,10 @@ export const PamAccountsSchema = z.object({
description: z.string().nullable().optional(), description: z.string().nullable().optional(),
encryptedCredentials: zodBuffer, encryptedCredentials: zodBuffer,
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
rotationEnabled: z.boolean().default(false),
rotationIntervalSeconds: z.number().nullable().optional(),
lastRotatedAt: z.date().nullable().optional()
}); });
export type TPamAccounts = z.infer<typeof PamAccountsSchema>; export type TPamAccounts = z.infer<typeof PamAccountsSchema>;
+2 -1
View File
@@ -17,7 +17,8 @@ export const PamResourcesSchema = z.object({
resourceType: z.string(), resourceType: z.string(),
encryptedConnectionDetails: zodBuffer, encryptedConnectionDetails: zodBuffer,
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
encryptedRotationAccountCredentials: zodBuffer.nullable().optional()
}); });
export type TPamResources = z.infer<typeof PamResourcesSchema>; export type TPamResources = z.infer<typeof PamResourcesSchema>;
@@ -22,11 +22,15 @@ export const registerPamResourceEndpoints = <C extends TPamAccount>({
folderId?: C["folderId"]; folderId?: C["folderId"];
name: C["name"]; name: C["name"];
description?: C["description"]; description?: C["description"];
rotationEnabled: C["rotationEnabled"];
rotationIntervalSeconds?: C["rotationIntervalSeconds"];
}>; }>;
updateAccountSchema: z.ZodType<{ updateAccountSchema: z.ZodType<{
credentials?: C["credentials"]; credentials?: C["credentials"];
name?: C["name"]; name?: C["name"];
description?: C["description"]; description?: C["description"];
rotationEnabled?: C["rotationEnabled"];
rotationIntervalSeconds?: C["rotationIntervalSeconds"];
}>; }>;
accountResponseSchema: z.ZodTypeAny; accountResponseSchema: z.ZodTypeAny;
}) => { }) => {
@@ -60,7 +64,9 @@ export const registerPamResourceEndpoints = <C extends TPamAccount>({
resourceType, resourceType,
folderId: req.body.folderId, folderId: req.body.folderId,
name: req.body.name, name: req.body.name,
description: req.body.description description: req.body.description,
rotationEnabled: req.body.rotationEnabled,
rotationIntervalSeconds: req.body.rotationIntervalSeconds
} }
} }
}); });
@@ -108,7 +114,9 @@ export const registerPamResourceEndpoints = <C extends TPamAccount>({
resourceId: account.resourceId, resourceId: account.resourceId,
resourceType, resourceType,
name: req.body.name, name: req.body.name,
description: req.body.description description: req.body.description,
rotationEnabled: req.body.rotationEnabled,
rotationIntervalSeconds: req.body.rotationIntervalSeconds
} }
} }
}); });
@@ -1,7 +1,7 @@
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
import { import {
CreatePostgresResourceSchema, CreatePostgresResourceSchema,
PostgresResourceSchema, SanitizedPostgresResourceSchema,
UpdatePostgresResourceSchema UpdatePostgresResourceSchema
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
@@ -12,7 +12,7 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record<PamResource, (server: Fast
registerPamResourceEndpoints({ registerPamResourceEndpoints({
server, server,
resourceType: PamResource.Postgres, resourceType: PamResource.Postgres,
resourceResponseSchema: PostgresResourceSchema, resourceResponseSchema: SanitizedPostgresResourceSchema,
createResourceSchema: CreatePostgresResourceSchema, createResourceSchema: CreatePostgresResourceSchema,
updateResourceSchema: UpdatePostgresResourceSchema updateResourceSchema: UpdatePostgresResourceSchema
}); });
@@ -21,11 +21,13 @@ export const registerPamResourceEndpoints = <T extends TPamResource>({
connectionDetails: T["connectionDetails"]; connectionDetails: T["connectionDetails"];
gatewayId: T["gatewayId"]; gatewayId: T["gatewayId"];
name: T["name"]; name: T["name"];
rotationAccountCredentials?: T["rotationAccountCredentials"];
}>; }>;
updateResourceSchema: z.ZodType<{ updateResourceSchema: z.ZodType<{
connectionDetails?: T["connectionDetails"]; connectionDetails?: T["connectionDetails"];
gatewayId?: T["gatewayId"]; gatewayId?: T["gatewayId"];
name?: T["name"]; name?: T["name"];
rotationAccountCredentials?: T["rotationAccountCredentials"];
}>; }>;
resourceResponseSchema: z.ZodTypeAny; resourceResponseSchema: z.ZodTypeAny;
}) => { }) => {
@@ -3,14 +3,14 @@ import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { import {
PostgresResourceListItemSchema, PostgresResourceListItemSchema,
PostgresResourceSchema SanitizedPostgresResourceSchema
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import { readLimit } from "@app/server/config/rateLimiter"; import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
// Use z.union([...]) when more resources are added // Use z.union([...]) when more resources are added
const ResourceSchema = PostgresResourceSchema; const SanitizedResourceSchema = SanitizedPostgresResourceSchema;
const ResourceOptionsSchema = z.discriminatedUnion("resource", [PostgresResourceListItemSchema]); const ResourceOptionsSchema = z.discriminatedUnion("resource", [PostgresResourceListItemSchema]);
@@ -50,7 +50,7 @@ export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
}), }),
response: { response: {
200: z.object({ 200: z.object({
resources: ResourceSchema.array() resources: SanitizedResourceSchema.array()
}) })
} }
}, },
@@ -527,6 +527,8 @@ export enum EventType {
PAM_ACCOUNT_CREATE = "pam-account-create", PAM_ACCOUNT_CREATE = "pam-account-create",
PAM_ACCOUNT_UPDATE = "pam-account-update", PAM_ACCOUNT_UPDATE = "pam-account-update",
PAM_ACCOUNT_DELETE = "pam-account-delete", PAM_ACCOUNT_DELETE = "pam-account-delete",
PAM_ACCOUNT_CREDENTIAL_ROTATION = "pam-account-credential-rotation",
PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED = "pam-account-credential-rotation-failed",
PAM_RESOURCE_LIST = "pam-resource-list", PAM_RESOURCE_LIST = "pam-resource-list",
PAM_RESOURCE_GET = "pam-resource-get", PAM_RESOURCE_GET = "pam-resource-get",
PAM_RESOURCE_CREATE = "pam-resource-create", PAM_RESOURCE_CREATE = "pam-resource-create",
@@ -3915,6 +3917,8 @@ interface PamAccountCreateEvent {
folderId?: string | null; folderId?: string | null;
name: string; name: string;
description?: string | null; description?: string | null;
rotationEnabled: boolean;
rotationIntervalSeconds?: number | null;
}; };
} }
@@ -3926,6 +3930,8 @@ interface PamAccountUpdateEvent {
resourceType: string; resourceType: string;
name?: string; name?: string;
description?: string | null; description?: string | null;
rotationEnabled?: boolean;
rotationIntervalSeconds?: number | null;
}; };
} }
@@ -3939,6 +3945,27 @@ interface PamAccountDeleteEvent {
}; };
} }
interface PamAccountCredentialRotationEvent {
type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION;
metadata: {
accountName: string;
accountId: string;
resourceId: string;
resourceType: string;
};
}
interface PamAccountCredentialRotationFailedEvent {
type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED;
metadata: {
accountName: string;
accountId: string;
resourceId: string;
resourceType: string;
errorMessage: string;
};
}
interface PamResourceListEvent { interface PamResourceListEvent {
type: EventType.PAM_RESOURCE_LIST; type: EventType.PAM_RESOURCE_LIST;
metadata: { metadata: {
@@ -4340,6 +4367,8 @@ export type Event =
| PamAccountCreateEvent | PamAccountCreateEvent
| PamAccountUpdateEvent | PamAccountUpdateEvent
| PamAccountDeleteEvent | PamAccountDeleteEvent
| PamAccountCredentialRotationEvent
| PamAccountCredentialRotationFailedEvent
| PamResourceListEvent | PamResourceListEvent
| PamResourceGetEvent | PamResourceGetEvent
| PamResourceCreateEvent | PamResourceCreateEvent
@@ -216,9 +216,8 @@ export const licenseServiceFactory = ({
const membersUsed = await licenseDAL.countOfOrgMembers(rootOrgId); const membersUsed = await licenseDAL.countOfOrgMembers(rootOrgId);
currentPlan.membersUsed = membersUsed; currentPlan.membersUsed = membersUsed;
const identityUsed = await licenseDAL.countOrgUsersAndIdentities(rootOrgId); const identityUsed = await licenseDAL.countOrgUsersAndIdentities(rootOrgId);
currentPlan.identitiesUsed = identityUsed;
if (currentPlan.identityLimit && currentPlan.identityLimit !== identityUsed) { if (currentPlan?.identitiesUsed && currentPlan.identitiesUsed !== identityUsed) {
try { try {
await licenseServerCloudApi.request.patch(`/api/license-server/v1/customers/${org.customerId}/cloud-plan`, { await licenseServerCloudApi.request.patch(`/api/license-server/v1/customers/${org.customerId}/cloud-plan`, {
quantity: membersUsed, quantity: membersUsed,
@@ -231,6 +230,7 @@ export const licenseServiceFactory = ({
); );
} }
} }
currentPlan.identitiesUsed = identityUsed;
await keyStore.setItemWithExpiry( await keyStore.setItemWithExpiry(
FEATURE_CACHE_KEY(org.id), FEATURE_CACHE_KEY(org.id),
@@ -18,7 +18,8 @@ export const pamAccountDALFactory = (db: TDbClient) => {
.select( .select(
// resource // resource
db.ref("name").withSchema(TableName.PamResource).as("resourceName"), db.ref("name").withSchema(TableName.PamResource).as("resourceName"),
db.ref("resourceType").withSchema(TableName.PamResource) db.ref("resourceType").withSchema(TableName.PamResource),
db.ref("encryptedRotationAccountCredentials").withSchema(TableName.PamResource)
); );
if (filter) { if (filter) {
@@ -28,16 +29,35 @@ export const pamAccountDALFactory = (db: TDbClient) => {
const accounts = await query; const accounts = await query;
return accounts.map(({ resourceId, resourceName, resourceType, ...account }) => ({ return accounts.map(
...account, ({ resourceId, resourceName, resourceType, encryptedRotationAccountCredentials, ...account }) => ({
resourceId, ...account,
resource: { resourceId,
id: resourceId, resource: {
name: resourceName, id: resourceId,
resourceType name: resourceName,
} resourceType,
})); encryptedRotationAccountCredentials
}
})
);
}; };
return { ...orm, findWithResourceDetails }; const findAccountsDueForRotation = async (tx?: Knex) => {
const dbClient = tx || db.replicaNode();
const accounts = await dbClient(TableName.PamAccount)
.innerJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`)
.whereNotNull(`${TableName.PamResource}.encryptedRotationAccountCredentials`)
.whereNotNull(`${TableName.PamAccount}.rotationIntervalSeconds`)
.where(`${TableName.PamAccount}.rotationEnabled`, true)
.whereRaw(
`COALESCE("${TableName.PamAccount}"."lastRotatedAt", "${TableName.PamAccount}"."createdAt") + "${TableName.PamAccount}"."rotationIntervalSeconds" * interval '1 second' < NOW()`
)
.select(selectAllTableCols(TableName.PamAccount));
return accounts;
};
return { ...orm, findWithResourceDetails, findAccountsDueForRotation };
}; };
@@ -11,12 +11,14 @@ import {
} from "@app/ee/services/permission/project-permission"; } from "@app/ee/services/permission/project-permission";
import { DatabaseErrorCode } from "@app/lib/error-codes"; import { DatabaseErrorCode } from "@app/lib/error-codes";
import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types"; import { OrgServiceActor } from "@app/lib/types";
import { ActorType } from "@app/services/auth/auth-type"; import { ActorType } from "@app/services/auth/auth-type";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserDALFactory } from "@app/services/user/user-dal";
import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types";
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
import { TLicenseServiceFactory } from "../license/license-service"; import { TLicenseServiceFactory } from "../license/license-service";
import { TPamFolderDALFactory } from "../pam-folder/pam-folder-dal"; import { TPamFolderDALFactory } from "../pam-folder/pam-folder-dal";
@@ -45,10 +47,12 @@ type TPamAccountServiceFactoryDep = {
"getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId" "getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId"
>; >;
userDAL: TUserDALFactory; userDAL: TUserDALFactory;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
}; };
export type TPamAccountServiceFactory = ReturnType<typeof pamAccountServiceFactory>; export type TPamAccountServiceFactory = ReturnType<typeof pamAccountServiceFactory>;
const ROTATION_CONCURRENCY_LIMIT = 10;
export const pamAccountServiceFactory = ({ export const pamAccountServiceFactory = ({
pamResourceDAL, pamResourceDAL,
pamSessionDAL, pamSessionDAL,
@@ -59,10 +63,19 @@ export const pamAccountServiceFactory = ({
permissionService, permissionService,
licenseService, licenseService,
kmsService, kmsService,
gatewayV2Service gatewayV2Service,
auditLogService
}: TPamAccountServiceFactoryDep) => { }: TPamAccountServiceFactoryDep) => {
const create = async ( const create = async (
{ credentials, resourceId, name, description, folderId }: TCreateAccountDTO, {
credentials,
resourceId,
name,
description,
folderId,
rotationEnabled,
rotationIntervalSeconds
}: TCreateAccountDTO,
actor: OrgServiceActor actor: OrgServiceActor
) => { ) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId); const orgLicensePlan = await licenseService.getPlan(actor.orgId);
@@ -72,6 +85,12 @@ export const pamAccountServiceFactory = ({
}); });
} }
if (rotationEnabled && (rotationIntervalSeconds === undefined || rotationIntervalSeconds === null)) {
throw new BadRequestError({
message: "Rotation interval must be defined when rotation is enabled."
});
}
const resource = await pamResourceDAL.findById(resourceId); const resource = await pamResourceDAL.findById(resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` }); if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` });
@@ -84,6 +103,10 @@ export const pamAccountServiceFactory = ({
actionProjectType: ActionProjectType.PAM actionProjectType: ActionProjectType.PAM
}); });
if (!resource.encryptedRotationAccountCredentials && rotationEnabled) {
throw new NotFoundError({ message: "Rotation credentials are not configured for this account's resource" });
}
const accountPath = await getFullPamFolderPath({ const accountPath = await getFullPamFolderPath({
pamFolderDAL, pamFolderDAL,
folderId, folderId,
@@ -126,12 +149,19 @@ export const pamAccountServiceFactory = ({
encryptedCredentials, encryptedCredentials,
name, name,
description, description,
folderId folderId,
rotationEnabled,
rotationIntervalSeconds
}); });
return { return {
...(await decryptAccount(account, resource.projectId, kmsService)), ...(await decryptAccount(account, resource.projectId, kmsService)),
resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } resource: {
id: resource.id,
name: resource.name,
resourceType: resource.resourceType,
rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials
}
}; };
} catch (err) { } catch (err) {
if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) {
@@ -145,7 +175,7 @@ export const pamAccountServiceFactory = ({
}; };
const updateById = async ( const updateById = async (
{ accountId, credentials, description, name }: TUpdateAccountDTO, { accountId, credentials, description, name, rotationEnabled, rotationIntervalSeconds }: TUpdateAccountDTO,
actor: OrgServiceActor actor: OrgServiceActor
) => { ) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId); const orgLicensePlan = await licenseService.getPlan(actor.orgId);
@@ -195,6 +225,17 @@ export const pamAccountServiceFactory = ({
updateDoc.description = description; updateDoc.description = description;
} }
if (rotationEnabled !== undefined) {
if (!resource.encryptedRotationAccountCredentials && rotationEnabled) {
throw new NotFoundError({ message: "Rotation credentials are not configured for this account's resource" });
}
updateDoc.rotationEnabled = rotationEnabled;
}
if (rotationIntervalSeconds !== undefined) {
updateDoc.rotationIntervalSeconds = rotationIntervalSeconds;
}
if (credentials !== undefined) { if (credentials !== undefined) {
const connectionDetails = await decryptResourceConnectionDetails({ const connectionDetails = await decryptResourceConnectionDetails({
projectId: account.projectId, projectId: account.projectId,
@@ -211,7 +252,7 @@ export const pamAccountServiceFactory = ({
// Logic to prevent overwriting unedited censored values // Logic to prevent overwriting unedited censored values
const finalCredentials = { ...credentials }; const finalCredentials = { ...credentials };
if (credentials.password === "******") { if (credentials.password === "__INFISICAL_UNCHANGED__") {
const decryptedCredentials = await decryptAccountCredentials({ const decryptedCredentials = await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials, encryptedCredentials: account.encryptedCredentials,
projectId: account.projectId, projectId: account.projectId,
@@ -239,7 +280,12 @@ export const pamAccountServiceFactory = ({
return { return {
...(await decryptAccount(updatedAccount, account.projectId, kmsService)), ...(await decryptAccount(updatedAccount, account.projectId, kmsService)),
resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } resource: {
id: resource.id,
name: resource.name,
resourceType: resource.resourceType,
rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials
}
}; };
}; };
@@ -278,7 +324,12 @@ export const pamAccountServiceFactory = ({
return { return {
...(await decryptAccount(deletedAccount, account.projectId, kmsService)), ...(await decryptAccount(deletedAccount, account.projectId, kmsService)),
resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } resource: {
id: resource.id,
name: resource.name,
resourceType: resource.resourceType,
rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials
}
}; };
}; };
@@ -300,7 +351,7 @@ export const pamAccountServiceFactory = ({
const decryptedAndPermittedAccounts: Array< const decryptedAndPermittedAccounts: Array<
TPamAccounts & { TPamAccounts & {
resource: Pick<TPamResources, "id" | "name" | "resourceType">; resource: Pick<TPamResources, "id" | "name" | "resourceType"> & { rotationCredentialsConfigured: boolean };
credentials: TPamAccountCredentials; credentials: TPamAccountCredentials;
} }
> = []; > = [];
@@ -330,7 +381,8 @@ export const pamAccountServiceFactory = ({
resource: { resource: {
id: account.resource.id, id: account.resource.id,
name: account.resource.name, name: account.resource.name,
resourceType: account.resource.resourceType resourceType: account.resource.resourceType,
rotationCredentialsConfigured: !!account.resource.encryptedRotationAccountCredentials
} }
}); });
} }
@@ -517,12 +569,116 @@ export const pamAccountServiceFactory = ({
}; };
}; };
const rotateAllDueAccounts = async () => {
const accounts = await pamAccountDAL.findAccountsDueForRotation();
for (let i = 0; i < accounts.length; i += ROTATION_CONCURRENCY_LIMIT) {
const batch = accounts.slice(i, i + ROTATION_CONCURRENCY_LIMIT);
const rotationPromises = batch.map(async (account) =>
pamAccountDAL.transaction(async (tx) => {
let logResourceType = "unknown";
try {
const resource = await pamResourceDAL.findById(account.resourceId, tx);
if (!resource || !resource.encryptedRotationAccountCredentials) return;
logResourceType = resource.resourceType;
const { connectionDetails, rotationAccountCredentials, gatewayId, resourceType } = await decryptResource(
resource,
account.projectId,
kmsService
);
if (!rotationAccountCredentials) return;
const accountCredentials = await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
projectId: account.projectId,
kmsService
});
const factory = PAM_RESOURCE_FACTORY_MAP[resourceType as PamResource](
resourceType as PamResource,
connectionDetails,
gatewayId,
gatewayV2Service
);
const newCredentials = await factory.rotateAccountCredentials(
rotationAccountCredentials,
accountCredentials
);
const encryptedCredentials = await encryptAccountCredentials({
credentials: newCredentials,
projectId: account.projectId,
kmsService
});
await pamAccountDAL.updateById(
account.id,
{
encryptedCredentials,
lastRotatedAt: new Date()
},
tx
);
await auditLogService.createAuditLog({
projectId: account.projectId,
actor: {
type: ActorType.PLATFORM,
metadata: {}
},
event: {
type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION,
metadata: {
accountId: account.id,
accountName: account.name,
resourceId: resource.id,
resourceType: logResourceType
}
}
});
} catch (error) {
logger.error(error, `Failed to rotate credentials for account [accountId=${account.id}]`);
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred";
await auditLogService.createAuditLog({
projectId: account.projectId,
actor: {
type: ActorType.PLATFORM,
metadata: {}
},
event: {
type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED,
metadata: {
accountId: account.id,
accountName: account.name,
resourceId: account.resourceId,
resourceType: logResourceType,
errorMessage
}
}
});
throw error; // Rollback transaction
}
})
);
// eslint-disable-next-line no-await-in-loop
await Promise.all(rotationPromises);
}
};
return { return {
create, create,
updateById, updateById,
deleteById, deleteById,
list, list,
access, access,
getSessionCredentials getSessionCredentials,
rotateAllDueAccounts
}; };
}; };
@@ -1,7 +1,10 @@
import { TPamAccount } from "../pam-resource/pam-resource-types"; import { TPamAccount } from "../pam-resource/pam-resource-types";
// DTOs // DTOs
export type TCreateAccountDTO = Pick<TPamAccount, "name" | "description" | "credentials" | "folderId" | "resourceId">; export type TCreateAccountDTO = Pick<
TPamAccount,
"name" | "description" | "credentials" | "folderId" | "resourceId" | "rotationEnabled" | "rotationIntervalSeconds"
>;
export type TUpdateAccountDTO = Partial<Omit<TCreateAccountDTO, "folderId" | "resourceId">> & { export type TUpdateAccountDTO = Partial<Omit<TCreateAccountDTO, "folderId" | "resourceId">> & {
accountId: string; accountId: string;
@@ -2,6 +2,7 @@ import { TPamResources } from "@app/db/schemas";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types"; import { KmsDataKey } from "@app/services/kms/kms-types";
import { decryptAccountCredentials } from "../pam-account/pam-account-fns";
import { TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types"; import { TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types";
import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns"; import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns";
@@ -63,6 +64,13 @@ export const decryptResource = async (
encryptedConnectionDetails: resource.encryptedConnectionDetails, encryptedConnectionDetails: resource.encryptedConnectionDetails,
projectId, projectId,
kmsService kmsService
}) }),
rotationAccountCredentials: resource.encryptedRotationAccountCredentials
? await decryptAccountCredentials({
encryptedCredentials: resource.encryptedRotationAccountCredentials,
projectId,
kmsService
})
: null
} as TPamResource; } as TPamResource;
}; };
@@ -6,6 +6,7 @@ import { slugSchema } from "@app/server/lib/schemas";
// Resources // Resources
export const BasePamResourceSchema = PamResourcesSchema.omit({ export const BasePamResourceSchema = PamResourcesSchema.omit({
encryptedConnectionDetails: true, encryptedConnectionDetails: true,
encryptedRotationAccountCredentials: true,
resourceType: true resourceType: true
}); });
@@ -30,6 +31,8 @@ export const BasePamAccountSchemaWithResource = BasePamAccountSchema.extend({
id: true, id: true,
name: true, name: true,
resourceType: true resourceType: true
}).extend({
rotationCredentialsConfigured: z.boolean()
}) })
}); });
@@ -37,10 +40,14 @@ export const BaseCreatePamAccountSchema = z.object({
resourceId: z.string().uuid(), resourceId: z.string().uuid(),
folderId: z.string().uuid().optional(), folderId: z.string().uuid().optional(),
name: slugSchema({ field: "name" }), name: slugSchema({ field: "name" }),
description: z.string().max(512).nullable().optional() description: z.string().max(512).nullable().optional(),
rotationEnabled: z.boolean(),
rotationIntervalSeconds: z.number().min(3600).nullable().optional()
}); });
export const BaseUpdatePamAccountSchema = z.object({ export const BaseUpdatePamAccountSchema = z.object({
name: slugSchema({ field: "name" }).optional(), name: slugSchema({ field: "name" }).optional(),
description: z.string().max(512).nullable().optional() description: z.string().max(512).nullable().optional(),
rotationEnabled: z.boolean().optional(),
rotationIntervalSeconds: z.number().min(3600).nullable().optional()
}); });
@@ -10,10 +10,16 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
import { TLicenseServiceFactory } from "../license/license-service"; import { TLicenseServiceFactory } from "../license/license-service";
import { decryptAccountCredentials, encryptAccountCredentials } from "../pam-account/pam-account-fns";
import { TPamResourceDALFactory } from "./pam-resource-dal"; import { TPamResourceDALFactory } from "./pam-resource-dal";
import { PamResource } from "./pam-resource-enums"; import { PamResource } from "./pam-resource-enums";
import { PAM_RESOURCE_FACTORY_MAP } from "./pam-resource-factory"; import { PAM_RESOURCE_FACTORY_MAP } from "./pam-resource-factory";
import { decryptResource, encryptResourceConnectionDetails, listResourceOptions } from "./pam-resource-fns"; import {
decryptResource,
decryptResourceConnectionDetails,
encryptResourceConnectionDetails,
listResourceOptions
} from "./pam-resource-fns";
import { TCreateResourceDTO, TUpdateResourceDTO } from "./pam-resource-types"; import { TCreateResourceDTO, TUpdateResourceDTO } from "./pam-resource-types";
type TPamResourceServiceFactoryDep = { type TPamResourceServiceFactoryDep = {
@@ -61,7 +67,7 @@ export const pamResourceServiceFactory = ({
}; };
const create = async ( const create = async (
{ resourceType, connectionDetails, gatewayId, name, projectId }: TCreateResourceDTO, { resourceType, connectionDetails, gatewayId, name, projectId, rotationAccountCredentials }: TCreateResourceDTO,
actor: OrgServiceActor actor: OrgServiceActor
) => { ) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId); const orgLicensePlan = await licenseService.getPlan(actor.orgId);
@@ -88,26 +94,42 @@ export const pamResourceServiceFactory = ({
gatewayId, gatewayId,
gatewayV2Service gatewayV2Service
); );
const validatedConnectionDetails = await factory.validateConnection();
const validatedConnectionDetails = await factory.validateConnection();
const encryptedConnectionDetails = await encryptResourceConnectionDetails({ const encryptedConnectionDetails = await encryptResourceConnectionDetails({
connectionDetails: validatedConnectionDetails, connectionDetails: validatedConnectionDetails,
projectId, projectId,
kmsService kmsService
}); });
let encryptedRotationAccountCredentials: Buffer | null = null;
if (rotationAccountCredentials) {
const validatedRotationAccountCredentials = await factory.validateAccountCredentials(rotationAccountCredentials);
encryptedRotationAccountCredentials = await encryptAccountCredentials({
credentials: validatedRotationAccountCredentials,
projectId,
kmsService
});
}
const resource = await pamResourceDAL.create({ const resource = await pamResourceDAL.create({
resourceType, resourceType,
encryptedConnectionDetails, encryptedConnectionDetails,
gatewayId, gatewayId,
name, name,
projectId projectId,
encryptedRotationAccountCredentials
}); });
return decryptResource(resource, projectId, kmsService); return decryptResource(resource, projectId, kmsService);
}; };
const updateById = async ({ connectionDetails, resourceId, name }: TUpdateResourceDTO, actor: OrgServiceActor) => { const updateById = async (
{ connectionDetails, resourceId, name, rotationAccountCredentials }: TUpdateResourceDTO,
actor: OrgServiceActor
) => {
const orgLicensePlan = await licenseService.getPlan(actor.orgId); const orgLicensePlan = await licenseService.getPlan(actor.orgId);
if (!orgLicensePlan.pam) { if (!orgLicensePlan.pam) {
throw new BadRequestError({ throw new BadRequestError({
@@ -151,6 +173,60 @@ export const pamResourceServiceFactory = ({
updateDoc.encryptedConnectionDetails = encryptedConnectionDetails; updateDoc.encryptedConnectionDetails = encryptedConnectionDetails;
} }
if (rotationAccountCredentials !== undefined) {
updateDoc.encryptedRotationAccountCredentials = null;
if (rotationAccountCredentials) {
const decryptedConnectionDetails =
connectionDetails ??
(await decryptResourceConnectionDetails({
encryptedConnectionDetails: resource.encryptedConnectionDetails,
projectId: resource.projectId,
kmsService
}));
const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource](
resource.resourceType as PamResource,
decryptedConnectionDetails,
resource.gatewayId,
gatewayV2Service
);
// Logic to prevent overwriting unedited censored values
const finalCredentials = { ...rotationAccountCredentials };
if (
resource.encryptedRotationAccountCredentials &&
rotationAccountCredentials.password === "__INFISICAL_UNCHANGED__"
) {
const decryptedCredentials = await decryptAccountCredentials({
encryptedCredentials: resource.encryptedRotationAccountCredentials,
projectId: resource.projectId,
kmsService
});
finalCredentials.password = decryptedCredentials.password;
}
try {
const validatedRotationAccountCredentials = await factory.validateAccountCredentials(finalCredentials);
updateDoc.encryptedRotationAccountCredentials = await encryptAccountCredentials({
credentials: validatedRotationAccountCredentials,
projectId: resource.projectId,
kmsService
});
} catch (err) {
if (err instanceof BadRequestError) {
throw new BadRequestError({
message: `Rotation Account Error: ${err.message}`
});
}
throw err;
}
}
}
// If nothing was updated, return the fetched resource // If nothing was updated, return the fetched resource
if (Object.keys(updateDoc).length === 0) { if (Object.keys(updateDoc).length === 0) {
return decryptResource(resource, resource.projectId, kmsService); return decryptResource(resource, resource.projectId, kmsService);
@@ -18,7 +18,7 @@ export type TPamAccountCredentials = TPostgresAccountCredentials;
// Resource DTOs // Resource DTOs
export type TCreateResourceDTO = Pick< export type TCreateResourceDTO = Pick<
TPamResource, TPamResource,
"name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId" "name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId" | "rotationAccountCredentials"
>; >;
export type TUpdateResourceDTO = Partial<Omit<TCreateResourceDTO, "resourceType" | "projectId">> & { export type TUpdateResourceDTO = Partial<Omit<TCreateResourceDTO, "resourceType" | "projectId">> & {
@@ -30,6 +30,10 @@ export type TPamResourceFactoryValidateConnection<T extends TPamResourceConnecti
export type TPamResourceFactoryValidateAccountCredentials<C extends TPamAccountCredentials> = ( export type TPamResourceFactoryValidateAccountCredentials<C extends TPamAccountCredentials> = (
credentials: C credentials: C
) => Promise<C>; ) => Promise<C>;
export type TPamResourceFactoryRotateAccountCredentials<C extends TPamAccountCredentials> = (
rotationAccountCredentials: C,
currentCredentials: C
) => Promise<C>;
export type TPamResourceFactory<T extends TPamResourceConnectionDetails, C extends TPamAccountCredentials> = ( export type TPamResourceFactory<T extends TPamResourceConnectionDetails, C extends TPamAccountCredentials> = (
resourceType: PamResource, resourceType: PamResource,
@@ -39,4 +43,5 @@ export type TPamResourceFactory<T extends TPamResourceConnectionDetails, C exten
) => { ) => {
validateConnection: TPamResourceFactoryValidateConnection<T>; validateConnection: TPamResourceFactoryValidateConnection<T>;
validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<C>; validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<C>;
rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials<C>;
}; };
@@ -15,13 +15,24 @@ import {
BaseSqlResourceConnectionDetailsSchema BaseSqlResourceConnectionDetailsSchema
} from "../shared/sql/sql-resource-schemas"; } from "../shared/sql/sql-resource-schemas";
// Resources
export const PostgresResourceConnectionDetailsSchema = BaseSqlResourceConnectionDetailsSchema; export const PostgresResourceConnectionDetailsSchema = BaseSqlResourceConnectionDetailsSchema;
export const PostgresAccountCredentialsSchema = BaseSqlAccountCredentialsSchema;
// Resources
const BasePostgresResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.Postgres) }); const BasePostgresResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.Postgres) });
export const PostgresResourceSchema = BasePostgresResourceSchema.extend({ export const PostgresResourceSchema = BasePostgresResourceSchema.extend({
connectionDetails: PostgresResourceConnectionDetailsSchema connectionDetails: PostgresResourceConnectionDetailsSchema,
rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional()
});
export const SanitizedPostgresResourceSchema = BasePostgresResourceSchema.extend({
connectionDetails: PostgresResourceConnectionDetailsSchema,
rotationAccountCredentials: PostgresAccountCredentialsSchema.pick({
username: true
})
.nullable()
.optional()
}); });
export const PostgresResourceListItemSchema = z.object({ export const PostgresResourceListItemSchema = z.object({
@@ -30,16 +41,16 @@ export const PostgresResourceListItemSchema = z.object({
}); });
export const CreatePostgresResourceSchema = BaseCreatePamResourceSchema.extend({ export const CreatePostgresResourceSchema = BaseCreatePamResourceSchema.extend({
connectionDetails: PostgresResourceConnectionDetailsSchema connectionDetails: PostgresResourceConnectionDetailsSchema,
rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional()
}); });
export const UpdatePostgresResourceSchema = BaseUpdatePamResourceSchema.extend({ export const UpdatePostgresResourceSchema = BaseUpdatePamResourceSchema.extend({
connectionDetails: PostgresResourceConnectionDetailsSchema.optional() connectionDetails: PostgresResourceConnectionDetailsSchema.optional(),
rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional()
}); });
// Accounts // Accounts
export const PostgresAccountCredentialsSchema = BaseSqlAccountCredentialsSchema;
export const PostgresAccountSchema = BasePamAccountSchema.extend({ export const PostgresAccountSchema = BasePamAccountSchema.extend({
credentials: PostgresAccountCredentialsSchema credentials: PostgresAccountCredentialsSchema
}); });
@@ -6,9 +6,14 @@ import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { GatewayProxyProtocol } from "@app/lib/gateway"; import { GatewayProxyProtocol } from "@app/lib/gateway";
import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { PamResource } from "../../pam-resource-enums"; import { PamResource } from "../../pam-resource-enums";
import { TPamResourceFactory, TPamResourceFactoryValidateAccountCredentials } from "../../pam-resource-types"; import {
TPamResourceFactory,
TPamResourceFactoryRotateAccountCredentials,
TPamResourceFactoryValidateAccountCredentials
} from "../../pam-resource-types";
import { TSqlAccountCredentials, TSqlResourceConnectionDetails } from "./sql-resource-types"; import { TSqlAccountCredentials, TSqlResourceConnectionDetails } from "./sql-resource-types";
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
@@ -176,8 +181,66 @@ export const sqlResourceFactory: TPamResourceFactory<TSqlResourceConnectionDetai
} }
}; };
const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials<TSqlAccountCredentials> = async (
rotationAccountCredentials,
currentCredentials
) => {
try {
const newPassword = alphaNumericNanoId(32);
await executeWithGateway(
{
connectionDetails,
gatewayId,
resourceType,
username: rotationAccountCredentials.username,
password: rotationAccountCredentials.password
},
gatewayV2Service,
async (client) => {
switch (resourceType) {
case PamResource.Postgres:
await client.raw(`ALTER USER ?? WITH PASSWORD '${newPassword}'`, [currentCredentials.username]);
break;
default:
throw new BadRequestError({
message: `Password rotation for ${resourceType as PamResource} is not supported.`
});
}
}
);
return { username: currentCredentials.username, password: newPassword };
} catch (error) {
if (error instanceof BadRequestError) {
if (error.message === `password authentication failed for user "${rotationAccountCredentials.username}"`) {
throw new BadRequestError({
message: "Management credentials invalid: Username or password incorrect"
});
}
if (error.message.includes("permission denied")) {
throw new BadRequestError({
message: `Management credentials lack permission to rotate password for user "${currentCredentials.username}"`
});
}
if (error.message === "Connection terminated unexpectedly") {
throw new BadRequestError({
message: "Connection terminated unexpectedly. Verify that host and port are correct"
});
}
}
throw new BadRequestError({
message: `Unable to rotate account credentials for ${resourceType}: ${(error as Error).message || String(error)}`
});
}
};
return { return {
validateConnection, validateConnection,
validateAccountCredentials validateAccountCredentials,
rotateAccountCredentials
}; };
}; };
@@ -16,6 +16,6 @@ export const BaseSqlResourceConnectionDetailsSchema = z.object({
// Accounts // Accounts
export const BaseSqlAccountCredentialsSchema = z.object({ export const BaseSqlAccountCredentialsSchema = z.object({
username: z.string().trim().min(1), username: z.string().trim().min(1).max(63),
password: z.string().trim().min(1) password: z.string().trim().min(1).max(256)
}); });
+8 -2
View File
@@ -77,7 +77,8 @@ export enum QueueName {
DailyReminders = "daily-reminders", DailyReminders = "daily-reminders",
SecretReminderMigration = "secret-reminder-migration", SecretReminderMigration = "secret-reminder-migration",
UserNotification = "user-notification", UserNotification = "user-notification",
HealthAlert = "health-alert" HealthAlert = "health-alert",
PamAccountRotation = "pam-account-rotation"
} }
export enum QueueJobs { export enum QueueJobs {
@@ -126,7 +127,8 @@ export enum QueueJobs {
DailyReminders = "daily-reminders", DailyReminders = "daily-reminders",
SecretReminderMigration = "secret-reminder-migration", SecretReminderMigration = "secret-reminder-migration",
UserNotification = "user-notification-job", UserNotification = "user-notification-job",
HealthAlert = "health-alert" HealthAlert = "health-alert",
PamAccountRotation = "pam-account-rotation"
} }
export type TQueueJobTypes = { export type TQueueJobTypes = {
@@ -357,6 +359,10 @@ export type TQueueJobTypes = {
name: QueueJobs.HealthAlert; name: QueueJobs.HealthAlert;
payload: undefined; payload: undefined;
}; };
[QueueName.PamAccountRotation]: {
name: QueueJobs.PamAccountRotation;
payload: undefined;
};
}; };
const SECRET_SCANNING_JOBS = [ const SECRET_SCANNING_JOBS = [
+9 -1
View File
@@ -261,6 +261,7 @@ import { orgDALFactory } from "@app/services/org/org-dal";
import { orgServiceFactory } from "@app/services/org/org-service"; import { orgServiceFactory } from "@app/services/org/org-service";
import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service";
import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { pamAccountRotationServiceFactory } from "@app/services/pam-account-rotation/pam-account-rotation-queue";
import { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue"; import { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue";
import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal"; import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal";
import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service";
@@ -2258,7 +2259,13 @@ export const registerRoutes = async (
pamSessionDAL, pamSessionDAL,
permissionService, permissionService,
projectDAL, projectDAL,
userDAL userDAL,
auditLogService
});
const pamAccountRotation = pamAccountRotationServiceFactory({
queueService,
pamAccountService
}); });
const pamSessionService = pamSessionServiceFactory({ const pamSessionService = pamSessionServiceFactory({
@@ -2318,6 +2325,7 @@ export const registerRoutes = async (
await dailyResourceCleanUp.init(); await dailyResourceCleanUp.init();
await healthAlert.init(); await healthAlert.init();
await pkiSyncCleanup.init(); await pkiSyncCleanup.init();
await pamAccountRotation.init();
await dailyReminderQueueService.startDailyRemindersJob(); await dailyReminderQueueService.startDailyRemindersJob();
await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyReminderQueueService.startSecretReminderMigrationJob();
await dailyExpiringPkiItemAlert.startSendingAlerts(); await dailyExpiringPkiItemAlert.startSendingAlerts();
@@ -0,0 +1,61 @@
import { TPamAccountServiceFactory } from "@app/ee/services/pam-account/pam-account-service";
import { getConfig } from "@app/lib/config/env";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
type TPamAccountRotationServiceFactoryDep = {
queueService: TQueueServiceFactory;
pamAccountService: Pick<TPamAccountServiceFactory, "rotateAllDueAccounts">;
};
export type TPamAccountRotationServiceFactory = ReturnType<typeof pamAccountRotationServiceFactory>;
export const pamAccountRotationServiceFactory = ({
queueService,
pamAccountService
}: TPamAccountRotationServiceFactoryDep) => {
const appCfg = getConfig();
const init = async () => {
if (appCfg.isSecondaryInstance) {
return;
}
await queueService.stopRepeatableJob(
QueueName.PamAccountRotation,
QueueJobs.PamAccountRotation,
{ pattern: "0 * * * *", utc: true },
QueueName.PamAccountRotation // job id
);
await queueService.startPg<QueueName.PamAccountRotation>(
QueueJobs.PamAccountRotation,
async () => {
try {
logger.info(`${QueueName.PamAccountRotation}: pam account rotation task started`);
await pamAccountService.rotateAllDueAccounts();
logger.info(`${QueueName.PamAccountRotation}: pam account rotation task completed`);
} catch (error) {
logger.error(error, `${QueueName.PamAccountRotation}: pam account rotation failed`);
throw error;
}
},
{
batchSize: 1,
workerCount: 1,
pollingIntervalSeconds: 5 * 60
}
);
await queueService.schedulePg(
QueueJobs.PamAccountRotation,
"0 * * * *", // Schedule to run every hour
undefined,
{ tz: "UTC" }
);
};
return {
init
};
};
+13 -16
View File
@@ -7,20 +7,20 @@ To set a strong foundation, this section outlines how we, the community and memb
should approach the development and contribution process. should approach the development and contribution process.
## Code-bases ## Code-bases
Infisical has two major code-bases. One for the platform code, and one for SDKs. The contribution process has some key differences between the two, so we've split the documentation into two sections: Infisical has two major code-bases. One for the platform code, and one for SDKs. The contribution process has some key differences between the two, so we've split the documentation into two sections:
- The [Infisical Platform](https://github.com/Infisical/infisical), the Infisical platform itself. - The [Infisical Platform](https://github.com/Infisical/infisical), the Infisical platform itself.
- The [Infisical SDK](https://infisical.com/docs/sdks/overview), the official Infisical client SDKs. - The <b>Infisical SDKs</b>, please refer to each individual SDK repositories for more information.
- [Node.js SDK](https://github.com/Infisical/node-sdk-v2)
- [Python SDK](https://github.com/Infisical/python-sdk-official)
<CardGroup cols={2}> - [Java SDK](https://github.com/Infisical/java-sdk)
<Card title="Infisical Platform" href="/contributing/platform/developing" icon="layer-group" color="#A1B659"> - [.NET SDK](https://github.com/Infisical/infisical-dotnet-sdk)
The Infisical platform is the core of the Infisical ecosystem. - [Go SDK](https://github.com/Infisical/go-sdk)
</Card> - [C++ SDK](https://github.com/Infisical/infisical-cpp-sdk)
<Card href="/contributing/sdk/developing" title="Infisical SDK" icon="code" color="#A1B659"> - [PHP SDK](https://github.com/Infisical/php-sdk)
The SDKs are the official Infisical client libraries, used by developers to easily interact with the Infisical platform. - [Rust SDK](https://github.com/Infisical/rust-sdk)
</Card> - [Ruby SDK](https://github.com/infisical/sdk)
</CardGroup>
## Community ## Community
@@ -45,15 +45,12 @@ If you're ever in doubt about whether or not a proposed feature aligns with Infi
## Writing and submitting code ## Writing and submitting code
Anyone can contribute code to Infisical. To get started, check out the local development guides for each language. Anyone can contribute code to Infisical. To get started, check out the local development guide for the platform:
- Local development guide for Platform is [here](/contributing/platform/developing).
- Local development guide for SDK is [here](/contributing/sdk/developing).
- Local development guide for Platform is [here](/contributing/platform/developing).
## Licensing ## Licensing
Most of Infisical's code is under the MIT license, though some paid feature restrictions are covered by a proprietary license. Most of Infisical's code is under the MIT license, though some paid feature restrictions are covered by a proprietary license.
Any third party components incorporated into our code are licensed under the original license provided by the applicable component owner. Any third party components incorporated into our code are licensed under the original license provided by the applicable component owner.
+9
View File
@@ -776,6 +776,15 @@
] ]
} }
] ]
},
{
"item": "Infisical PAM",
"groups": [
{
"group": "Infisical PAM",
"pages": ["documentation/platform/pam/overview"]
}
]
} }
] ]
}, },
@@ -38,3 +38,4 @@ Infisical consists of several tightly integrated products, each designed to solv
- [Infisical PKI](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs. - [Infisical PKI](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs.
- [Infisical SSH](/documentation/platform/ssh/overview): Provide short-lived SSH access to servers using certificate-based authentication, replacing static keys with policy-driven, time-bound control. - [Infisical SSH](/documentation/platform/ssh/overview): Provide short-lived SSH access to servers using certificate-based authentication, replacing static keys with policy-driven, time-bound control.
- [Infisical KMS](/documentation/platform/kms/overview): Encrypt and decrypt data using centrally managed keys with enforced access policies and full audit visibility. - [Infisical KMS](/documentation/platform/kms/overview): Encrypt and decrypt data using centrally managed keys with enforced access policies and full audit visibility.
- [Infisical PAM](/documentation/platform/pam/overview): Manage access to resources like databases, servers, and accounts with policy-based controls and approvals.
@@ -40,6 +40,12 @@ description: "The open source platform for managing secrets, certificates, and s
> >
Replace static SSH keys with short-lived SSH certificates to simplify access and improve security. Replace static SSH keys with short-lived SSH certificates to simplify access and improve security.
</Card> </Card>
<Card
title="Infisical PAM"
href="/documentation/platform/pam/overview"
>
Manage access to resources like databases, servers, and accounts with policy-based controls and approvals.
</Card>
</Columns> </Columns>
<Columns cols="1"> <Columns cols="1">
@@ -13,7 +13,7 @@ Each identity must authenticate with the Infisical API using a supported authent
Key Features: Key Features:
- Role Assignment: Identities must be assigned [roles](/documentation/platform/role-based-access-controls). These roles determine the scope of access to resources, either at the organization level or project level. - Role Assignment: Identities must be assigned [roles](/documentation/platform/access-controls/role-based-access-controls). These roles determine the scope of access to resources, either at the organization level or project level.
- Auth/Token Configuration: Identities must be configured with corresponding authentication methods and access token properties to securely interact with the Infisical API. - Auth/Token Configuration: Identities must be configured with corresponding authentication methods and access token properties to securely interact with the Infisical API.
## Workflow ## Workflow
@@ -0,0 +1,45 @@
---
title: "Infisical PAM"
sidebarTitle: "Overview"
description: "Learn how to manage access to resources like databases, servers, and accounts with policy-based controls and approvals."
---
Infisical Privileged Access Management (PAM) provides a centralized way to manage and secure access to your critical infrastructure. It allows you to enforce fine-grained, policy-based controls over resources like databases, servers, and more, ensuring that only authorized users can access sensitive systems, and only when they need to.
### How it Works
Infisical PAM employs a resource-based model to organize and manage access. This model is designed to be intuitive and scalable.
#### 1. Create a Resource
The first step is to define a resource you want to manage. A resource represents a target system, such as a PostgreSQL database. When creating a resource, you'll provide the necessary connection details, like the host and port.
![Create Resource](/images/pam/overview/create-resource.png)
#### 2. Add Accounts to the Resource
Once a resource is created, you can add accounts to it. An account represents a specific set of credentials (e.g., a username and password) that can be used to access the resource. This allows you to manage multiple sets of credentials for a single database or server from one place.
![Create Account](/images/pam/overview/create-account.png)
### Infisical PAM Features
#### Session Logging and Auditing
- **Session Logging**: All user sessions are extensively logged, providing a detailed and searchable record of activities performed during a session.
- **Audit Logging**: Every significant event, such as a user starting a session or accessing an account's credentials, is recorded in audit logs. This gives you complete visibility over your project.
![Session Page](/images/pam/overview/session-page.png)
#### Automated Credential Rotation
Infisical PAM can automatically rotate account credentials to enhance your security posture.
Here’s how it works:
1. **Add a Rotation Account**: On the resource level, you configure a "rotation account." This is a master or privileged account that has the necessary permissions to change the passwords of other accounts on that same resource.
![Credential Rotation Account](/images/pam/overview/credential-rotation-account.png)
2. **Configure Rotation on Accounts**: For each individual account you want to rotate, you can simply enable rotation and set a desired interval (e.g., every 30 days).
![Rotate Credentials Account](/images/pam/overview/rotate-credentials-account.png)
Infisical will then use the rotation account on the resource to automatically update the credentials of the target account at the specified interval, eliminating credential staleness.
+1
View File
@@ -22,6 +22,7 @@ The supported project types are:
- [Infisical PKI](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs. - [Infisical PKI](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs.
- [Infisical SSH](/documentation/platform/ssh/overview): Provide short-lived SSH access to servers using certificate-based authentication, replacing static keys with policy-driven, time-bound control. - [Infisical SSH](/documentation/platform/ssh/overview): Provide short-lived SSH access to servers using certificate-based authentication, replacing static keys with policy-driven, time-bound control.
- [Infisical KMS](/documentation/platform/kms/overview): Encrypt and decrypt data using centrally managed keys with enforced access policies and full audit visibility. - [Infisical KMS](/documentation/platform/kms/overview): Encrypt and decrypt data using centrally managed keys with enforced access policies and full audit visibility.
- [Infisical PAM](/documentation/platform/pam/overview): Manage access to resources like databases, servers, and accounts with policy-based controls and approvals.
## Roles and Access Control ## Roles and Access Control
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File
+1
View File
@@ -0,0 +1 @@
export const UNCHANGED_PASSWORD_SENTINEL = "__INFISICAL_UNCHANGED__";
+29
View File
@@ -3,6 +3,7 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { TPamResourceOption } from "./types/resource-options"; import { TPamResourceOption } from "./types/resource-options";
import { PamResourceType } from "./enums";
import { TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types"; import { TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types";
export const pamKeys = { export const pamKeys = {
@@ -12,6 +13,12 @@ export const pamKeys = {
session: () => [...pamKeys.all, "session"] as const, session: () => [...pamKeys.all, "session"] as const,
listResourceOptions: () => [...pamKeys.resource(), "options"] as const, listResourceOptions: () => [...pamKeys.resource(), "options"] as const,
listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId], listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId],
getResource: (resourceType: string, resourceId: string) => [
...pamKeys.resource(),
"get",
resourceType,
resourceId
],
listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId], listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId],
getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId], getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId],
listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId] listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId]
@@ -68,6 +75,28 @@ export const useListPamResources = (
}); });
}; };
export const useGetPamResourceById = (
resourceType?: PamResourceType,
resourceId?: string,
options?: Omit<
UseQueryOptions<TPamResource, unknown, TPamResource, ReturnType<typeof pamKeys.getResource>>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: pamKeys.getResource(resourceType || "", resourceId || ""),
queryFn: async () => {
const { data } = await apiRequest.get<{ resource: TPamResource }>(
`/api/v1/pam/resources/${resourceType}/${resourceId}`
);
return data.resource;
},
enabled: !!resourceId && !!resourceType && (options?.enabled ?? true),
...options
});
};
// Accounts // Accounts
export const useListPamAccounts = ( export const useListPamAccounts = (
projectId: string, projectId: string,
@@ -9,9 +9,13 @@ export interface TBasePamAccount {
id: string; id: string;
name: string; name: string;
resourceType: PamResourceType; resourceType: PamResourceType;
rotationCredentialsConfigured: boolean;
}; };
name: string; name: string;
description?: string | null; description?: string | null;
rotationEnabled: boolean;
rotationIntervalSeconds?: number | null;
lastRotatedAt?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -6,6 +6,7 @@ import { TBasePamResource } from "./base-resource";
// Resources // Resources
export type TPostgresResource = TBasePamResource & { resourceType: PamResourceType.Postgres } & { export type TPostgresResource = TBasePamResource & { resourceType: PamResourceType.Postgres } & {
connectionDetails: TBaseSqlConnectionDetails; connectionDetails: TBaseSqlConnectionDetails;
rotationAccountCredentials?: TBaseSqlCredentials | null;
}; };
// Accounts // Accounts
@@ -138,7 +138,7 @@ export const SignupInvitePage = () => {
// Step 4 of the sign up process (download the emergency kit pdf) // Step 4 of the sign up process (download the emergency kit pdf)
const stepConfirmEmail = ( const stepConfirmEmail = (
<div className="mx-1 mb-36 flex h-7/12 w-full max-w-xs flex-col items-center rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-4 py-8 drop-shadow-xl md:mb-16 md:max-w-lg md:px-6"> <div className="mx-1 mt-14 mb-36 flex w-full max-w-xs flex-col items-center rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-4 py-8 drop-shadow-xl md:mb-16 md:max-w-lg md:px-6">
<p className="mb-2 flex justify-center text-center text-4xl font-medium text-primary-100"> <p className="mb-2 flex justify-center text-center text-4xl font-medium text-primary-100">
Confirm your email Confirm your email
</p> </p>
@@ -179,7 +179,7 @@ export const SignupInvitePage = () => {
// Because this is the invite signup - we directly go to the last step of signup (email is already verified) // Because this is the invite signup - we directly go to the last step of signup (email is already verified)
const main = ( const main = (
<div className="mx-auto mb-32 h-7/12 w-max rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-8 py-10 drop-shadow-xl md:mb-16"> <div className="mx-auto mt-14 mb-32 w-max rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-8 py-10 drop-shadow-xl md:mb-16">
<p className="mx-8 mb-6 flex justify-center bg-linear-to-tr from-mineshaft-300 to-white bg-clip-text text-4xl font-bold text-transparent md:mx-16"> <p className="mx-8 mb-6 flex justify-center bg-linear-to-tr from-mineshaft-300 to-white bg-clip-text text-4xl font-bold text-transparent md:mx-16">
Almost there! Almost there!
</p> </p>
@@ -1,3 +1,4 @@
import { useState } from "react";
import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form"; import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -29,6 +30,8 @@ const formSchema = z.object({
type FormData = z.infer<typeof formSchema>; type FormData = z.infer<typeof formSchema>;
export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => { export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => {
const [showPassword, setShowPassword] = useState(false);
const isUpdate = Boolean(auditLogStream); const isUpdate = Boolean(auditLogStream);
const form = useForm<FormData>({ const form = useForm<FormData>({
@@ -96,10 +99,10 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P
> >
<Input <Input
{...field} {...field}
type="password" type={showPassword ? "text" : "password"}
placeholder="Bearer <token>"
autoComplete="new-password" autoComplete="new-password"
onFocus={(e) => { placeholder="Bearer <token>"
onFocus={() => {
if ( if (
auditLogStream && auditLogStream &&
auditLogStream.credentials.headers[i] && auditLogStream.credentials.headers[i] &&
@@ -108,9 +111,9 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P
) { ) {
field.onChange(""); field.onChange("");
} }
e.target.type = "text"; setShowPassword(true);
}} }}
onBlur={(e) => { onBlur={() => {
if ( if (
auditLogStream && auditLogStream &&
auditLogStream.credentials.headers[i] && auditLogStream.credentials.headers[i] &&
@@ -119,7 +122,7 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P
) { ) {
field.onChange("******"); field.onChange("******");
} }
e.target.type = "password"; setShowPassword(false);
}} }}
/> />
</FormControl> </FormControl>
@@ -34,10 +34,11 @@ const CreateForm = ({
}: CreateFormProps) => { }: CreateFormProps) => {
const createPamAccount = useCreatePamAccount(); const createPamAccount = useCreatePamAccount();
console.log({ folderId });
const onSubmit = async ( const onSubmit = async (
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials"> formData: DiscriminativePick<
TPamAccount,
"name" | "description" | "credentials" | "rotationEnabled" | "rotationIntervalSeconds"
>
) => { ) => {
try { try {
const account = await createPamAccount.mutateAsync({ const account = await createPamAccount.mutateAsync({
@@ -64,7 +65,13 @@ const CreateForm = ({
switch (resourceType) { switch (resourceType) {
case PamResourceType.Postgres: case PamResourceType.Postgres:
return <PostgresAccountForm onSubmit={onSubmit} />; return (
<PostgresAccountForm
onSubmit={onSubmit}
resourceId={resourceId}
resourceType={resourceType}
/>
);
default: default:
throw new Error(`Unhandled resource: ${resourceType}`); throw new Error(`Unhandled resource: ${resourceType}`);
} }
@@ -74,7 +81,10 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
const updatePamAccount = useUpdatePamAccount(); const updatePamAccount = useUpdatePamAccount();
const onSubmit = async ( const onSubmit = async (
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials"> formData: DiscriminativePick<
TPamAccount,
"name" | "description" | "credentials" | "rotationEnabled" | "rotationIntervalSeconds"
>
) => { ) => {
try { try {
const updatedAccount = await updatePamAccount.mutateAsync({ const updatedAccount = await updatePamAccount.mutateAsync({
@@ -1,26 +1,31 @@
import { useEffect, useState } from "react";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { Button, ModalClose } from "@app/components/v2"; import { Button, ModalClose } from "@app/components/v2";
import { TPostgresAccount } from "@app/hooks/api/pam"; import { PamResourceType, TPostgresAccount, useGetPamResourceById } from "@app/hooks/api/pam";
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
import { BaseSqlAccountSchema } from "./shared/sql-account-schemas"; import { BaseSqlAccountSchema } from "./shared/sql-account-schemas";
import { SqlAccountFields } from "./shared/SqlAccountFields"; import { SqlAccountFields } from "./shared/SqlAccountFields";
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields"; import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountFields";
type Props = { type Props = {
account?: TPostgresAccount; account?: TPostgresAccount;
resourceId?: string;
resourceType?: PamResourceType;
onSubmit: (formData: FormData) => Promise<void>; onSubmit: (formData: FormData) => Promise<void>;
}; };
const formSchema = genericAccountFieldsSchema.extend({ const formSchema = genericAccountFieldsSchema.extend(rotateAccountFieldsSchema.shape).extend({
credentials: BaseSqlAccountSchema credentials: BaseSqlAccountSchema
}); });
type FormData = z.infer<typeof formSchema>; type FormData = z.infer<typeof formSchema>;
export const PostgresAccountForm = ({ account, onSubmit }: Props) => { export const PostgresAccountForm = ({ account, resourceId, resourceType, onSubmit }: Props) => {
const isUpdate = Boolean(account); const isUpdate = Boolean(account);
const form = useForm<FormData>({ const form = useForm<FormData>({
@@ -30,7 +35,7 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
...account, ...account,
credentials: { credentials: {
...account.credentials, ...account.credentials,
password: "******" password: UNCHANGED_PASSWORD_SENTINEL
} }
} }
: undefined : undefined
@@ -41,6 +46,20 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
formState: { isSubmitting, isDirty } formState: { isSubmitting, isDirty }
} = form; } = form;
const [rotationCredentialsConfigured, setRotationCredentialsConfigured] = useState(false);
const { data: resource } = useGetPamResourceById(resourceType, resourceId, {
enabled: !account && !!resourceId && !!resourceType
});
useEffect(() => {
if (account) {
setRotationCredentialsConfigured(account.resource.rotationCredentialsConfigured);
} else {
setRotationCredentialsConfigured(!!resource?.rotationAccountCredentials);
}
}, [account, resource]);
return ( return (
<FormProvider {...form}> <FormProvider {...form}>
<form <form
@@ -50,6 +69,7 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
> >
<GenericAccountFields /> <GenericAccountFields />
<SqlAccountFields isUpdate={isUpdate} /> <SqlAccountFields isUpdate={isUpdate} />
<RotateAccountFields rotationCredentialsConfigured={rotationCredentialsConfigured} />
<div className="mt-6 flex items-center"> <div className="mt-6 flex items-center">
<Button <Button
className="mr-4" className="mr-4"
@@ -0,0 +1,87 @@
import { Controller, useFormContext } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { FormControl, Select, SelectItem, Switch, Tooltip } from "@app/components/v2";
export const rotateAccountFieldsSchema = z.object({
rotationEnabled: z.boolean(),
rotationIntervalSeconds: z.number().nullable().optional()
});
export const RotateAccountFields = ({
rotationCredentialsConfigured
}: {
rotationCredentialsConfigured: boolean;
}) => {
const { control, watch } = useFormContext<{
rotationEnabled: boolean;
rotationIntervalSeconds?: number | null;
}>();
const rotationEnabled = watch("rotationEnabled");
return (
<Tooltip
isDisabled={rotationCredentialsConfigured}
content="The resource which owns this account does not have rotation credentials configured."
>
<div
className={twMerge(
"flex h-9 w-fit items-center gap-3",
!rotationCredentialsConfigured && "opacity-50"
)}
>
<Controller
control={control}
name="rotationEnabled"
defaultValue={false}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} className="mb-0">
<Switch
className="ml-0 bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
id="rotation-enabled"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
isDisabled={!rotationCredentialsConfigured}
/>
</FormControl>
)}
/>
<span className="text-sm">Rotate Credentials Every</span>
<Controller
name="rotationIntervalSeconds"
control={control}
defaultValue={2592000}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
className="mb-0"
>
<Select
value={(value || 2592000).toString()}
onValueChange={(val) => onChange(parseInt(val, 10))}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
placeholder="Select an interval..."
dropdownContainerClassName="max-w-none"
isDisabled={!rotationEnabled || !rotationCredentialsConfigured}
dropdownContainerStyle={{
width: "130px"
}}
>
<SelectItem value="2592000">30 Days</SelectItem>
<SelectItem value="604800">7 Days</SelectItem>
<SelectItem value="259200">3 Days</SelectItem>
<SelectItem value="86400">1 Day</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
</Tooltip>
);
};
@@ -1,9 +1,19 @@
import { Controller, useFormContext } from "react-hook-form"; import { useEffect, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { FormControl, Input } from "@app/components/v2"; import { FormControl, Input } from "@app/components/v2";
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => { export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
const { control } = useFormContext(); const { control } = useFormContext();
const [showPassword, setShowPassword] = useState(false);
const password = useWatch({ control, name: "credentials.password" });
useEffect(() => {
if (password === UNCHANGED_PASSWORD_SENTINEL) {
setShowPassword(false);
}
}, [password]);
return ( return (
<div className="flex gap-2"> <div className="flex gap-2">
@@ -17,7 +27,7 @@ export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
label="Username" label="Username"
> >
<Input {...field} /> <Input {...field} autoComplete="off" />
</FormControl> </FormControl>
)} )}
/> />
@@ -33,18 +43,19 @@ export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
> >
<Input <Input
{...field} {...field}
type="password" type={showPassword ? "text" : "password"}
onFocus={(e) => { autoComplete="new-password"
if (isUpdate && field.value === "******") { onFocus={() => {
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
field.onChange(""); field.onChange("");
} }
e.target.type = "text"; setShowPassword(true);
}} }}
onBlur={(e) => { onBlur={() => {
if (isUpdate && field.value === "") { if (isUpdate && field.value === "") {
field.onChange("******"); field.onChange(UNCHANGED_PASSWORD_SENTINEL);
} }
e.target.type = "password"; setShowPassword(false);
}} }}
/> />
</FormControl> </FormControl>
@@ -5,6 +5,10 @@ export const BaseSqlAccountSchema = z.object({
.string() .string()
.trim() .trim()
.min(1, "Username required") .min(1, "Username required")
.max(255, "Username must be 255 characters or less"), .max(63, "Username must be 63 characters or less"),
password: z.string().trim().min(1, "Password required") password: z
.string()
.trim()
.min(1, "Password required")
.max(256, "Password must be 256 characters or less")
}); });
@@ -7,9 +7,11 @@ import {
faEllipsisV, faEllipsisV,
faFolder, faFolder,
faRightToBracket, faRightToBracket,
faRotate,
faTrash faTrash
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatDistance } from "date-fns";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
@@ -100,6 +102,12 @@ export const PamAccountRow = ({
</span> </span>
</Badge> </Badge>
)} )}
{account.lastRotatedAt && (
<Badge className="flex h-5 w-min items-center gap-1.5 bg-orange/20 whitespace-nowrap text-orange">
<FontAwesomeIcon icon={faRotate} />
<span>Rotated {formatDistance(new Date(), account.lastRotatedAt)} ago</span>
</Badge>
)}
</div> </div>
</div> </div>
</Td> </Td>
@@ -5,9 +5,12 @@ import { z } from "zod";
import { Button, ModalClose } from "@app/components/v2"; import { Button, ModalClose } from "@app/components/v2";
import { PamResourceType, TPostgresResource } from "@app/hooks/api/pam"; import { PamResourceType, TPostgresResource } from "@app/hooks/api/pam";
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
import { BaseSqlAccountSchema } from "@app/pages/pam/PamAccountsPage/components/PamAccountForm/shared/sql-account-schemas";
import { BaseSqlResourceSchema } from "./shared/sql-resource-schemas"; import { BaseSqlResourceSchema } from "./shared/sql-resource-schemas";
import { SqlResourceFields } from "./shared/SqlResourceFields"; import { SqlResourceFields } from "./shared/SqlResourceFields";
import { SqlRotateAccountFields } from "./shared/SqlRotateAccountFields";
import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields"; import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields";
type Props = { type Props = {
@@ -17,7 +20,8 @@ type Props = {
const formSchema = genericResourceFieldsSchema.extend({ const formSchema = genericResourceFieldsSchema.extend({
resourceType: z.literal(PamResourceType.Postgres), resourceType: z.literal(PamResourceType.Postgres),
connectionDetails: BaseSqlResourceSchema connectionDetails: BaseSqlResourceSchema,
rotationAccountCredentials: BaseSqlAccountSchema.nullable().optional()
}); });
type FormData = z.infer<typeof formSchema>; type FormData = z.infer<typeof formSchema>;
@@ -28,17 +32,27 @@ export const PostgresResourceForm = ({ resource, onSubmit }: Props) => {
const form = useForm<FormData>({ const form = useForm<FormData>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: resource ?? { defaultValues: resource
resourceType: PamResourceType.Postgres, ? {
connectionDetails: { ...resource,
host: "", rotationAccountCredentials: resource.rotationAccountCredentials
port: 5432, ? {
database: "default", ...resource.rotationAccountCredentials,
sslEnabled: true, password: UNCHANGED_PASSWORD_SENTINEL
sslRejectUnauthorized: true, }
sslCertificate: undefined : resource.rotationAccountCredentials
} }
} : {
resourceType: PamResourceType.Postgres,
connectionDetails: {
host: "",
port: 5432,
database: "default",
sslEnabled: true,
sslRejectUnauthorized: true,
sslCertificate: undefined
}
}
}); });
const { const {
@@ -59,6 +73,7 @@ export const PostgresResourceForm = ({ resource, onSubmit }: Props) => {
selectedTabIndex={selectedTabIndex} selectedTabIndex={selectedTabIndex}
setSelectedTabIndex={setSelectedTabIndex} setSelectedTabIndex={setSelectedTabIndex}
/> />
<SqlRotateAccountFields isUpdate={isUpdate} />
<div className="mt-6 flex items-center"> <div className="mt-6 flex items-center">
<Button <Button
className="mr-4" className="mr-4"
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
FormControl,
Input
} from "@app/components/v2";
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
export const SqlRotateAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
const { control } = useFormContext();
const [showPassword, setShowPassword] = useState(false);
const password = useWatch({ control, name: "credentials.password" });
useEffect(() => {
if (password === UNCHANGED_PASSWORD_SENTINEL) {
setShowPassword(false);
}
}, [password]);
return (
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="advance-settings" className="data-[state=open]:border-none">
<AccordionTrigger className="h-fit flex-none pl-1 text-sm">
<div className="order-1 ml-3">Rotation Account</div>
</AccordionTrigger>
<AccordionContent childrenClassName="px-0 py-0">
<p className="mb-2 text-xs">
Credentials of the privileged account which will be used for rotating other accounts
under this resource
</p>
<div className="flex gap-2">
<Controller
name="rotationAccountCredentials.username"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Username"
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
name="rotationAccountCredentials.password"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Password"
>
<Input
{...field}
type={showPassword ? "text" : "password"}
autoComplete="new-password"
onFocus={() => {
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
field.onChange("");
}
setShowPassword(true);
}}
onBlur={() => {
if (isUpdate && field.value === "") {
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
}
setShowPassword(false);
}}
/>
</FormControl>
)}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
);
};
@@ -1109,7 +1109,6 @@ const Page = () => {
secretPath={secretPath} secretPath={secretPath}
isSmaller={isNotEmpty} isSmaller={isNotEmpty}
environments={currentProject?.environments} environments={currentProject?.environments}
isProtectedBranch={isProtectedBranch}
/> />
<PitDrawer <PitDrawer
secretSnaphots={snapshotList} secretSnaphots={snapshotList}
@@ -10,7 +10,6 @@ import {
faUpload faUpload
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useQueryClient } from "@tanstack/react-query";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
@@ -34,21 +33,27 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks"; import { usePopUp, useToggle } from "@app/hooks";
import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api"; import { PendingAction } from "@app/hooks/api/secretFolders/types";
import { import { fetchProjectSecrets, mergePersonalSecrets } from "@app/hooks/api/secrets/queries";
dashboardKeys, import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
fetchDashboardProjectSecretsByKeys
} from "@app/hooks/api/dashboard/queries";
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
import { secretKeys } from "@app/hooks/api/secrets/queries";
import { SecretType } from "@app/hooks/api/types";
import { PopUpNames, usePopUpAction } from "../../SecretMainPage.store"; import {
BatchContext,
PendingSecretCreate,
PendingSecretUpdate,
PopUpNames,
useBatchModeActions,
usePopUpAction
} from "../../SecretMainPage.store";
import { CopySecretsFromBoard } from "./CopySecretsFromBoard"; import { CopySecretsFromBoard } from "./CopySecretsFromBoard";
import { PasteSecretEnvModal } from "./PasteSecretEnvModal"; import { PasteSecretEnvModal } from "./PasteSecretEnvModal";
type TParsedEnv = Record<string, { value: string; comments: string[] }>; type TParsedEnv = Record<string, { value: string; comments: string[] }>;
type TSecOverwriteOpt = { update: TParsedEnv; create: TParsedEnv }; type TSecOverwriteOpt = {
update: TParsedEnv;
create: TParsedEnv;
existingSecrets: SecretV3RawSanitized[];
};
type Props = { type Props = {
isSmaller: boolean; isSmaller: boolean;
@@ -56,7 +61,6 @@ type Props = {
projectId: string; projectId: string;
environment: string; environment: string;
secretPath: string; secretPath: string;
isProtectedBranch?: boolean;
}; };
type SecretMatrixMap = { type SecretMatrixMap = {
@@ -65,7 +69,7 @@ type SecretMatrixMap = {
comment: number | null; comment: number | null;
}; };
const popupKeys = ["importSecEnv", "confirmUpload", "pasteSecEnv", "importMatrixMap"] as const; const popupKeys = ["importSecEnv", "pasteSecEnv", "importMatrixMap"] as const;
const MatrixImportModalTableRow = ({ const MatrixImportModalTableRow = ({
importSecretMatrixMap, importSecretMatrixMap,
@@ -142,8 +146,7 @@ export const SecretDropzone = ({
environments = [], environments = [],
projectId, projectId,
environment, environment,
secretPath, secretPath
isProtectedBranch = false
}: Props): JSX.Element => { }: Props): JSX.Element => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isDragActive, setDragActive] = useToggle(); const [isDragActive, setDragActive] = useToggle();
@@ -157,18 +160,11 @@ export const SecretDropzone = ({
}); });
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp(popupKeys); const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp(popupKeys);
const queryClient = useQueryClient();
const { openPopUp } = usePopUpAction(); const { openPopUp } = usePopUpAction();
const { addPendingChange } = useBatchModeActions();
const { mutateAsync: updateSecretBatch, isPending: isUpdatingSecrets } = useUpdateSecretBatch({
options: { onSuccess: undefined }
});
const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({
options: { onSuccess: undefined }
});
// hide copy secrets from board due to import folders feature // hide copy secrets from board due to import folders feature
const shouldRenderCopySecrets = false; const shouldRenderCopySecrets = false;
const isSubmitting = isCreatingSecrets || isUpdatingSecrets;
const handleDrag = (e: DragEvent) => { const handleDrag = (e: DragEvent) => {
e.preventDefault(); e.preventDefault();
@@ -180,6 +176,81 @@ export const SecretDropzone = ({
} }
}; };
const handleSaveSecrets = async (data: TSecOverwriteOpt) => {
const { update, create, existingSecrets } = data;
try {
const context: BatchContext = {
projectId,
environment,
secretPath
};
const existingSecretsMap = existingSecrets.reduce<Record<string, SecretV3RawSanitized>>(
(prev, curr) => ({ ...prev, [curr.key]: curr }),
{}
);
const totalCount = Object.keys(create || {}).length + Object.keys(update || {}).length;
if (Object.keys(create || {}).length) {
Object.entries(create).forEach(([secretKey, secData]) => {
const createChange: PendingSecretCreate = {
id: secretKey,
timestamp: Date.now(),
resourceType: "secret",
type: PendingAction.Create,
secretKey,
secretValue: secData.value,
secretComment: secData.comments.join("\n") || undefined,
tags: [],
secretMetadata: []
};
addPendingChange(createChange, context);
});
}
if (Object.keys(update || {}).length) {
Object.entries(update).forEach(([secretKey, secData]) => {
const existingSecret = existingSecretsMap[secretKey];
if (!existingSecret) {
console.warn(`Existing secret not found for key: ${secretKey}`);
return;
}
const updateChange: PendingSecretUpdate = {
id: existingSecret.id,
timestamp: Date.now(),
resourceType: "secret",
type: PendingAction.Update,
secretKey,
secretValue: secData.value,
secretComment: secData.comments.join("\n") || undefined,
existingSecret,
originalValue: existingSecret.value || "",
originalComment: existingSecret.comment || "",
originalSkipMultilineEncoding: existingSecret.skipMultilineEncoding || false,
originalTags: existingSecret.tags || [],
originalSecretMetadata: existingSecret.secretMetadata || []
};
addPendingChange(updateChange, context);
});
}
createNotification({
type: "success",
text: `Successfully imported ${totalCount} secret${totalCount > 1 ? "s" : ""}.`
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to import secrets"
});
}
};
const handleParsedEnv = async (env: TParsedEnv) => { const handleParsedEnv = async (env: TParsedEnv) => {
const envSecretKeys = Object.keys(env); const envSecretKeys = Object.keys(env);
@@ -193,29 +264,38 @@ export const SecretDropzone = ({
try { try {
setIsLoading.on(); setIsLoading.on();
const { secrets: existingSecrets } = await fetchDashboardProjectSecretsByKeys({ const { secrets: rawExistingSecrets } = await fetchProjectSecrets({
secretPath,
environment,
projectId, projectId,
keys: envSecretKeys environment,
secretPath,
viewSecretValue: true
}); });
const secretsGroupedByKey = existingSecrets.reduce<Record<string, boolean>>( const allExistingSecrets = mergePersonalSecrets(rawExistingSecrets);
(prev, curr) => ({ ...prev, [curr.secretKey]: true }),
const existingSecretsMap = allExistingSecrets.reduce<Record<string, SecretV3RawSanitized>>(
(prev, curr) => ({ ...prev, [curr.key]: curr }),
{} {}
); );
const updateSecrets = Object.keys(env) const updateSecrets: TParsedEnv = {};
.filter((secKey) => secretsGroupedByKey[secKey]) const createSecrets: TParsedEnv = {};
.reduce<TParsedEnv>((prev, curr) => ({ ...prev, [curr]: env[curr] }), {}); const relevantExistingSecrets: SecretV3RawSanitized[] = [];
const createSecrets = Object.keys(env) Object.entries(env).forEach(([secretKey, secretData]) => {
.filter((secKey) => !secretsGroupedByKey[secKey]) const existingSecret = existingSecretsMap[secretKey];
.reduce<TParsedEnv>((prev, curr) => ({ ...prev, [curr]: env[curr] }), {}); if (existingSecret) {
updateSecrets[secretKey] = secretData;
relevantExistingSecrets.push(existingSecret);
} else {
createSecrets[secretKey] = secretData;
}
});
handlePopUpOpen("confirmUpload", { await handleSaveSecrets({
update: updateSecrets, update: updateSecrets,
create: createSecrets create: createSecrets,
existingSecrets: relevantExistingSecrets
}); });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -223,7 +303,6 @@ export const SecretDropzone = ({
text: "Failed to check for secret conflicts", text: "Failed to check for secret conflicts",
type: "error" type: "error"
}); });
handlePopUpClose("confirmUpload");
} finally { } finally {
setIsLoading.off(); setIsLoading.off();
} }
@@ -328,70 +407,6 @@ export const SecretDropzone = ({
parseFile(e.target?.files?.[0]); parseFile(e.target?.files?.[0]);
}; };
const handleSaveSecrets = async () => {
const { update, create } = popUp?.confirmUpload?.data as TSecOverwriteOpt;
try {
if (Object.keys(create || {}).length) {
await createSecretBatch({
secretPath,
projectId,
environment,
secrets: Object.entries(create).map(([secretKey, secData]) => ({
type: SecretType.Shared,
secretComment: secData.comments.join("\n"),
secretValue: secData.value,
secretKey
}))
});
}
if (Object.keys(update || {}).length) {
await updateSecretBatch({
secretPath,
projectId,
environment,
secrets: Object.entries(update).map(([secretKey, secData]) => ({
type: SecretType.Shared,
secretComment: secData.comments.join("\n"),
secretValue: secData.value,
secretKey
}))
});
}
queryClient.invalidateQueries({
queryKey: secretKeys.getProjectSecret({ projectId, environment, secretPath })
});
queryClient.invalidateQueries({
queryKey: dashboardKeys.getDashboardSecrets({ projectId, secretPath })
});
queryClient.invalidateQueries({
queryKey: secretApprovalRequestKeys.count({ projectId })
});
handlePopUpClose("confirmUpload");
createNotification({
type: "success",
text: isProtectedBranch
? "Uploaded changes have been sent for review"
: "Successfully uploaded secrets"
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to upload secrets"
});
}
};
const createSecretCount = Object.keys(
(popUp.confirmUpload?.data as TSecOverwriteOpt)?.create || {}
).length;
const updateSecretCount = Object.keys(
(popUp.confirmUpload?.data as TSecOverwriteOpt)?.update || {}
).length;
const isNonConflictingUpload = !updateSecretCount;
return ( return (
<div> <div>
<div <div
@@ -495,58 +510,6 @@ export const SecretDropzone = ({
</div> </div>
)} )}
</div> </div>
<Modal
isOpen={popUp?.confirmUpload?.isOpen}
onOpenChange={(open) => handlePopUpToggle("confirmUpload", open)}
>
<ModalContent
title="Confirm Secret Upload"
footerContent={[
<Button
isLoading={isSubmitting}
isDisabled={isSubmitting}
colorSchema={isNonConflictingUpload ? "primary" : "danger"}
key="overwrite-btn"
onClick={handleSaveSecrets}
>
{isNonConflictingUpload ? "Upload" : "Overwrite"}
</Button>,
<Button
key="keep-old-btn"
className="ml-4"
onClick={() => handlePopUpClose("confirmUpload")}
variant="outline_bg"
isDisabled={isSubmitting}
>
Cancel
</Button>
]}
>
{isNonConflictingUpload ? (
<div>
Are you sure you want to import {createSecretCount} secret
{createSecretCount > 1 ? "s" : ""} to this environment?
</div>
) : (
<div className="flex flex-col text-gray-300">
<div>Your project already contains the following {updateSecretCount} secrets:</div>
<div className="mt-2 text-sm text-gray-400">
{Object.keys((popUp?.confirmUpload?.data as TSecOverwriteOpt)?.update || {})
?.map((key) => key)
.join(", ")}
</div>
<div className="mt-6">
Are you sure you want to overwrite these secrets
{createSecretCount > 0
? ` and import ${createSecretCount} new
one${createSecretCount > 1 ? "s" : ""}`
: ""}
?
</div>
</div>
)}
</ModalContent>
</Modal>
{/* Matrix Import Modal */} {/* Matrix Import Modal */}
<Modal <Modal
@@ -130,7 +130,8 @@ export const SecretImportListView = ({
const [items, setItems] = useState(secretImports ?? []); const [items, setItems] = useState(secretImports ?? []);
const getImportReplicatedFolder = (importPath: string) => { const getImportReplicatedFolder = (importPath: string) => {
const cleanImportPath = importPath.replace("/__reserve_replication_", ""); if (!importPath.includes("/__reserve_replication_")) return undefined;
const cleanImportPath = importPath.split("/__reserve_replication_")[1];
const replicatedFolder = items?.find(({ id }) => id === cleanImportPath); const replicatedFolder = items?.find(({ id }) => id === cleanImportPath);
return replicatedFolder; return replicatedFolder;
}; };