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

This commit is contained in:
Daniel Hougaard
2025-10-22 23:23:14 +04:00
52 changed files with 1087 additions and 270 deletions

View File

@@ -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");
});
}
}

View File

@@ -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().nullable().optional(),
lastRotatedAt: z.date().nullable().optional()
});
export type TPamAccounts = z.infer<typeof PamAccountsSchema>;

View File

@@ -17,7 +17,8 @@ export const PamResourcesSchema = z.object({
resourceType: z.string(),
encryptedConnectionDetails: zodBuffer,
createdAt: z.date(),
updatedAt: z.date()
updatedAt: z.date(),
encryptedRotationAccountCredentials: zodBuffer.nullable().optional()
});
export type TPamResources = z.infer<typeof PamResourcesSchema>;

View File

@@ -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
}
}
});

View File

@@ -1,7 +1,7 @@
import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums";
import {
CreatePostgresResourceSchema,
PostgresResourceSchema,
SanitizedPostgresResourceSchema,
UpdatePostgresResourceSchema
} 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({
server,
resourceType: PamResource.Postgres,
resourceResponseSchema: PostgresResourceSchema,
resourceResponseSchema: SanitizedPostgresResourceSchema,
createResourceSchema: CreatePostgresResourceSchema,
updateResourceSchema: UpdatePostgresResourceSchema
});

View File

@@ -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;
}) => {

View File

@@ -3,14 +3,14 @@ import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import {
PostgresResourceListItemSchema,
PostgresResourceSchema
SanitizedPostgresResourceSchema
} from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
// Use z.union([...]) when more resources are added
const ResourceSchema = PostgresResourceSchema;
const SanitizedResourceSchema = SanitizedPostgresResourceSchema;
const ResourceOptionsSchema = z.discriminatedUnion("resource", [PostgresResourceListItemSchema]);
@@ -50,7 +50,7 @@ export const registerPamResourceRouter = async (server: FastifyZodProvider) => {
}),
response: {
200: z.object({
resources: ResourceSchema.array()
resources: SanitizedResourceSchema.array()
})
}
},

View File

@@ -527,6 +527,8 @@ 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_ACCOUNT_CREDENTIAL_ROTATION_FAILED = "pam-account-credential-rotation-failed",
PAM_RESOURCE_LIST = "pam-resource-list",
PAM_RESOURCE_GET = "pam-resource-get",
PAM_RESOURCE_CREATE = "pam-resource-create",
@@ -3915,6 +3917,8 @@ interface PamAccountCreateEvent {
folderId?: string | null;
name: string;
description?: string | null;
rotationEnabled: boolean;
rotationIntervalSeconds?: number | null;
};
}
@@ -3926,6 +3930,8 @@ interface PamAccountUpdateEvent {
resourceType: string;
name?: string;
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 {
type: EventType.PAM_RESOURCE_LIST;
metadata: {
@@ -4340,6 +4367,8 @@ export type Event =
| PamAccountCreateEvent
| PamAccountUpdateEvent
| PamAccountDeleteEvent
| PamAccountCredentialRotationEvent
| PamAccountCredentialRotationFailedEvent
| PamResourceListEvent
| PamResourceGetEvent
| PamResourceCreateEvent

View File

@@ -216,9 +216,8 @@ export const licenseServiceFactory = ({
const membersUsed = await licenseDAL.countOfOrgMembers(rootOrgId);
currentPlan.membersUsed = membersUsed;
const identityUsed = await licenseDAL.countOrgUsersAndIdentities(rootOrgId);
currentPlan.identitiesUsed = identityUsed;
if (currentPlan.identityLimit && currentPlan.identityLimit !== identityUsed) {
if (currentPlan?.identitiesUsed && currentPlan.identitiesUsed !== identityUsed) {
try {
await licenseServerCloudApi.request.patch(`/api/license-server/v1/customers/${org.customerId}/cloud-plan`, {
quantity: membersUsed,
@@ -231,6 +230,7 @@ export const licenseServiceFactory = ({
);
}
}
currentPlan.identitiesUsed = identityUsed;
await keyStore.setItemWithExpiry(
FEATURE_CACHE_KEY(org.id),

View File

@@ -18,7 +18,8 @@ export const pamAccountDALFactory = (db: TDbClient) => {
.select(
// resource
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) {
@@ -28,16 +29,35 @@ export const pamAccountDALFactory = (db: TDbClient) => {
const accounts = await query;
return accounts.map(({ resourceId, resourceName, resourceType, ...account }) => ({
...account,
resourceId,
resource: {
id: resourceId,
name: resourceName,
resourceType
}
}));
return accounts.map(
({ resourceId, resourceName, resourceType, encryptedRotationAccountCredentials, ...account }) => ({
...account,
resourceId,
resource: {
id: resourceId,
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 };
};

View File

@@ -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);
@@ -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);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` });
@@ -84,6 +103,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,12 +149,19 @@ export const pamAccountServiceFactory = ({
encryptedCredentials,
name,
description,
folderId
folderId,
rotationEnabled,
rotationIntervalSeconds
});
return {
...(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) {
if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) {
@@ -145,7 +175,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 +225,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,
@@ -211,7 +252,7 @@ export const pamAccountServiceFactory = ({
// Logic to prevent overwriting unedited censored values
const finalCredentials = { ...credentials };
if (credentials.password === "******") {
if (credentials.password === "__INFISICAL_UNCHANGED__") {
const decryptedCredentials = await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
projectId: account.projectId,
@@ -239,7 +280,12 @@ export const pamAccountServiceFactory = ({
return {
...(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 {
...(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<
TPamAccounts & {
resource: Pick<TPamResources, "id" | "name" | "resourceType">;
resource: Pick<TPamResources, "id" | "name" | "resourceType"> & { rotationCredentialsConfigured: boolean };
credentials: TPamAccountCredentials;
}
> = [];
@@ -330,7 +381,8 @@ export const pamAccountServiceFactory = ({
resource: {
id: account.resource.id,
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 {
create,
updateById,
deleteById,
list,
access,
getSessionCredentials
getSessionCredentials,
rotateAllDueAccounts
};
};

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -6,6 +6,7 @@ import { slugSchema } from "@app/server/lib/schemas";
// Resources
export const BasePamResourceSchema = PamResourcesSchema.omit({
encryptedConnectionDetails: true,
encryptedRotationAccountCredentials: true,
resourceType: true
});
@@ -30,6 +31,8 @@ export const BasePamAccountSchemaWithResource = BasePamAccountSchema.extend({
id: true,
name: true,
resourceType: true
}).extend({
rotationCredentialsConfigured: z.boolean()
})
});
@@ -37,10 +40,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).nullable().optional()
});
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).nullable().optional()
});

View File

@@ -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 { decryptAccountCredentials, 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,60 @@ export const pamResourceServiceFactory = ({
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 (Object.keys(updateDoc).length === 0) {
return decryptResource(resource, resource.projectId, kmsService);

View File

@@ -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>;
};

View File

@@ -15,13 +15,24 @@ 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 SanitizedPostgresResourceSchema = BasePostgresResourceSchema.extend({
connectionDetails: PostgresResourceConnectionDetailsSchema,
rotationAccountCredentials: PostgresAccountCredentialsSchema.pick({
username: true
})
.nullable()
.optional()
});
export const PostgresResourceListItemSchema = z.object({
@@ -30,16 +41,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
});

View File

@@ -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
};
};

View File

@@ -16,6 +16,6 @@ export const BaseSqlResourceConnectionDetailsSchema = z.object({
// Accounts
export const BaseSqlAccountCredentialsSchema = z.object({
username: z.string().trim().min(1),
password: z.string().trim().min(1)
username: z.string().trim().min(1).max(63),
password: z.string().trim().min(1).max(256)
});

View File

@@ -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 = [

View File

@@ -261,6 +261,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";
@@ -2258,7 +2259,13 @@ export const registerRoutes = async (
pamSessionDAL,
permissionService,
projectDAL,
userDAL
userDAL,
auditLogService
});
const pamAccountRotation = pamAccountRotationServiceFactory({
queueService,
pamAccountService
});
const pamSessionService = pamSessionServiceFactory({
@@ -2318,6 +2325,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();

View File

@@ -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
};
};

View File

@@ -7,20 +7,20 @@ To set a strong foundation, this section outlines how we, the community and memb
should approach the development and contribution process.
## Code-bases
Infisical has two major code-bases. One for the platform code, and one for SDKs. The contribution process has some key differences between the two, so we've split the documentation into two sections:
- The [Infisical Platform](https://github.com/Infisical/infisical), the Infisical platform itself.
- The [Infisical SDK](https://infisical.com/docs/sdks/overview), the official Infisical client SDKs.
<CardGroup cols={2}>
<Card title="Infisical Platform" href="/contributing/platform/developing" icon="layer-group" color="#A1B659">
The Infisical platform is the core of the Infisical ecosystem.
</Card>
<Card href="/contributing/sdk/developing" title="Infisical SDK" icon="code" color="#A1B659">
The SDKs are the official Infisical client libraries, used by developers to easily interact with the Infisical platform.
</Card>
</CardGroup>
- The <b>Infisical SDKs</b>, please refer to each individual SDK repositories for more information.
- [Node.js SDK](https://github.com/Infisical/node-sdk-v2)
- [Python SDK](https://github.com/Infisical/python-sdk-official)
- [Java SDK](https://github.com/Infisical/java-sdk)
- [.NET SDK](https://github.com/Infisical/infisical-dotnet-sdk)
- [Go SDK](https://github.com/Infisical/go-sdk)
- [C++ SDK](https://github.com/Infisical/infisical-cpp-sdk)
- [PHP SDK](https://github.com/Infisical/php-sdk)
- [Rust SDK](https://github.com/Infisical/rust-sdk)
- [Ruby SDK](https://github.com/infisical/sdk)
## Community
@@ -45,15 +45,12 @@ If you're ever in doubt about whether or not a proposed feature aligns with Infi
## Writing and submitting code
Anyone can contribute code to Infisical. To get started, check out the local development guides for each language.
- Local development guide for Platform is [here](/contributing/platform/developing).
- Local development guide for SDK is [here](/contributing/sdk/developing).
Anyone can contribute code to Infisical. To get started, check out the local development guide for the platform:
- Local development guide for Platform is [here](/contributing/platform/developing).
## Licensing
Most of Infisical's code is under the MIT license, though some paid feature restrictions are covered by a proprietary license.
Any third party components incorporated into our code are licensed under the original license provided by the applicable component owner.

View File

@@ -776,6 +776,15 @@
]
}
]
},
{
"item": "Infisical PAM",
"groups": [
{
"group": "Infisical PAM",
"pages": ["documentation/platform/pam/overview"]
}
]
}
]
},

View File

@@ -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 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 PAM](/documentation/platform/pam/overview): Manage access to resources like databases, servers, and accounts with policy-based controls and approvals.

View File

@@ -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.
</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 cols="1">

View File

@@ -13,7 +13,7 @@ Each identity must authenticate with the Infisical API using a supported authent
Key Features:
- Role Assignment: Identities must be assigned [roles](/documentation/platform/role-based-access-controls). These roles determine the scope of access to resources, either at the organization level or project level.
- Role Assignment: Identities must be assigned [roles](/documentation/platform/access-controls/role-based-access-controls). These roles determine the scope of access to resources, either at the organization level or project level.
- Auth/Token Configuration: Identities must be configured with corresponding authentication methods and access token properties to securely interact with the Infisical API.
## Workflow

View File

@@ -0,0 +1,45 @@
---
title: "Infisical PAM"
sidebarTitle: "Overview"
description: "Learn how to manage access to resources like databases, servers, and accounts with policy-based controls and approvals."
---
Infisical Privileged Access Management (PAM) provides a centralized way to manage and secure access to your critical infrastructure. It allows you to enforce fine-grained, policy-based controls over resources like databases, servers, and more, ensuring that only authorized users can access sensitive systems, and only when they need to.
### How it Works
Infisical PAM employs a resource-based model to organize and manage access. This model is designed to be intuitive and scalable.
#### 1. Create a Resource
The first step is to define a resource you want to manage. A resource represents a target system, such as a PostgreSQL database. When creating a resource, you'll provide the necessary connection details, like the host and port.
![Create Resource](/images/pam/overview/create-resource.png)
#### 2. Add Accounts to the Resource
Once a resource is created, you can add accounts to it. An account represents a specific set of credentials (e.g., a username and password) that can be used to access the resource. This allows you to manage multiple sets of credentials for a single database or server from one place.
![Create Account](/images/pam/overview/create-account.png)
### Infisical PAM Features
#### Session Logging and Auditing
- **Session Logging**: All user sessions are extensively logged, providing a detailed and searchable record of activities performed during a session.
- **Audit Logging**: Every significant event, such as a user starting a session or accessing an account's credentials, is recorded in audit logs. This gives you complete visibility over your project.
![Session Page](/images/pam/overview/session-page.png)
#### Automated Credential Rotation
Infisical PAM can automatically rotate account credentials to enhance your security posture.
Here’s how it works:
1. **Add a Rotation Account**: On the resource level, you configure a "rotation account." This is a master or privileged account that has the necessary permissions to change the passwords of other accounts on that same resource.
![Credential Rotation Account](/images/pam/overview/credential-rotation-account.png)
2. **Configure Rotation on Accounts**: For each individual account you want to rotate, you can simply enable rotation and set a desired interval (e.g., every 30 days).
![Rotate Credentials Account](/images/pam/overview/rotate-credentials-account.png)
Infisical will then use the rotation account on the resource to automatically update the credentials of the target account at the specified interval, eliminating credential staleness.

View File

@@ -22,6 +22,7 @@ The supported project types are:
- [Infisical PKI](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs.
- [Infisical 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 PAM](/documentation/platform/pam/overview): Manage access to resources like databases, servers, and accounts with policy-based controls and approvals.
## Roles and Access Control

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

View File

@@ -0,0 +1 @@
export const UNCHANGED_PASSWORD_SENTINEL = "__INFISICAL_UNCHANGED__";

View File

@@ -3,6 +3,7 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TPamResourceOption } from "./types/resource-options";
import { PamResourceType } from "./enums";
import { TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types";
export const pamKeys = {
@@ -12,6 +13,12 @@ export const pamKeys = {
session: () => [...pamKeys.all, "session"] as const,
listResourceOptions: () => [...pamKeys.resource(), "options"] as const,
listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId],
getResource: (resourceType: string, resourceId: string) => [
...pamKeys.resource(),
"get",
resourceType,
resourceId
],
listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId],
getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId],
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
export const useListPamAccounts = (
projectId: string,

View File

@@ -9,9 +9,13 @@ export interface TBasePamAccount {
id: string;
name: string;
resourceType: PamResourceType;
rotationCredentialsConfigured: boolean;
};
name: string;
description?: string | null;
rotationEnabled: boolean;
rotationIntervalSeconds?: number | null;
lastRotatedAt?: string | null;
createdAt: string;
updatedAt: string;
}

View File

@@ -6,6 +6,7 @@ import { TBasePamResource } from "./base-resource";
// Resources
export type TPostgresResource = TBasePamResource & { resourceType: PamResourceType.Postgres } & {
connectionDetails: TBaseSqlConnectionDetails;
rotationAccountCredentials?: TBaseSqlCredentials | null;
};
// Accounts

View File

@@ -138,7 +138,7 @@ export const SignupInvitePage = () => {
// Step 4 of the sign up process (download the emergency kit pdf)
const stepConfirmEmail = (
<div className="mx-1 mb-36 flex h-7/12 w-full max-w-xs flex-col items-center rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-4 py-8 drop-shadow-xl md:mb-16 md:max-w-lg md:px-6">
<div className="mx-1 mt-14 mb-36 flex w-full max-w-xs flex-col items-center rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-4 py-8 drop-shadow-xl md:mb-16 md:max-w-lg md:px-6">
<p className="mb-2 flex justify-center text-center text-4xl font-medium text-primary-100">
Confirm your email
</p>
@@ -179,7 +179,7 @@ export const SignupInvitePage = () => {
// Because this is the invite signup - we directly go to the last step of signup (email is already verified)
const main = (
<div className="mx-auto mb-32 h-7/12 w-max rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-8 py-10 drop-shadow-xl md:mb-16">
<div className="mx-auto mt-14 mb-32 w-max rounded-xl border border-mineshaft-600 bg-mineshaft-800 px-8 py-10 drop-shadow-xl md:mb-16">
<p className="mx-8 mb-6 flex justify-center bg-linear-to-tr from-mineshaft-300 to-white bg-clip-text text-4xl font-bold text-transparent md:mx-16">
Almost there!
</p>

View File

@@ -1,3 +1,4 @@
import { useState } from "react";
import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -29,6 +30,8 @@ const formSchema = z.object({
type FormData = z.infer<typeof formSchema>;
export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => {
const [showPassword, setShowPassword] = useState(false);
const isUpdate = Boolean(auditLogStream);
const form = useForm<FormData>({
@@ -96,10 +99,10 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P
>
<Input
{...field}
type="password"
placeholder="Bearer <token>"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
onFocus={(e) => {
placeholder="Bearer <token>"
onFocus={() => {
if (
auditLogStream &&
auditLogStream.credentials.headers[i] &&
@@ -108,9 +111,9 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P
) {
field.onChange("");
}
e.target.type = "text";
setShowPassword(true);
}}
onBlur={(e) => {
onBlur={() => {
if (
auditLogStream &&
auditLogStream.credentials.headers[i] &&
@@ -119,7 +122,7 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P
) {
field.onChange("******");
}
e.target.type = "password";
setShowPassword(false);
}}
/>
</FormControl>

View File

@@ -34,10 +34,11 @@ const CreateForm = ({
}: CreateFormProps) => {
const createPamAccount = useCreatePamAccount();
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({
@@ -64,7 +65,13 @@ const CreateForm = ({
switch (resourceType) {
case PamResourceType.Postgres:
return <PostgresAccountForm onSubmit={onSubmit} />;
return (
<PostgresAccountForm
onSubmit={onSubmit}
resourceId={resourceId}
resourceType={resourceType}
/>
);
default:
throw new Error(`Unhandled resource: ${resourceType}`);
}
@@ -74,7 +81,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({

View File

@@ -1,26 +1,31 @@
import { useEffect, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
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 { SqlAccountFields } from "./shared/SqlAccountFields";
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountFields";
type Props = {
account?: TPostgresAccount;
resourceId?: string;
resourceType?: PamResourceType;
onSubmit: (formData: FormData) => Promise<void>;
};
const formSchema = genericAccountFieldsSchema.extend({
const formSchema = genericAccountFieldsSchema.extend(rotateAccountFieldsSchema.shape).extend({
credentials: BaseSqlAccountSchema
});
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 form = useForm<FormData>({
@@ -30,7 +35,7 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
...account,
credentials: {
...account.credentials,
password: "******"
password: UNCHANGED_PASSWORD_SENTINEL
}
}
: undefined
@@ -41,6 +46,20 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
formState: { isSubmitting, isDirty }
} = 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 (
<FormProvider {...form}>
<form
@@ -50,6 +69,7 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => {
>
<GenericAccountFields />
<SqlAccountFields isUpdate={isUpdate} />
<RotateAccountFields rotationCredentialsConfigured={rotationCredentialsConfigured} />
<div className="mt-6 flex items-center">
<Button
className="mr-4"

View File

@@ -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>
);
};

View File

@@ -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 { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
export const SqlAccountFields = ({ 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 (
<div className="flex gap-2">
@@ -17,7 +27,7 @@ export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
isError={Boolean(error?.message)}
label="Username"
>
<Input {...field} />
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
@@ -33,18 +43,19 @@ export const SqlAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
>
<Input
{...field}
type="password"
onFocus={(e) => {
if (isUpdate && field.value === "******") {
type={showPassword ? "text" : "password"}
autoComplete="new-password"
onFocus={() => {
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
field.onChange("");
}
e.target.type = "text";
setShowPassword(true);
}}
onBlur={(e) => {
onBlur={() => {
if (isUpdate && field.value === "") {
field.onChange("******");
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
}
e.target.type = "password";
setShowPassword(false);
}}
/>
</FormControl>

View File

@@ -5,6 +5,10 @@ export const BaseSqlAccountSchema = z.object({
.string()
.trim()
.min(1, "Username required")
.max(255, "Username must be 255 characters or less"),
password: z.string().trim().min(1, "Password required")
.max(63, "Username must be 63 characters or less"),
password: z
.string()
.trim()
.min(1, "Password required")
.max(256, "Password must be 256 characters or less")
});

View File

@@ -7,9 +7,11 @@ import {
faEllipsisV,
faFolder,
faRightToBracket,
faRotate,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatDistance } from "date-fns";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
@@ -100,6 +102,12 @@ export const PamAccountRow = ({
</span>
</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>
</Td>

View File

@@ -5,9 +5,12 @@ import { z } from "zod";
import { Button, ModalClose } from "@app/components/v2";
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 { SqlResourceFields } from "./shared/SqlResourceFields";
import { SqlRotateAccountFields } from "./shared/SqlRotateAccountFields";
import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields";
type Props = {
@@ -17,7 +20,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>;
@@ -28,17 +32,27 @@ export const PostgresResourceForm = ({ resource, onSubmit }: Props) => {
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: resource ?? {
resourceType: PamResourceType.Postgres,
connectionDetails: {
host: "",
port: 5432,
database: "default",
sslEnabled: true,
sslRejectUnauthorized: true,
sslCertificate: undefined
}
}
defaultValues: resource
? {
...resource,
rotationAccountCredentials: resource.rotationAccountCredentials
? {
...resource.rotationAccountCredentials,
password: UNCHANGED_PASSWORD_SENTINEL
}
: resource.rotationAccountCredentials
}
: {
resourceType: PamResourceType.Postgres,
connectionDetails: {
host: "",
port: 5432,
database: "default",
sslEnabled: true,
sslRejectUnauthorized: true,
sslCertificate: undefined
}
}
});
const {
@@ -59,6 +73,7 @@ export const PostgresResourceForm = ({ resource, onSubmit }: Props) => {
selectedTabIndex={selectedTabIndex}
setSelectedTabIndex={setSelectedTabIndex}
/>
<SqlRotateAccountFields isUpdate={isUpdate} />
<div className="mt-6 flex items-center">
<Button
className="mr-4"

View File

@@ -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>
);
};

View File

@@ -1109,7 +1109,6 @@ const Page = () => {
secretPath={secretPath}
isSmaller={isNotEmpty}
environments={currentProject?.environments}
isProtectedBranch={isProtectedBranch}
/>
<PitDrawer
secretSnaphots={snapshotList}

View File

@@ -10,7 +10,6 @@ import {
faUpload
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useQueryClient } from "@tanstack/react-query";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
@@ -34,21 +33,27 @@ import {
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp, useToggle } from "@app/hooks";
import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api";
import {
dashboardKeys,
fetchDashboardProjectSecretsByKeys
} from "@app/hooks/api/dashboard/queries";
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
import { secretKeys } from "@app/hooks/api/secrets/queries";
import { SecretType } from "@app/hooks/api/types";
import { PendingAction } from "@app/hooks/api/secretFolders/types";
import { fetchProjectSecrets, mergePersonalSecrets } from "@app/hooks/api/secrets/queries";
import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
import { PopUpNames, usePopUpAction } from "../../SecretMainPage.store";
import {
BatchContext,
PendingSecretCreate,
PendingSecretUpdate,
PopUpNames,
useBatchModeActions,
usePopUpAction
} from "../../SecretMainPage.store";
import { CopySecretsFromBoard } from "./CopySecretsFromBoard";
import { PasteSecretEnvModal } from "./PasteSecretEnvModal";
type TParsedEnv = Record<string, { value: string; comments: string[] }>;
type TSecOverwriteOpt = { update: TParsedEnv; create: TParsedEnv };
type TSecOverwriteOpt = {
update: TParsedEnv;
create: TParsedEnv;
existingSecrets: SecretV3RawSanitized[];
};
type Props = {
isSmaller: boolean;
@@ -56,7 +61,6 @@ type Props = {
projectId: string;
environment: string;
secretPath: string;
isProtectedBranch?: boolean;
};
type SecretMatrixMap = {
@@ -65,7 +69,7 @@ type SecretMatrixMap = {
comment: number | null;
};
const popupKeys = ["importSecEnv", "confirmUpload", "pasteSecEnv", "importMatrixMap"] as const;
const popupKeys = ["importSecEnv", "pasteSecEnv", "importMatrixMap"] as const;
const MatrixImportModalTableRow = ({
importSecretMatrixMap,
@@ -142,8 +146,7 @@ export const SecretDropzone = ({
environments = [],
projectId,
environment,
secretPath,
isProtectedBranch = false
secretPath
}: Props): JSX.Element => {
const { t } = useTranslation();
const [isDragActive, setDragActive] = useToggle();
@@ -157,18 +160,11 @@ export const SecretDropzone = ({
});
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp(popupKeys);
const queryClient = useQueryClient();
const { openPopUp } = usePopUpAction();
const { addPendingChange } = useBatchModeActions();
const { mutateAsync: updateSecretBatch, isPending: isUpdatingSecrets } = useUpdateSecretBatch({
options: { onSuccess: undefined }
});
const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({
options: { onSuccess: undefined }
});
// hide copy secrets from board due to import folders feature
const shouldRenderCopySecrets = false;
const isSubmitting = isCreatingSecrets || isUpdatingSecrets;
const handleDrag = (e: DragEvent) => {
e.preventDefault();
@@ -180,6 +176,81 @@ export const SecretDropzone = ({
}
};
const handleSaveSecrets = async (data: TSecOverwriteOpt) => {
const { update, create, existingSecrets } = data;
try {
const context: BatchContext = {
projectId,
environment,
secretPath
};
const existingSecretsMap = existingSecrets.reduce<Record<string, SecretV3RawSanitized>>(
(prev, curr) => ({ ...prev, [curr.key]: curr }),
{}
);
const totalCount = Object.keys(create || {}).length + Object.keys(update || {}).length;
if (Object.keys(create || {}).length) {
Object.entries(create).forEach(([secretKey, secData]) => {
const createChange: PendingSecretCreate = {
id: secretKey,
timestamp: Date.now(),
resourceType: "secret",
type: PendingAction.Create,
secretKey,
secretValue: secData.value,
secretComment: secData.comments.join("\n") || undefined,
tags: [],
secretMetadata: []
};
addPendingChange(createChange, context);
});
}
if (Object.keys(update || {}).length) {
Object.entries(update).forEach(([secretKey, secData]) => {
const existingSecret = existingSecretsMap[secretKey];
if (!existingSecret) {
console.warn(`Existing secret not found for key: ${secretKey}`);
return;
}
const updateChange: PendingSecretUpdate = {
id: existingSecret.id,
timestamp: Date.now(),
resourceType: "secret",
type: PendingAction.Update,
secretKey,
secretValue: secData.value,
secretComment: secData.comments.join("\n") || undefined,
existingSecret,
originalValue: existingSecret.value || "",
originalComment: existingSecret.comment || "",
originalSkipMultilineEncoding: existingSecret.skipMultilineEncoding || false,
originalTags: existingSecret.tags || [],
originalSecretMetadata: existingSecret.secretMetadata || []
};
addPendingChange(updateChange, context);
});
}
createNotification({
type: "success",
text: `Successfully imported ${totalCount} secret${totalCount > 1 ? "s" : ""}.`
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to import secrets"
});
}
};
const handleParsedEnv = async (env: TParsedEnv) => {
const envSecretKeys = Object.keys(env);
@@ -193,29 +264,38 @@ export const SecretDropzone = ({
try {
setIsLoading.on();
const { secrets: existingSecrets } = await fetchDashboardProjectSecretsByKeys({
secretPath,
environment,
const { secrets: rawExistingSecrets } = await fetchProjectSecrets({
projectId,
keys: envSecretKeys
environment,
secretPath,
viewSecretValue: true
});
const secretsGroupedByKey = existingSecrets.reduce<Record<string, boolean>>(
(prev, curr) => ({ ...prev, [curr.secretKey]: true }),
const allExistingSecrets = mergePersonalSecrets(rawExistingSecrets);
const existingSecretsMap = allExistingSecrets.reduce<Record<string, SecretV3RawSanitized>>(
(prev, curr) => ({ ...prev, [curr.key]: curr }),
{}
);
const updateSecrets = Object.keys(env)
.filter((secKey) => secretsGroupedByKey[secKey])
.reduce<TParsedEnv>((prev, curr) => ({ ...prev, [curr]: env[curr] }), {});
const updateSecrets: TParsedEnv = {};
const createSecrets: TParsedEnv = {};
const relevantExistingSecrets: SecretV3RawSanitized[] = [];
const createSecrets = Object.keys(env)
.filter((secKey) => !secretsGroupedByKey[secKey])
.reduce<TParsedEnv>((prev, curr) => ({ ...prev, [curr]: env[curr] }), {});
Object.entries(env).forEach(([secretKey, secretData]) => {
const existingSecret = existingSecretsMap[secretKey];
if (existingSecret) {
updateSecrets[secretKey] = secretData;
relevantExistingSecrets.push(existingSecret);
} else {
createSecrets[secretKey] = secretData;
}
});
handlePopUpOpen("confirmUpload", {
await handleSaveSecrets({
update: updateSecrets,
create: createSecrets
create: createSecrets,
existingSecrets: relevantExistingSecrets
});
} catch (e) {
console.error(e);
@@ -223,7 +303,6 @@ export const SecretDropzone = ({
text: "Failed to check for secret conflicts",
type: "error"
});
handlePopUpClose("confirmUpload");
} finally {
setIsLoading.off();
}
@@ -328,70 +407,6 @@ export const SecretDropzone = ({
parseFile(e.target?.files?.[0]);
};
const handleSaveSecrets = async () => {
const { update, create } = popUp?.confirmUpload?.data as TSecOverwriteOpt;
try {
if (Object.keys(create || {}).length) {
await createSecretBatch({
secretPath,
projectId,
environment,
secrets: Object.entries(create).map(([secretKey, secData]) => ({
type: SecretType.Shared,
secretComment: secData.comments.join("\n"),
secretValue: secData.value,
secretKey
}))
});
}
if (Object.keys(update || {}).length) {
await updateSecretBatch({
secretPath,
projectId,
environment,
secrets: Object.entries(update).map(([secretKey, secData]) => ({
type: SecretType.Shared,
secretComment: secData.comments.join("\n"),
secretValue: secData.value,
secretKey
}))
});
}
queryClient.invalidateQueries({
queryKey: secretKeys.getProjectSecret({ projectId, environment, secretPath })
});
queryClient.invalidateQueries({
queryKey: dashboardKeys.getDashboardSecrets({ projectId, secretPath })
});
queryClient.invalidateQueries({
queryKey: secretApprovalRequestKeys.count({ projectId })
});
handlePopUpClose("confirmUpload");
createNotification({
type: "success",
text: isProtectedBranch
? "Uploaded changes have been sent for review"
: "Successfully uploaded secrets"
});
} catch (err) {
console.log(err);
createNotification({
type: "error",
text: "Failed to upload secrets"
});
}
};
const createSecretCount = Object.keys(
(popUp.confirmUpload?.data as TSecOverwriteOpt)?.create || {}
).length;
const updateSecretCount = Object.keys(
(popUp.confirmUpload?.data as TSecOverwriteOpt)?.update || {}
).length;
const isNonConflictingUpload = !updateSecretCount;
return (
<div>
<div
@@ -495,58 +510,6 @@ export const SecretDropzone = ({
</div>
)}
</div>
<Modal
isOpen={popUp?.confirmUpload?.isOpen}
onOpenChange={(open) => handlePopUpToggle("confirmUpload", open)}
>
<ModalContent
title="Confirm Secret Upload"
footerContent={[
<Button
isLoading={isSubmitting}
isDisabled={isSubmitting}
colorSchema={isNonConflictingUpload ? "primary" : "danger"}
key="overwrite-btn"
onClick={handleSaveSecrets}
>
{isNonConflictingUpload ? "Upload" : "Overwrite"}
</Button>,
<Button
key="keep-old-btn"
className="ml-4"
onClick={() => handlePopUpClose("confirmUpload")}
variant="outline_bg"
isDisabled={isSubmitting}
>
Cancel
</Button>
]}
>
{isNonConflictingUpload ? (
<div>
Are you sure you want to import {createSecretCount} secret
{createSecretCount > 1 ? "s" : ""} to this environment?
</div>
) : (
<div className="flex flex-col text-gray-300">
<div>Your project already contains the following {updateSecretCount} secrets:</div>
<div className="mt-2 text-sm text-gray-400">
{Object.keys((popUp?.confirmUpload?.data as TSecOverwriteOpt)?.update || {})
?.map((key) => key)
.join(", ")}
</div>
<div className="mt-6">
Are you sure you want to overwrite these secrets
{createSecretCount > 0
? ` and import ${createSecretCount} new
one${createSecretCount > 1 ? "s" : ""}`
: ""}
?
</div>
</div>
)}
</ModalContent>
</Modal>
{/* Matrix Import Modal */}
<Modal

View File

@@ -130,7 +130,8 @@ export const SecretImportListView = ({
const [items, setItems] = useState(secretImports ?? []);
const getImportReplicatedFolder = (importPath: string) => {
const cleanImportPath = importPath.replace("/__reserve_replication_", "");
if (!importPath.includes("/__reserve_replication_")) return undefined;
const cleanImportPath = importPath.split("/__reserve_replication_")[1];
const replicatedFolder = items?.find(({ id }) => id === cleanImportPath);
return replicatedFolder;
};