mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat(pam): account credential rotation
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
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.hasColumn(TableName.PamAccount, "rotationIntervalSeconds")) &&
|
||||
!(await knex.schema.hasColumn(TableName.PamAccount, "lastRotatedAt"))
|
||||
) {
|
||||
await knex.schema.alterTable(TableName.PamAccount, (t) => {
|
||||
t.boolean("rotationEnabled").notNullable().defaultTo(false);
|
||||
t.integer("rotationIntervalSeconds").notNullable().defaultTo(2592000);
|
||||
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.hasColumn(TableName.PamAccount, "rotationIntervalSeconds")) &&
|
||||
(await knex.schema.hasColumn(TableName.PamAccount, "lastRotatedAt"))
|
||||
) {
|
||||
await knex.schema.alterTable(TableName.PamAccount, (t) => {
|
||||
t.dropColumn("lastRotatedAt");
|
||||
t.dropColumn("rotationIntervalSeconds");
|
||||
t.dropColumn("rotationEnabled");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,10 @@ export const PamAccountsSchema = z.object({
|
||||
description: z.string().nullable().optional(),
|
||||
encryptedCredentials: zodBuffer,
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
updatedAt: z.date(),
|
||||
rotationEnabled: z.boolean().default(false),
|
||||
rotationIntervalSeconds: z.number().default(2592000),
|
||||
lastRotatedAt: z.date().nullable().optional()
|
||||
});
|
||||
|
||||
export type TPamAccounts = z.infer<typeof PamAccountsSchema>;
|
||||
|
||||
@@ -17,7 +17,9 @@ export const PamResourcesSchema = z.object({
|
||||
resourceType: z.string(),
|
||||
encryptedConnectionDetails: zodBuffer,
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
updatedAt: z.date(),
|
||||
encryptedRotationAccountDetails: zodBuffer.nullable().optional(),
|
||||
encryptedRotationAccountCredentials: zodBuffer.nullable().optional()
|
||||
});
|
||||
|
||||
export type TPamResources = z.infer<typeof PamResourcesSchema>;
|
||||
|
||||
@@ -22,11 +22,15 @@ export const registerPamResourceEndpoints = <C extends TPamAccount>({
|
||||
folderId?: C["folderId"];
|
||||
name: C["name"];
|
||||
description?: C["description"];
|
||||
rotationEnabled: C["rotationEnabled"];
|
||||
rotationIntervalSeconds: C["rotationIntervalSeconds"];
|
||||
}>;
|
||||
updateAccountSchema: z.ZodType<{
|
||||
credentials?: C["credentials"];
|
||||
name?: C["name"];
|
||||
description?: C["description"];
|
||||
rotationEnabled?: C["rotationEnabled"];
|
||||
rotationIntervalSeconds?: C["rotationIntervalSeconds"];
|
||||
}>;
|
||||
accountResponseSchema: z.ZodTypeAny;
|
||||
}) => {
|
||||
@@ -60,7 +64,9 @@ export const registerPamResourceEndpoints = <C extends TPamAccount>({
|
||||
resourceType,
|
||||
folderId: req.body.folderId,
|
||||
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,
|
||||
resourceType,
|
||||
name: req.body.name,
|
||||
description: req.body.description
|
||||
description: req.body.description,
|
||||
rotationEnabled: req.body.rotationEnabled,
|
||||
rotationIntervalSeconds: req.body.rotationIntervalSeconds
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -21,11 +21,13 @@ export const registerPamResourceEndpoints = <T extends TPamResource>({
|
||||
connectionDetails: T["connectionDetails"];
|
||||
gatewayId: T["gatewayId"];
|
||||
name: T["name"];
|
||||
rotationAccountCredentials?: T["rotationAccountCredentials"];
|
||||
}>;
|
||||
updateResourceSchema: z.ZodType<{
|
||||
connectionDetails?: T["connectionDetails"];
|
||||
gatewayId?: T["gatewayId"];
|
||||
name?: T["name"];
|
||||
rotationAccountCredentials?: T["rotationAccountCredentials"];
|
||||
}>;
|
||||
resourceResponseSchema: z.ZodTypeAny;
|
||||
}) => {
|
||||
|
||||
@@ -515,6 +515,7 @@ export enum EventType {
|
||||
PAM_ACCOUNT_CREATE = "pam-account-create",
|
||||
PAM_ACCOUNT_UPDATE = "pam-account-update",
|
||||
PAM_ACCOUNT_DELETE = "pam-account-delete",
|
||||
PAM_ACCOUNT_CREDENTIAL_ROTATION = "pam-account-credential-rotation",
|
||||
PAM_RESOURCE_LIST = "pam-resource-list",
|
||||
PAM_RESOURCE_GET = "pam-resource-get",
|
||||
PAM_RESOURCE_CREATE = "pam-resource-create",
|
||||
@@ -3795,6 +3796,8 @@ interface PamAccountCreateEvent {
|
||||
folderId?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
rotationEnabled: boolean;
|
||||
rotationIntervalSeconds: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3806,6 +3809,8 @@ interface PamAccountUpdateEvent {
|
||||
resourceType: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
rotationEnabled?: boolean;
|
||||
rotationIntervalSeconds?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3819,6 +3824,16 @@ interface PamAccountDeleteEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface PamAccountCredentialRotationEvent {
|
||||
type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION;
|
||||
metadata: {
|
||||
accountName: string;
|
||||
accountId: string;
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PamResourceListEvent {
|
||||
type: EventType.PAM_RESOURCE_LIST;
|
||||
metadata: {
|
||||
@@ -4209,6 +4224,7 @@ export type Event =
|
||||
| PamAccountCreateEvent
|
||||
| PamAccountUpdateEvent
|
||||
| PamAccountDeleteEvent
|
||||
| PamAccountCredentialRotationEvent
|
||||
| PamResourceListEvent
|
||||
| PamResourceGetEvent
|
||||
| PamResourceCreateEvent
|
||||
|
||||
@@ -39,5 +39,19 @@ export const pamAccountDALFactory = (db: TDbClient) => {
|
||||
}));
|
||||
};
|
||||
|
||||
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`)
|
||||
.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";
|
||||
import { DatabaseErrorCode } from "@app/lib/error-codes";
|
||||
import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-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 { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { TPamFolderDALFactory } from "../pam-folder/pam-folder-dal";
|
||||
@@ -45,10 +47,12 @@ type TPamAccountServiceFactoryDep = {
|
||||
"getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId"
|
||||
>;
|
||||
userDAL: TUserDALFactory;
|
||||
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
|
||||
};
|
||||
|
||||
export type TPamAccountServiceFactory = ReturnType<typeof pamAccountServiceFactory>;
|
||||
|
||||
const ROTATION_CONCURRENCY_LIMIT = 10;
|
||||
|
||||
export const pamAccountServiceFactory = ({
|
||||
pamResourceDAL,
|
||||
pamSessionDAL,
|
||||
@@ -59,10 +63,19 @@ export const pamAccountServiceFactory = ({
|
||||
permissionService,
|
||||
licenseService,
|
||||
kmsService,
|
||||
gatewayV2Service
|
||||
gatewayV2Service,
|
||||
auditLogService
|
||||
}: TPamAccountServiceFactoryDep) => {
|
||||
const create = async (
|
||||
{ credentials, resourceId, name, description, folderId }: TCreateAccountDTO,
|
||||
{
|
||||
credentials,
|
||||
resourceId,
|
||||
name,
|
||||
description,
|
||||
folderId,
|
||||
rotationEnabled,
|
||||
rotationIntervalSeconds
|
||||
}: TCreateAccountDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
@@ -84,6 +97,10 @@ export const pamAccountServiceFactory = ({
|
||||
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({
|
||||
pamFolderDAL,
|
||||
folderId,
|
||||
@@ -126,7 +143,9 @@ export const pamAccountServiceFactory = ({
|
||||
encryptedCredentials,
|
||||
name,
|
||||
description,
|
||||
folderId
|
||||
folderId,
|
||||
rotationEnabled,
|
||||
rotationIntervalSeconds
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -145,7 +164,7 @@ export const pamAccountServiceFactory = ({
|
||||
};
|
||||
|
||||
const updateById = async (
|
||||
{ accountId, credentials, description, name }: TUpdateAccountDTO,
|
||||
{ accountId, credentials, description, name, rotationEnabled, rotationIntervalSeconds }: TUpdateAccountDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
@@ -195,6 +214,17 @@ export const pamAccountServiceFactory = ({
|
||||
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) {
|
||||
const connectionDetails = await decryptResourceConnectionDetails({
|
||||
projectId: account.projectId,
|
||||
@@ -516,12 +546,84 @@ 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) => {
|
||||
try {
|
||||
const resource = await pamResourceDAL.findById(account.resourceId);
|
||||
if (!resource || !resource.encryptedRotationAccountCredentials) return;
|
||||
|
||||
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()
|
||||
});
|
||||
|
||||
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: resource.resourceType
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to rotate credentials for account ${account.id}`);
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await Promise.all(rotationPromises);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
create,
|
||||
updateById,
|
||||
deleteById,
|
||||
list,
|
||||
access,
|
||||
getSessionCredentials
|
||||
getSessionCredentials,
|
||||
rotateAllDueAccounts
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { TPamAccount } from "../pam-resource/pam-resource-types";
|
||||
|
||||
// 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">> & {
|
||||
accountId: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TPamResources } from "@app/db/schemas";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { KmsDataKey } from "@app/services/kms/kms-types";
|
||||
|
||||
import { decryptAccountCredentials } from "../pam-account/pam-account-fns";
|
||||
import { TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types";
|
||||
import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns";
|
||||
|
||||
@@ -63,6 +64,13 @@ export const decryptResource = async (
|
||||
encryptedConnectionDetails: resource.encryptedConnectionDetails,
|
||||
projectId,
|
||||
kmsService
|
||||
})
|
||||
}),
|
||||
rotationAccountCredentials: resource.encryptedRotationAccountCredentials
|
||||
? await decryptAccountCredentials({
|
||||
encryptedCredentials: resource.encryptedRotationAccountCredentials,
|
||||
projectId,
|
||||
kmsService
|
||||
})
|
||||
: null
|
||||
} as TPamResource;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { slugSchema } from "@app/server/lib/schemas";
|
||||
// Resources
|
||||
export const BasePamResourceSchema = PamResourcesSchema.omit({
|
||||
encryptedConnectionDetails: true,
|
||||
encryptedRotationAccountCredentials: true,
|
||||
resourceType: true
|
||||
});
|
||||
|
||||
@@ -37,10 +38,14 @@ export const BaseCreatePamAccountSchema = z.object({
|
||||
resourceId: z.string().uuid(),
|
||||
folderId: z.string().uuid().optional(),
|
||||
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)
|
||||
});
|
||||
|
||||
export const BaseUpdatePamAccountSchema = z.object({
|
||||
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).optional()
|
||||
});
|
||||
|
||||
@@ -10,10 +10,16 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
|
||||
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
|
||||
import { TLicenseServiceFactory } from "../license/license-service";
|
||||
import { encryptAccountCredentials } from "../pam-account/pam-account-fns";
|
||||
import { TPamResourceDALFactory } from "./pam-resource-dal";
|
||||
import { PamResource } from "./pam-resource-enums";
|
||||
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";
|
||||
|
||||
type TPamResourceServiceFactoryDep = {
|
||||
@@ -61,7 +67,7 @@ export const pamResourceServiceFactory = ({
|
||||
};
|
||||
|
||||
const create = async (
|
||||
{ resourceType, connectionDetails, gatewayId, name, projectId }: TCreateResourceDTO,
|
||||
{ resourceType, connectionDetails, gatewayId, name, projectId, rotationAccountCredentials }: TCreateResourceDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const orgLicensePlan = await licenseService.getPlan(actor.orgId);
|
||||
@@ -88,26 +94,42 @@ export const pamResourceServiceFactory = ({
|
||||
gatewayId,
|
||||
gatewayV2Service
|
||||
);
|
||||
const validatedConnectionDetails = await factory.validateConnection();
|
||||
|
||||
const validatedConnectionDetails = await factory.validateConnection();
|
||||
const encryptedConnectionDetails = await encryptResourceConnectionDetails({
|
||||
connectionDetails: validatedConnectionDetails,
|
||||
projectId,
|
||||
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({
|
||||
resourceType,
|
||||
encryptedConnectionDetails,
|
||||
gatewayId,
|
||||
name,
|
||||
projectId
|
||||
projectId,
|
||||
encryptedRotationAccountCredentials
|
||||
});
|
||||
|
||||
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);
|
||||
if (!orgLicensePlan.pam) {
|
||||
throw new BadRequestError({
|
||||
@@ -151,6 +173,34 @@ export const pamResourceServiceFactory = ({
|
||||
updateDoc.encryptedConnectionDetails = encryptedConnectionDetails;
|
||||
}
|
||||
|
||||
if (rotationAccountCredentials !== undefined) {
|
||||
updateDoc.encryptedRotationAccountCredentials = null;
|
||||
|
||||
if (rotationAccountCredentials) {
|
||||
const decryptedConnectionDetails = 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
|
||||
);
|
||||
|
||||
const validatedRotationAccountCredentials =
|
||||
await factory.validateAccountCredentials(rotationAccountCredentials);
|
||||
|
||||
updateDoc.encryptedRotationAccountCredentials = await encryptAccountCredentials({
|
||||
credentials: validatedRotationAccountCredentials,
|
||||
projectId: resource.projectId,
|
||||
kmsService
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If nothing was updated, return the fetched resource
|
||||
if (Object.keys(updateDoc).length === 0) {
|
||||
return decryptResource(resource, resource.projectId, kmsService);
|
||||
|
||||
@@ -18,7 +18,7 @@ export type TPamAccountCredentials = TPostgresAccountCredentials;
|
||||
// Resource DTOs
|
||||
export type TCreateResourceDTO = Pick<
|
||||
TPamResource,
|
||||
"name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId"
|
||||
"name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId" | "rotationAccountCredentials"
|
||||
>;
|
||||
|
||||
export type TUpdateResourceDTO = Partial<Omit<TCreateResourceDTO, "resourceType" | "projectId">> & {
|
||||
@@ -30,6 +30,10 @@ export type TPamResourceFactoryValidateConnection<T extends TPamResourceConnecti
|
||||
export type TPamResourceFactoryValidateAccountCredentials<C extends TPamAccountCredentials> = (
|
||||
credentials: C
|
||||
) => Promise<C>;
|
||||
export type TPamResourceFactoryRotateAccountCredentials<C extends TPamAccountCredentials> = (
|
||||
rotationAccountCredentials: C,
|
||||
currentCredentials: C
|
||||
) => Promise<C>;
|
||||
|
||||
export type TPamResourceFactory<T extends TPamResourceConnectionDetails, C extends TPamAccountCredentials> = (
|
||||
resourceType: PamResource,
|
||||
@@ -39,4 +43,5 @@ export type TPamResourceFactory<T extends TPamResourceConnectionDetails, C exten
|
||||
) => {
|
||||
validateConnection: TPamResourceFactoryValidateConnection<T>;
|
||||
validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials<C>;
|
||||
rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials<C>;
|
||||
};
|
||||
|
||||
@@ -15,13 +15,15 @@ import {
|
||||
BaseSqlResourceConnectionDetailsSchema
|
||||
} from "../shared/sql/sql-resource-schemas";
|
||||
|
||||
// Resources
|
||||
export const PostgresResourceConnectionDetailsSchema = BaseSqlResourceConnectionDetailsSchema;
|
||||
export const PostgresAccountCredentialsSchema = BaseSqlAccountCredentialsSchema;
|
||||
|
||||
// Resources
|
||||
const BasePostgresResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.Postgres) });
|
||||
|
||||
export const PostgresResourceSchema = BasePostgresResourceSchema.extend({
|
||||
connectionDetails: PostgresResourceConnectionDetailsSchema
|
||||
connectionDetails: PostgresResourceConnectionDetailsSchema,
|
||||
rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional()
|
||||
});
|
||||
|
||||
export const PostgresResourceListItemSchema = z.object({
|
||||
@@ -30,16 +32,16 @@ export const PostgresResourceListItemSchema = z.object({
|
||||
});
|
||||
|
||||
export const CreatePostgresResourceSchema = BaseCreatePamResourceSchema.extend({
|
||||
connectionDetails: PostgresResourceConnectionDetailsSchema
|
||||
connectionDetails: PostgresResourceConnectionDetailsSchema,
|
||||
rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional()
|
||||
});
|
||||
|
||||
export const UpdatePostgresResourceSchema = BaseUpdatePamResourceSchema.extend({
|
||||
connectionDetails: PostgresResourceConnectionDetailsSchema.optional()
|
||||
connectionDetails: PostgresResourceConnectionDetailsSchema.optional(),
|
||||
rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional()
|
||||
});
|
||||
|
||||
// Accounts
|
||||
export const PostgresAccountCredentialsSchema = BaseSqlAccountCredentialsSchema;
|
||||
|
||||
export const PostgresAccountSchema = BasePamAccountSchema.extend({
|
||||
credentials: PostgresAccountCredentialsSchema
|
||||
});
|
||||
|
||||
@@ -6,9 +6,14 @@ import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { GatewayProxyProtocol } from "@app/lib/gateway";
|
||||
import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
|
||||
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";
|
||||
|
||||
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 {
|
||||
validateConnection,
|
||||
validateAccountCredentials
|
||||
validateAccountCredentials,
|
||||
rotateAccountCredentials
|
||||
};
|
||||
};
|
||||
|
||||
@@ -77,7 +77,8 @@ export enum QueueName {
|
||||
DailyReminders = "daily-reminders",
|
||||
SecretReminderMigration = "secret-reminder-migration",
|
||||
UserNotification = "user-notification",
|
||||
HealthAlert = "health-alert"
|
||||
HealthAlert = "health-alert",
|
||||
PamAccountRotation = "pam-account-rotation"
|
||||
}
|
||||
|
||||
export enum QueueJobs {
|
||||
@@ -126,7 +127,8 @@ export enum QueueJobs {
|
||||
DailyReminders = "daily-reminders",
|
||||
SecretReminderMigration = "secret-reminder-migration",
|
||||
UserNotification = "user-notification-job",
|
||||
HealthAlert = "health-alert"
|
||||
HealthAlert = "health-alert",
|
||||
PamAccountRotation = "pam-account-rotation"
|
||||
}
|
||||
|
||||
export type TQueueJobTypes = {
|
||||
@@ -357,6 +359,10 @@ export type TQueueJobTypes = {
|
||||
name: QueueJobs.HealthAlert;
|
||||
payload: undefined;
|
||||
};
|
||||
[QueueName.PamAccountRotation]: {
|
||||
name: QueueJobs.PamAccountRotation;
|
||||
payload: undefined;
|
||||
};
|
||||
};
|
||||
|
||||
const SECRET_SCANNING_JOBS = [
|
||||
|
||||
@@ -250,6 +250,7 @@ import { orgDALFactory } from "@app/services/org/org-dal";
|
||||
import { orgServiceFactory } from "@app/services/org/org-service";
|
||||
import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service";
|
||||
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 { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal";
|
||||
import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service";
|
||||
@@ -2193,7 +2194,13 @@ export const registerRoutes = async (
|
||||
pamSessionDAL,
|
||||
permissionService,
|
||||
projectDAL,
|
||||
userDAL
|
||||
userDAL,
|
||||
auditLogService
|
||||
});
|
||||
|
||||
const pamAccountRotation = pamAccountRotationServiceFactory({
|
||||
queueService,
|
||||
pamAccountService
|
||||
});
|
||||
|
||||
const pamSessionService = pamSessionServiceFactory({
|
||||
@@ -2220,6 +2227,7 @@ export const registerRoutes = async (
|
||||
await dailyResourceCleanUp.init();
|
||||
await healthAlert.init();
|
||||
await pkiSyncCleanup.init();
|
||||
await pamAccountRotation.init();
|
||||
await dailyReminderQueueService.startDailyRemindersJob();
|
||||
await dailyReminderQueueService.startSecretReminderMigrationJob();
|
||||
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: 1 // 5 * 60
|
||||
}
|
||||
);
|
||||
|
||||
await queueService.schedulePg(
|
||||
QueueJobs.PamAccountRotation,
|
||||
"0 * * * *", // Schedule to run every hour
|
||||
undefined,
|
||||
{ tz: "UTC" }
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
init
|
||||
};
|
||||
};
|
||||
@@ -12,6 +12,9 @@ export interface TBasePamAccount {
|
||||
};
|
||||
name: string;
|
||||
description?: string | null;
|
||||
rotationEnabled: boolean;
|
||||
rotationIntervalSeconds: number;
|
||||
lastRotatedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TBasePamResource } from "./base-resource";
|
||||
// Resources
|
||||
export type TPostgresResource = TBasePamResource & { resourceType: PamResourceType.Postgres } & {
|
||||
connectionDetails: TBaseSqlConnectionDetails;
|
||||
rotationAccountCredentials?: TBaseSqlCredentials | null;
|
||||
};
|
||||
|
||||
// Accounts
|
||||
|
||||
@@ -37,7 +37,10 @@ const CreateForm = ({
|
||||
console.log({ folderId });
|
||||
|
||||
const onSubmit = async (
|
||||
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials">
|
||||
formData: DiscriminativePick<
|
||||
TPamAccount,
|
||||
"name" | "description" | "credentials" | "rotationEnabled" | "rotationIntervalSeconds"
|
||||
>
|
||||
) => {
|
||||
try {
|
||||
const account = await createPamAccount.mutateAsync({
|
||||
@@ -74,7 +77,10 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
|
||||
const updatePamAccount = useUpdatePamAccount();
|
||||
|
||||
const onSubmit = async (
|
||||
formData: DiscriminativePick<TPamAccount, "name" | "description" | "credentials">
|
||||
formData: DiscriminativePick<
|
||||
TPamAccount,
|
||||
"name" | "description" | "credentials" | "rotationEnabled" | "rotationIntervalSeconds"
|
||||
>
|
||||
) => {
|
||||
try {
|
||||
const updatedAccount = await updatePamAccount.mutateAsync({
|
||||
|
||||
@@ -8,13 +8,14 @@ import { TPostgresAccount } from "@app/hooks/api/pam";
|
||||
import { BaseSqlAccountSchema } from "./shared/sql-account-schemas";
|
||||
import { SqlAccountFields } from "./shared/SqlAccountFields";
|
||||
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
|
||||
import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountFields";
|
||||
|
||||
type Props = {
|
||||
account?: TPostgresAccount;
|
||||
onSubmit: (formData: FormData) => Promise<void>;
|
||||
};
|
||||
|
||||
const formSchema = genericAccountFieldsSchema.extend({
|
||||
const formSchema = genericAccountFieldsSchema.extend(rotateAccountFieldsSchema.shape).extend({
|
||||
credentials: BaseSqlAccountSchema
|
||||
});
|
||||
|
||||
@@ -50,6 +51,7 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
|
||||
>
|
||||
<GenericAccountFields />
|
||||
<SqlAccountFields isUpdate={isUpdate} />
|
||||
<RotateAccountFields />
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { FormControl, Select, SelectItem, Switch } from "@app/components/v2";
|
||||
|
||||
export const rotateAccountFieldsSchema = z.object({
|
||||
rotationEnabled: z.boolean(),
|
||||
rotationIntervalSeconds: z.number()
|
||||
});
|
||||
|
||||
export const RotateAccountFields = () => {
|
||||
const { control, watch } = useFormContext<{
|
||||
rotationEnabled: boolean;
|
||||
rotationIntervalSeconds: number;
|
||||
}>();
|
||||
|
||||
const rotationEnabled = watch("rotationEnabled");
|
||||
|
||||
return (
|
||||
<div className="flex h-9 items-center gap-3">
|
||||
<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="can-remove-certificates"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
onCheckedChange={onChange}
|
||||
isChecked={value}
|
||||
/>
|
||||
</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.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}
|
||||
dropdownContainerStyle={{
|
||||
width: "120px"
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -5,9 +5,11 @@ import { z } from "zod";
|
||||
|
||||
import { Button, ModalClose } from "@app/components/v2";
|
||||
import { PamResourceType, TPostgresResource } from "@app/hooks/api/pam";
|
||||
import { BaseSqlAccountSchema } from "@app/pages/pam/PamAccountsPage/components/PamAccountForm/shared/sql-account-schemas";
|
||||
|
||||
import { BaseSqlResourceSchema } from "./shared/sql-resource-schemas";
|
||||
import { SqlResourceFields } from "./shared/SqlResourceFields";
|
||||
import { SqlRotateAccountFields } from "./shared/SqlRotateAccountFields";
|
||||
import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields";
|
||||
|
||||
type Props = {
|
||||
@@ -17,7 +19,8 @@ type Props = {
|
||||
|
||||
const formSchema = genericResourceFieldsSchema.extend({
|
||||
resourceType: z.literal(PamResourceType.Postgres),
|
||||
connectionDetails: BaseSqlResourceSchema
|
||||
connectionDetails: BaseSqlResourceSchema,
|
||||
rotationAccountCredentials: BaseSqlAccountSchema.nullable().optional()
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
@@ -59,6 +62,7 @@ export const PostgresResourceForm = ({ resource, onSubmit }: Props) => {
|
||||
selectedTabIndex={selectedTabIndex}
|
||||
setSelectedTabIndex={setSelectedTabIndex}
|
||||
/>
|
||||
<SqlRotateAccountFields />
|
||||
<div className="mt-6 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Controller, useFormContext } from "react-hook-form";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
FormControl,
|
||||
Input,
|
||||
SecretInput
|
||||
} from "@app/components/v2";
|
||||
|
||||
export const SqlRotateAccountFields = () => {
|
||||
const { control } = useFormContext();
|
||||
|
||||
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">Credential Rotation Account</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent childrenClassName="px-0 py-0">
|
||||
<p className="mb-2 text-xs">
|
||||
Credentials to the high privilege 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} />
|
||||
</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"
|
||||
>
|
||||
<SecretInput
|
||||
containerClassName="text-gray-400 group-focus-within:border-primary-400/50! border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user