mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-06 21:27:10 +00:00
Merge pull request #4685 from Infisical/ENG-3925
feat(pam): account credential rotation
This commit is contained in:
@@ -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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
#### 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.
|
||||||
|

|
||||||
|
|
||||||
|
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).
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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 |
@@ -0,0 +1 @@
|
|||||||
|
export const UNCHANGED_PASSWORD_SENTINEL = "__INFISICAL_UNCHANGED__";
|
||||||
@@ -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
|
||||||
|
|||||||
+9
-6
@@ -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>
|
||||||
|
|||||||
+15
-5
@@ -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({
|
||||||
|
|||||||
+24
-4
@@ -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"
|
||||||
|
|||||||
+87
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+20
-9
@@ -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>
|
||||||
|
|||||||
+6
-2
@@ -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>
|
||||||
|
|||||||
+27
-12
@@ -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"
|
||||||
|
|||||||
+86
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user