Compare commits
44 Commits
fix/postgr
...
feat/secre
Author | SHA1 | Date | |
---|---|---|---|
80743997e1 | |||
f025509938 | |||
be04d3cf3a | |||
8f77a3ae0b | |||
a064fae94e | |||
cf6b9d8905 | |||
e4a28ab0f4 | |||
a3b0d86996 | |||
bc98c42c79 | |||
1c20e4fef0 | |||
b560cdb0f8 | |||
144143b43a | |||
b9a05688cd | |||
c06c6c6c61 | |||
350afee45e | |||
5ae18a691d | |||
8187b1da91 | |||
0174d36136 | |||
fd761df8e5 | |||
61ca617616 | |||
6ce6c276cd | |||
32b2f7b0fe | |||
4c2823c480 | |||
60438694e4 | |||
fdaf8f9a87 | |||
3fe41f81fe | |||
c1798d37be | |||
01c6d3192d | |||
621bfe3e60 | |||
67ec00d46b | |||
d6c2789d46 | |||
58ba0c8ed4 | |||
f38c574030 | |||
c330d8ca8a | |||
2cb0ecc768 | |||
ecc15bb432 | |||
59c0f1ff08 | |||
5110d59bea | |||
0e07ebae7b | |||
a94a26263a | |||
08dfaaa8b0 | |||
6bb634f5ed | |||
706447d5c6 | |||
246fe81134 |
@ -3,11 +3,14 @@ import { z } from "zod";
|
||||
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { removeTrailingSlash } from "@app/lib/fn";
|
||||
import { readLimit } from "@app/server/config/rateLimiter";
|
||||
import { isValidFolderName } from "@app/lib/validator";
|
||||
import { readLimit, secretsLimit } from "@app/server/config/rateLimiter";
|
||||
import { SecretNameSchema } from "@app/server/lib/schemas";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { booleanSchema } from "@app/server/routes/sanitizedSchemas";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { commitChangesResponseSchema, resourceChangeSchema } from "@app/services/folder-commit/folder-commit-schemas";
|
||||
import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema";
|
||||
|
||||
const commitHistoryItemSchema = z.object({
|
||||
id: z.string(),
|
||||
@ -413,4 +416,166 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/batch/commit",
|
||||
config: {
|
||||
rateLimit: secretsLimit
|
||||
},
|
||||
schema: {
|
||||
hide: true,
|
||||
description: "Commit changes",
|
||||
security: [
|
||||
{
|
||||
bearerAuth: []
|
||||
}
|
||||
],
|
||||
body: z.object({
|
||||
projectId: z.string().trim(),
|
||||
environment: z.string().trim(),
|
||||
secretPath: z.string().trim().default("/").transform(removeTrailingSlash),
|
||||
message: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.refine((message) => message.trim() !== "", {
|
||||
message: "Commit message cannot be empty"
|
||||
}),
|
||||
changes: z.object({
|
||||
secrets: z.object({
|
||||
create: z
|
||||
.array(
|
||||
z.object({
|
||||
secretKey: SecretNameSchema,
|
||||
secretValue: z.string().transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())),
|
||||
secretComment: z.string().trim().optional().default(""),
|
||||
skipMultilineEncoding: z.boolean().optional(),
|
||||
metadata: z.record(z.string()).optional(),
|
||||
secretMetadata: ResourceMetadataSchema.optional(),
|
||||
tagIds: z.string().array().optional()
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
update: z
|
||||
.array(
|
||||
z.object({
|
||||
secretKey: SecretNameSchema,
|
||||
newSecretName: SecretNameSchema.optional(),
|
||||
secretValue: z
|
||||
.string()
|
||||
.transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim()))
|
||||
.optional(),
|
||||
secretComment: z.string().trim().optional().default(""),
|
||||
skipMultilineEncoding: z.boolean().optional(),
|
||||
metadata: z.record(z.string()).optional(),
|
||||
secretMetadata: ResourceMetadataSchema.optional(),
|
||||
tagIds: z.string().array().optional()
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
delete: z
|
||||
.array(
|
||||
z.object({
|
||||
secretKey: SecretNameSchema
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
}),
|
||||
folders: z.object({
|
||||
create: z
|
||||
.array(
|
||||
z.object({
|
||||
folderName: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((name) => isValidFolderName(name), {
|
||||
message: "Invalid folder name. Only alphanumeric characters, dashes, and underscores are allowed."
|
||||
}),
|
||||
description: z.string().optional()
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
update: z
|
||||
.array(
|
||||
z.object({
|
||||
folderName: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((name) => isValidFolderName(name), {
|
||||
message: "Invalid folder name. Only alphanumeric characters, dashes, and underscores are allowed."
|
||||
}),
|
||||
description: z.string().nullable().optional(),
|
||||
id: z.string()
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
delete: z
|
||||
.array(
|
||||
z.object({
|
||||
folderName: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((name) => isValidFolderName(name), {
|
||||
message: "Invalid folder name. Only alphanumeric characters, dashes, and underscores are allowed."
|
||||
}),
|
||||
id: z.string()
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
})
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
message: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const result = await server.services.pit.processNewCommitRaw({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
actorOrgId: req.permission.orgId,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
projectId: req.body.projectId,
|
||||
environment: req.body.environment,
|
||||
secretPath: req.body.secretPath,
|
||||
message: req.body.message,
|
||||
changes: {
|
||||
secrets: req.body.changes.secrets,
|
||||
folders: req.body.changes.folders
|
||||
}
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: req.body.projectId,
|
||||
event: {
|
||||
type: EventType.PIT_PROCESS_NEW_COMMIT_RAW,
|
||||
metadata: {
|
||||
commitId: result.commitId,
|
||||
approvalId: result.approvalId,
|
||||
projectId: req.body.projectId,
|
||||
environment: req.body.environment,
|
||||
secretPath: req.body.secretPath,
|
||||
message: req.body.message
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for await (const event of result.secretMutationEvents) {
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
orgId: req.permission.orgId,
|
||||
projectId: req.body.projectId,
|
||||
event
|
||||
});
|
||||
}
|
||||
|
||||
return { message: "success" };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -449,6 +449,7 @@ export enum EventType {
|
||||
PIT_REVERT_COMMIT = "pit-revert-commit",
|
||||
PIT_GET_FOLDER_STATE = "pit-get-folder-state",
|
||||
PIT_COMPARE_FOLDER_STATES = "pit-compare-folder-states",
|
||||
PIT_PROCESS_NEW_COMMIT_RAW = "pit-process-new-commit-raw",
|
||||
SECRET_SCANNING_DATA_SOURCE_LIST = "secret-scanning-data-source-list",
|
||||
SECRET_SCANNING_DATA_SOURCE_CREATE = "secret-scanning-data-source-create",
|
||||
SECRET_SCANNING_DATA_SOURCE_UPDATE = "secret-scanning-data-source-update",
|
||||
@ -1546,8 +1547,9 @@ interface UpdateFolderEvent {
|
||||
metadata: {
|
||||
environment: string;
|
||||
folderId: string;
|
||||
oldFolderName: string;
|
||||
oldFolderName?: string;
|
||||
newFolderName: string;
|
||||
newFolderDescription?: string;
|
||||
folderPath: string;
|
||||
};
|
||||
}
|
||||
@ -3222,6 +3224,18 @@ interface PitCompareFolderStatesEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface PitProcessNewCommitRawEvent {
|
||||
type: EventType.PIT_PROCESS_NEW_COMMIT_RAW;
|
||||
metadata: {
|
||||
projectId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
message: string;
|
||||
approvalId?: string;
|
||||
commitId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SecretScanningDataSourceListEvent {
|
||||
type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST;
|
||||
metadata: {
|
||||
@ -3658,6 +3672,7 @@ export type Event =
|
||||
| PitRevertCommitEvent
|
||||
| PitCompareFolderStatesEvent
|
||||
| PitGetFolderStateEvent
|
||||
| PitProcessNewCommitRawEvent
|
||||
| SecretScanningDataSourceListEvent
|
||||
| SecretScanningDataSourceGetEvent
|
||||
| SecretScanningDataSourceCreateEvent
|
||||
|
@ -1,29 +1,52 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { Event, EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { ProjectPermissionCommitsActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { NotFoundError } from "@app/lib/errors";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
|
||||
import { ResourceType, TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service";
|
||||
import { TFolderCommitDALFactory } from "@app/services/folder-commit/folder-commit-dal";
|
||||
import {
|
||||
ResourceType,
|
||||
TCommitResourceChangeDTO,
|
||||
TFolderCommitServiceFactory
|
||||
} from "@app/services/folder-commit/folder-commit-service";
|
||||
import {
|
||||
isFolderCommitChange,
|
||||
isSecretCommitChange
|
||||
} from "@app/services/folder-commit-changes/folder-commit-changes-dal";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
|
||||
import { TSecretServiceFactory } from "@app/services/secret/secret-service";
|
||||
import { TProcessNewCommitRawDTO } from "@app/services/secret/secret-types";
|
||||
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||
import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service";
|
||||
import { TSecretV2BridgeServiceFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-service";
|
||||
import { SecretOperations, SecretUpdateMode } from "@app/services/secret-v2-bridge/secret-v2-bridge-types";
|
||||
|
||||
import { TPermissionServiceFactory } from "../permission/permission-service-types";
|
||||
import { TSecretApprovalPolicyServiceFactory } from "../secret-approval-policy/secret-approval-policy-service";
|
||||
import { TSecretApprovalRequestServiceFactory } from "../secret-approval-request/secret-approval-request-service";
|
||||
|
||||
type TPitServiceFactoryDep = {
|
||||
folderCommitService: TFolderCommitServiceFactory;
|
||||
secretService: Pick<TSecretServiceFactory, "getSecretVersionsV2ByIds" | "getChangeVersions">;
|
||||
folderService: Pick<TSecretFolderServiceFactory, "getFolderById" | "getFolderVersions">;
|
||||
folderService: Pick<
|
||||
TSecretFolderServiceFactory,
|
||||
"getFolderById" | "getFolderVersions" | "createManyFolders" | "updateManyFolders" | "deleteManyFolders"
|
||||
>;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
folderDAL: Pick<TSecretFolderDALFactory, "findSecretPathByFolderIds">;
|
||||
folderDAL: Pick<TSecretFolderDALFactory, "findSecretPathByFolderIds" | "findBySecretPath">;
|
||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
|
||||
secretApprovalRequestService: Pick<
|
||||
TSecretApprovalRequestServiceFactory,
|
||||
"generateSecretApprovalRequest" | "generateSecretApprovalRequestV2Bridge"
|
||||
>;
|
||||
secretApprovalPolicyService: Pick<TSecretApprovalPolicyServiceFactory, "getSecretApprovalPolicy">;
|
||||
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus" | "findProjectBySlug" | "findById">;
|
||||
secretV2BridgeService: TSecretV2BridgeServiceFactory;
|
||||
folderCommitDAL: Pick<TFolderCommitDALFactory, "transaction">;
|
||||
};
|
||||
|
||||
export type TPitServiceFactory = ReturnType<typeof pitServiceFactory>;
|
||||
@ -34,7 +57,12 @@ export const pitServiceFactory = ({
|
||||
folderService,
|
||||
permissionService,
|
||||
folderDAL,
|
||||
projectEnvDAL
|
||||
projectEnvDAL,
|
||||
secretApprovalRequestService,
|
||||
secretApprovalPolicyService,
|
||||
projectDAL,
|
||||
secretV2BridgeService,
|
||||
folderCommitDAL
|
||||
}: TPitServiceFactoryDep) => {
|
||||
const getCommitsCount = async ({
|
||||
actor,
|
||||
@ -471,6 +499,347 @@ export const pitServiceFactory = ({
|
||||
});
|
||||
};
|
||||
|
||||
const processNewCommitRaw = async ({
|
||||
actorId,
|
||||
projectId,
|
||||
environment,
|
||||
actor,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
secretPath,
|
||||
message,
|
||||
changes = {
|
||||
secrets: {
|
||||
create: [],
|
||||
update: [],
|
||||
delete: []
|
||||
},
|
||||
folders: {
|
||||
create: [],
|
||||
update: [],
|
||||
delete: []
|
||||
}
|
||||
}
|
||||
}: {
|
||||
actorId: string;
|
||||
projectId: string;
|
||||
environment: string;
|
||||
actor: ActorType;
|
||||
actorOrgId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
secretPath: string;
|
||||
message: string;
|
||||
changes: TProcessNewCommitRawDTO;
|
||||
}) => {
|
||||
const policy =
|
||||
actor === ActorType.USER
|
||||
? await secretApprovalPolicyService.getSecretApprovalPolicy(projectId, environment, secretPath)
|
||||
: undefined;
|
||||
const secretMutationEvents: Event[] = [];
|
||||
|
||||
const project = await projectDAL.findById(projectId);
|
||||
if (project.enforceCapitalization) {
|
||||
const caseViolatingSecretKeys = [
|
||||
// Check create operations
|
||||
...(changes.secrets?.create
|
||||
?.filter((sec) => sec.secretKey !== sec.secretKey.toUpperCase())
|
||||
.map((sec) => sec.secretKey) ?? []),
|
||||
|
||||
// Check update operations
|
||||
...(changes.secrets?.update
|
||||
?.filter((sec) => sec.newSecretName && sec.newSecretName !== sec.newSecretName.toUpperCase())
|
||||
.map((sec) => sec.secretKey) ?? [])
|
||||
];
|
||||
|
||||
if (caseViolatingSecretKeys.length) {
|
||||
throw new BadRequestError({
|
||||
message: `Secret names must be in UPPERCASE per project requirements: ${caseViolatingSecretKeys.join(
|
||||
", "
|
||||
)}. You can disable this requirement in project settings`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const response = await folderCommitDAL.transaction(async (trx) => {
|
||||
const targetFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath, trx);
|
||||
if (!targetFolder)
|
||||
throw new NotFoundError({
|
||||
message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found`,
|
||||
name: "CreateManySecret"
|
||||
});
|
||||
const commitChanges: TCommitResourceChangeDTO[] = [];
|
||||
const folderChanges: { create: string[]; update: string[]; delete: string[] } = {
|
||||
create: [],
|
||||
update: [],
|
||||
delete: []
|
||||
};
|
||||
|
||||
if ((changes.folders?.create?.length ?? 0) > 0) {
|
||||
const createdFolders = await folderService.createManyFolders({
|
||||
projectId,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
folders:
|
||||
changes.folders?.create?.map((folder) => ({
|
||||
name: folder.folderName,
|
||||
environment,
|
||||
path: secretPath,
|
||||
description: folder.description
|
||||
})) ?? [],
|
||||
tx: trx,
|
||||
commitChanges
|
||||
});
|
||||
const newFolderEvents = createdFolders.folders.map(
|
||||
(folder) =>
|
||||
({
|
||||
type: EventType.CREATE_FOLDER,
|
||||
metadata: {
|
||||
environment,
|
||||
folderId: folder.id,
|
||||
folderName: folder.name,
|
||||
folderPath: secretPath,
|
||||
...(folder.description ? { description: folder.description } : {})
|
||||
}
|
||||
}) as Event
|
||||
);
|
||||
secretMutationEvents.push(...newFolderEvents);
|
||||
folderChanges.create.push(...createdFolders.folders.map((folder) => folder.id));
|
||||
}
|
||||
|
||||
if ((changes.folders?.update?.length ?? 0) > 0) {
|
||||
const updatedFolders = await folderService.updateManyFolders({
|
||||
projectId,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
folders:
|
||||
changes.folders?.update?.map((folder) => ({
|
||||
environment,
|
||||
path: secretPath,
|
||||
id: folder.id,
|
||||
name: folder.folderName,
|
||||
description: folder.description
|
||||
})) ?? [],
|
||||
tx: trx,
|
||||
commitChanges
|
||||
});
|
||||
|
||||
const updatedFolderEvents = updatedFolders.newFolders.map(
|
||||
(folder) =>
|
||||
({
|
||||
type: EventType.UPDATE_FOLDER,
|
||||
metadata: {
|
||||
environment,
|
||||
folderId: folder.id,
|
||||
folderPath: secretPath,
|
||||
newFolderName: folder.name,
|
||||
newFolderDescription: folder.description
|
||||
}
|
||||
}) as Event
|
||||
);
|
||||
secretMutationEvents.push(...updatedFolderEvents);
|
||||
folderChanges.update.push(...updatedFolders.newFolders.map((folder) => folder.id));
|
||||
}
|
||||
|
||||
if ((changes.folders?.delete?.length ?? 0) > 0) {
|
||||
const deletedFolders = await folderService.deleteManyFolders({
|
||||
projectId,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
folders:
|
||||
changes.folders?.delete?.map((folder) => ({
|
||||
environment,
|
||||
path: secretPath,
|
||||
idOrName: folder.id
|
||||
})) ?? [],
|
||||
tx: trx,
|
||||
commitChanges
|
||||
});
|
||||
const deletedFolderEvents = deletedFolders.folders.map(
|
||||
(folder) =>
|
||||
({
|
||||
type: EventType.DELETE_FOLDER,
|
||||
metadata: {
|
||||
environment,
|
||||
folderId: folder.id,
|
||||
folderPath: secretPath,
|
||||
folderName: folder.name
|
||||
}
|
||||
}) as Event
|
||||
);
|
||||
secretMutationEvents.push(...deletedFolderEvents);
|
||||
folderChanges.delete.push(...deletedFolders.folders.map((folder) => folder.id));
|
||||
}
|
||||
|
||||
if (policy) {
|
||||
if (
|
||||
(changes.secrets?.create?.length ?? 0) > 0 ||
|
||||
(changes.secrets?.update?.length ?? 0) > 0 ||
|
||||
(changes.secrets?.delete?.length ?? 0) > 0
|
||||
) {
|
||||
const approval = await secretApprovalRequestService.generateSecretApprovalRequestV2Bridge({
|
||||
policy,
|
||||
secretPath,
|
||||
environment,
|
||||
projectId,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
data: {
|
||||
[SecretOperations.Create]:
|
||||
changes.secrets?.create?.map((el) => ({
|
||||
tagIds: el.tagIds,
|
||||
secretValue: el.secretValue,
|
||||
secretComment: el.secretComment,
|
||||
metadata: el.metadata,
|
||||
skipMultilineEncoding: el.skipMultilineEncoding,
|
||||
secretKey: el.secretKey,
|
||||
secretMetadata: el.secretMetadata
|
||||
})) ?? [],
|
||||
[SecretOperations.Update]:
|
||||
changes.secrets?.update?.map((el) => ({
|
||||
tagIds: el.tagIds,
|
||||
newSecretName: el.newSecretName,
|
||||
secretValue: el.secretValue,
|
||||
secretComment: el.secretComment,
|
||||
metadata: el.metadata,
|
||||
skipMultilineEncoding: el.skipMultilineEncoding,
|
||||
secretKey: el.secretKey,
|
||||
secretMetadata: el.secretMetadata
|
||||
})) ?? [],
|
||||
[SecretOperations.Delete]:
|
||||
changes.secrets?.delete?.map((el) => ({
|
||||
secretKey: el.secretKey
|
||||
})) ?? []
|
||||
}
|
||||
});
|
||||
return {
|
||||
approvalId: approval.id,
|
||||
folderChanges,
|
||||
secretMutationEvents
|
||||
};
|
||||
}
|
||||
return {
|
||||
folderChanges,
|
||||
secretMutationEvents
|
||||
};
|
||||
}
|
||||
|
||||
if ((changes.secrets?.create?.length ?? 0) > 0) {
|
||||
const newSecrets = await secretV2BridgeService.createManySecret({
|
||||
secretPath,
|
||||
environment,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actor,
|
||||
actorId,
|
||||
secrets: changes.secrets?.create ?? [],
|
||||
tx: trx,
|
||||
commitChanges
|
||||
});
|
||||
secretMutationEvents.push({
|
||||
type: EventType.CREATE_SECRETS,
|
||||
metadata: {
|
||||
environment,
|
||||
secretPath,
|
||||
secrets: newSecrets.map((secret) => ({
|
||||
secretId: secret.id,
|
||||
secretKey: secret.secretKey,
|
||||
secretVersion: secret.version
|
||||
}))
|
||||
}
|
||||
});
|
||||
}
|
||||
if ((changes.secrets?.update?.length ?? 0) > 0) {
|
||||
const updatedSecrets = await secretV2BridgeService.updateManySecret({
|
||||
secretPath,
|
||||
environment,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actor,
|
||||
actorId,
|
||||
secrets: changes.secrets?.update ?? [],
|
||||
mode: SecretUpdateMode.FailOnNotFound,
|
||||
tx: trx,
|
||||
commitChanges
|
||||
});
|
||||
secretMutationEvents.push({
|
||||
type: EventType.UPDATE_SECRETS,
|
||||
metadata: {
|
||||
environment,
|
||||
secretPath,
|
||||
secrets: updatedSecrets.map((secret) => ({
|
||||
secretId: secret.id,
|
||||
secretKey: secret.secretKey,
|
||||
secretVersion: secret.version
|
||||
}))
|
||||
}
|
||||
});
|
||||
}
|
||||
if ((changes.secrets?.delete?.length ?? 0) > 0) {
|
||||
const deletedSecrets = await secretV2BridgeService.deleteManySecret({
|
||||
secretPath,
|
||||
environment,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actor,
|
||||
actorId,
|
||||
secrets: changes.secrets?.delete ?? [],
|
||||
tx: trx,
|
||||
commitChanges
|
||||
});
|
||||
secretMutationEvents.push({
|
||||
type: EventType.DELETE_SECRETS,
|
||||
metadata: {
|
||||
environment,
|
||||
secretPath,
|
||||
secrets: deletedSecrets.map((secret) => ({
|
||||
secretId: secret.id,
|
||||
secretKey: secret.secretKey,
|
||||
secretVersion: secret.version
|
||||
}))
|
||||
}
|
||||
});
|
||||
}
|
||||
if (commitChanges?.length > 0) {
|
||||
const commit = await folderCommitService.createCommit(
|
||||
{
|
||||
actor: {
|
||||
type: actor || ActorType.PLATFORM,
|
||||
metadata: {
|
||||
id: actorId
|
||||
}
|
||||
},
|
||||
message,
|
||||
folderId: targetFolder.id,
|
||||
changes: commitChanges
|
||||
},
|
||||
trx
|
||||
);
|
||||
return {
|
||||
folderChanges,
|
||||
commitId: commit?.id,
|
||||
secretMutationEvents
|
||||
};
|
||||
}
|
||||
return {
|
||||
folderChanges,
|
||||
secretMutationEvents
|
||||
};
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
return {
|
||||
getCommitsCount,
|
||||
getCommitsForFolder,
|
||||
@ -478,6 +847,7 @@ export const pitServiceFactory = ({
|
||||
compareCommitChanges,
|
||||
rollbackToCommit,
|
||||
revertCommit,
|
||||
getFolderStateAtCommit
|
||||
getFolderStateAtCommit,
|
||||
processNewCommitRaw
|
||||
};
|
||||
};
|
||||
|
@ -410,7 +410,7 @@ export const samlConfigServiceFactory = ({
|
||||
}
|
||||
await licenseService.updateSubscriptionOrgMemberCount(organization.id);
|
||||
|
||||
const isUserCompleted = Boolean(user.isAccepted);
|
||||
const isUserCompleted = Boolean(user.isAccepted && user.isEmailVerified);
|
||||
const userEnc = await userDAL.findUserEncKeyByUserId(user.id);
|
||||
const providerAuthToken = crypto.jwt().sign(
|
||||
{
|
||||
|
@ -1,5 +1,6 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
import { Knex } from "knex";
|
||||
|
||||
import {
|
||||
ProjectMembershipRole,
|
||||
@ -1368,8 +1369,9 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
policy,
|
||||
projectId,
|
||||
secretPath,
|
||||
environment
|
||||
}: TGenerateSecretApprovalRequestV2BridgeDTO) => {
|
||||
environment,
|
||||
trx: providedTx
|
||||
}: TGenerateSecretApprovalRequestV2BridgeDTO & { trx?: Knex }) => {
|
||||
if (actor === ActorType.SERVICE || actor === ActorType.Machine)
|
||||
throw new BadRequestError({ message: "Cannot use service token or machine token over protected branches" });
|
||||
|
||||
@ -1595,7 +1597,7 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
);
|
||||
});
|
||||
|
||||
const secretApprovalRequest = await secretApprovalRequestDAL.transaction(async (tx) => {
|
||||
const executeApprovalRequestCreation = async (tx: Knex) => {
|
||||
const doc = await secretApprovalRequestDAL.create(
|
||||
{
|
||||
folderId,
|
||||
@ -1657,7 +1659,11 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
}
|
||||
|
||||
return { ...doc, commits: approvalCommits };
|
||||
});
|
||||
};
|
||||
|
||||
const secretApprovalRequest = providedTx
|
||||
? await executeApprovalRequestCreation(providedTx)
|
||||
: await secretApprovalRequestDAL.transaction(executeApprovalRequestCreation);
|
||||
|
||||
const user = await userDAL.findById(actorId);
|
||||
const env = await projectEnvDAL.findOne({ id: policy.envId });
|
||||
|
@ -2285,6 +2285,10 @@ export const AppConnections = {
|
||||
},
|
||||
CHECKLY: {
|
||||
apiKey: "The API key used to authenticate with Checkly."
|
||||
},
|
||||
SUPABASE: {
|
||||
accessKey: "The Key used to access Supabase.",
|
||||
instanceUrl: "The URL used to access Supabase."
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -2494,6 +2498,10 @@ export const SecretSyncs = {
|
||||
},
|
||||
CHECKLY: {
|
||||
accountId: "The ID of the Checkly account to sync secrets to."
|
||||
},
|
||||
SUPABASE: {
|
||||
projectId: "The ID of the Supabase project to sync secrets to.",
|
||||
projectName: "The name of the Supabase project to sync secrets to."
|
||||
}
|
||||
}
|
||||
};
|
||||
|
43
backend/src/server/lib/cookie.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { FastifyReply } from "fastify";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
/**
|
||||
* `aod` (Auth Origin Domain) cookie is used to store the origin domain of the application when user was last authenticated.
|
||||
* This is useful for determining the target domain for authentication redirects, especially in cloud deployments.
|
||||
* It is set only in cloud mode to ensure that the cookie is shared across subdomains.
|
||||
*/
|
||||
export function addAuthOriginDomainCookie(res: FastifyReply) {
|
||||
try {
|
||||
const appCfg = getConfig();
|
||||
|
||||
// Only set the cookie if the app is running in cloud mode
|
||||
if (!appCfg.isCloud) return;
|
||||
|
||||
const siteUrl = appCfg.SITE_URL!;
|
||||
let domain: string;
|
||||
|
||||
const { hostname } = new URL(siteUrl);
|
||||
|
||||
const parts = hostname.split(".");
|
||||
|
||||
if (parts.length >= 2) {
|
||||
// For `app.infisical.com` => `.infisical.com`
|
||||
domain = `.${parts.slice(-2).join(".")}`;
|
||||
} else {
|
||||
// If somehow only "example", fallback to itself
|
||||
domain = `.${hostname}`;
|
||||
}
|
||||
|
||||
void res.setCookie("aod", siteUrl, {
|
||||
domain,
|
||||
path: "/",
|
||||
sameSite: "strict",
|
||||
httpOnly: false,
|
||||
secure: appCfg.HTTPS_ENABLED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to set auth origin domain cookie");
|
||||
}
|
||||
}
|
@ -1538,7 +1538,12 @@ export const registerRoutes = async (
|
||||
folderService,
|
||||
permissionService,
|
||||
folderDAL,
|
||||
projectEnvDAL
|
||||
projectEnvDAL,
|
||||
secretApprovalRequestService,
|
||||
secretApprovalPolicyService,
|
||||
projectDAL,
|
||||
secretV2BridgeService,
|
||||
folderCommitDAL
|
||||
});
|
||||
|
||||
const identityOidcAuthService = identityOidcAuthServiceFactory({
|
||||
|
@ -12,6 +12,7 @@ import { getConfig, overridableKeys } from "@app/lib/config/env";
|
||||
import { crypto } from "@app/lib/crypto/cryptography";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { addAuthOriginDomainCookie } from "@app/server/lib/cookie";
|
||||
import { getTelemetryDistinctId } from "@app/server/lib/telemetry";
|
||||
import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
@ -593,6 +594,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
|
||||
secure: appCfg.HTTPS_ENABLED
|
||||
});
|
||||
|
||||
addAuthOriginDomainCookie(res);
|
||||
|
||||
return {
|
||||
message: "Successfully set up admin account",
|
||||
user: user.user,
|
||||
|
@ -83,6 +83,10 @@ import {
|
||||
RenderConnectionListItemSchema,
|
||||
SanitizedRenderConnectionSchema
|
||||
} from "@app/services/app-connection/render/render-connection-schema";
|
||||
import {
|
||||
SanitizedSupabaseConnectionSchema,
|
||||
SupabaseConnectionListItemSchema
|
||||
} from "@app/services/app-connection/supabase";
|
||||
import {
|
||||
SanitizedTeamCityConnectionSchema,
|
||||
TeamCityConnectionListItemSchema
|
||||
@ -133,7 +137,8 @@ const SanitizedAppConnectionSchema = z.union([
|
||||
...SanitizedBitbucketConnectionSchema.options,
|
||||
...SanitizedZabbixConnectionSchema.options,
|
||||
...SanitizedRailwayConnectionSchema.options,
|
||||
...SanitizedChecklyConnectionSchema.options
|
||||
...SanitizedChecklyConnectionSchema.options,
|
||||
...SanitizedSupabaseConnectionSchema.options
|
||||
]);
|
||||
|
||||
const AppConnectionOptionsSchema = z.discriminatedUnion("app", [
|
||||
@ -169,7 +174,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [
|
||||
BitbucketConnectionListItemSchema,
|
||||
ZabbixConnectionListItemSchema,
|
||||
RailwayConnectionListItemSchema,
|
||||
ChecklyConnectionListItemSchema
|
||||
ChecklyConnectionListItemSchema,
|
||||
SupabaseConnectionListItemSchema
|
||||
]);
|
||||
|
||||
export const registerAppConnectionRouter = async (server: FastifyZodProvider) => {
|
||||
|
@ -28,6 +28,7 @@ import { registerMySqlConnectionRouter } from "./mysql-connection-router";
|
||||
import { registerPostgresConnectionRouter } from "./postgres-connection-router";
|
||||
import { registerRailwayConnectionRouter } from "./railway-connection-router";
|
||||
import { registerRenderConnectionRouter } from "./render-connection-router";
|
||||
import { registerSupabaseConnectionRouter } from "./supabase-connection-router";
|
||||
import { registerTeamCityConnectionRouter } from "./teamcity-connection-router";
|
||||
import { registerTerraformCloudConnectionRouter } from "./terraform-cloud-router";
|
||||
import { registerVercelConnectionRouter } from "./vercel-connection-router";
|
||||
@ -70,5 +71,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record<AppConnection, (server:
|
||||
[AppConnection.Bitbucket]: registerBitbucketConnectionRouter,
|
||||
[AppConnection.Zabbix]: registerZabbixConnectionRouter,
|
||||
[AppConnection.Railway]: registerRailwayConnectionRouter,
|
||||
[AppConnection.Checkly]: registerChecklyConnectionRouter
|
||||
[AppConnection.Checkly]: registerChecklyConnectionRouter,
|
||||
[AppConnection.Supabase]: registerSupabaseConnectionRouter
|
||||
};
|
||||
|
@ -0,0 +1,55 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { readLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import {
|
||||
CreateSupabaseConnectionSchema,
|
||||
SanitizedSupabaseConnectionSchema,
|
||||
UpdateSupabaseConnectionSchema
|
||||
} from "@app/services/app-connection/supabase";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
|
||||
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
|
||||
|
||||
export const registerSupabaseConnectionRouter = async (server: FastifyZodProvider) => {
|
||||
registerAppConnectionEndpoints({
|
||||
app: AppConnection.Supabase,
|
||||
server,
|
||||
sanitizedResponseSchema: SanitizedSupabaseConnectionSchema,
|
||||
createSchema: CreateSupabaseConnectionSchema,
|
||||
updateSchema: UpdateSupabaseConnectionSchema
|
||||
});
|
||||
|
||||
// The below endpoints are not exposed and for Infisical App use
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: `/:connectionId/projects`,
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
connectionId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
projects: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
id: z.string()
|
||||
})
|
||||
.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT]),
|
||||
handler: async (req) => {
|
||||
const { connectionId } = req.params;
|
||||
|
||||
const projects = await server.services.appConnection.supabase.listProjects(connectionId, req.permission);
|
||||
|
||||
return { projects };
|
||||
}
|
||||
});
|
||||
};
|
@ -42,6 +42,14 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => {
|
||||
maxAge: 0
|
||||
});
|
||||
|
||||
void res.cookie("aod", "", {
|
||||
httpOnly: false,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secure: appCfg.HTTPS_ENABLED,
|
||||
maxAge: 0
|
||||
});
|
||||
|
||||
return { message: "Successfully logged out" };
|
||||
}
|
||||
});
|
||||
|
@ -28,7 +28,17 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider)
|
||||
.object({
|
||||
authorization: z.string(),
|
||||
host: z.string(),
|
||||
"x-date": z.string()
|
||||
"x-date": z.string().optional(),
|
||||
date: z.string().optional()
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
if (!val.date && !val["x-date"]) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Either date or x-date must be provided",
|
||||
path: ["headers", "date"]
|
||||
});
|
||||
}
|
||||
})
|
||||
.describe(OCI_AUTH.LOGIN.headers)
|
||||
}),
|
||||
|
@ -21,6 +21,7 @@ import { registerHerokuSyncRouter } from "./heroku-sync-router";
|
||||
import { registerHumanitecSyncRouter } from "./humanitec-sync-router";
|
||||
import { registerRailwaySyncRouter } from "./railway-sync-router";
|
||||
import { registerRenderSyncRouter } from "./render-sync-router";
|
||||
import { registerSupabaseSyncRouter } from "./supabase-sync-router";
|
||||
import { registerTeamCitySyncRouter } from "./teamcity-sync-router";
|
||||
import { registerTerraformCloudSyncRouter } from "./terraform-cloud-sync-router";
|
||||
import { registerVercelSyncRouter } from "./vercel-sync-router";
|
||||
@ -53,7 +54,7 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record<SecretSync, (server: Fastif
|
||||
[SecretSync.GitLab]: registerGitLabSyncRouter,
|
||||
[SecretSync.CloudflarePages]: registerCloudflarePagesSyncRouter,
|
||||
[SecretSync.CloudflareWorkers]: registerCloudflareWorkersSyncRouter,
|
||||
|
||||
[SecretSync.Supabase]: registerSupabaseSyncRouter,
|
||||
[SecretSync.Zabbix]: registerZabbixSyncRouter,
|
||||
[SecretSync.Railway]: registerRailwaySyncRouter,
|
||||
[SecretSync.Checkly]: registerChecklySyncRouter
|
||||
|
@ -41,6 +41,7 @@ import { HerokuSyncListItemSchema, HerokuSyncSchema } from "@app/services/secret
|
||||
import { HumanitecSyncListItemSchema, HumanitecSyncSchema } from "@app/services/secret-sync/humanitec";
|
||||
import { RailwaySyncListItemSchema, RailwaySyncSchema } from "@app/services/secret-sync/railway/railway-sync-schemas";
|
||||
import { RenderSyncListItemSchema, RenderSyncSchema } from "@app/services/secret-sync/render/render-sync-schemas";
|
||||
import { SupabaseSyncListItemSchema, SupabaseSyncSchema } from "@app/services/secret-sync/supabase";
|
||||
import { TeamCitySyncListItemSchema, TeamCitySyncSchema } from "@app/services/secret-sync/teamcity";
|
||||
import { TerraformCloudSyncListItemSchema, TerraformCloudSyncSchema } from "@app/services/secret-sync/terraform-cloud";
|
||||
import { VercelSyncListItemSchema, VercelSyncSchema } from "@app/services/secret-sync/vercel";
|
||||
@ -71,7 +72,7 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [
|
||||
GitLabSyncSchema,
|
||||
CloudflarePagesSyncSchema,
|
||||
CloudflareWorkersSyncSchema,
|
||||
|
||||
SupabaseSyncSchema,
|
||||
ZabbixSyncSchema,
|
||||
RailwaySyncSchema,
|
||||
ChecklySyncSchema
|
||||
@ -104,7 +105,8 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [
|
||||
|
||||
ZabbixSyncListItemSchema,
|
||||
RailwaySyncListItemSchema,
|
||||
ChecklySyncListItemSchema
|
||||
ChecklySyncListItemSchema,
|
||||
SupabaseSyncListItemSchema
|
||||
]);
|
||||
|
||||
export const registerSecretSyncRouter = async (server: FastifyZodProvider) => {
|
||||
|
@ -0,0 +1,17 @@
|
||||
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||
import {
|
||||
CreateSupabaseSyncSchema,
|
||||
SupabaseSyncSchema,
|
||||
UpdateSupabaseSyncSchema
|
||||
} from "@app/services/secret-sync/supabase";
|
||||
|
||||
import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints";
|
||||
|
||||
export const registerSupabaseSyncRouter = async (server: FastifyZodProvider) =>
|
||||
registerSyncSecretsEndpoints({
|
||||
destination: SecretSync.Supabase,
|
||||
server,
|
||||
responseSchema: SupabaseSyncSchema,
|
||||
createSchema: CreateSupabaseSyncSchema,
|
||||
updateSchema: UpdateSupabaseSyncSchema
|
||||
});
|
@ -22,6 +22,7 @@ import { logger } from "@app/lib/logger";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { fetchGithubEmails, fetchGithubUser } from "@app/lib/requests/github";
|
||||
import { authRateLimit } from "@app/server/config/rateLimiter";
|
||||
import { addAuthOriginDomainCookie } from "@app/server/lib/cookie";
|
||||
import { AuthMethod } from "@app/services/auth/auth-type";
|
||||
import { OrgAuthMethod } from "@app/services/org/org-types";
|
||||
import { getServerCfg } from "@app/services/super-admin/super-admin-service";
|
||||
@ -475,6 +476,8 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||
secure: appCfg.HTTPS_ENABLED
|
||||
});
|
||||
|
||||
addAuthOriginDomainCookie(res);
|
||||
|
||||
return {
|
||||
encryptionVersion: data.user.encryptionVersion,
|
||||
token: data.token.access,
|
||||
|
@ -4,6 +4,7 @@ import { getConfig } from "@app/lib/config/env";
|
||||
import { crypto } from "@app/lib/crypto";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { mfaRateLimit } from "@app/server/config/rateLimiter";
|
||||
import { addAuthOriginDomainCookie } from "@app/server/lib/cookie";
|
||||
import { AuthModeMfaJwtTokenPayload, AuthTokenType, MfaMethod } from "@app/services/auth/auth-type";
|
||||
|
||||
export const registerMfaRouter = async (server: FastifyZodProvider) => {
|
||||
@ -131,6 +132,8 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => {
|
||||
secure: appCfg.HTTPS_ENABLED
|
||||
});
|
||||
|
||||
addAuthOriginDomainCookie(res);
|
||||
|
||||
return {
|
||||
...user,
|
||||
token: token.access,
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
import { ApiDocsTags, ORGANIZATIONS } from "@app/lib/api-docs";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { addAuthOriginDomainCookie } from "@app/server/lib/cookie";
|
||||
import { GenericResourceNameSchema } from "@app/server/lib/schemas";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { ActorType, AuthMode } from "@app/services/auth/auth-type";
|
||||
@ -396,6 +397,8 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
||||
secure: cfg.HTTPS_ENABLED
|
||||
});
|
||||
|
||||
addAuthOriginDomainCookie(res);
|
||||
|
||||
return { organization, accessToken: tokens.accessToken };
|
||||
}
|
||||
});
|
||||
|
@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { INFISICAL_PROVIDER_GITHUB_ACCESS_TOKEN } from "@app/lib/config/const";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { authRateLimit } from "@app/server/config/rateLimiter";
|
||||
import { addAuthOriginDomainCookie } from "@app/server/lib/cookie";
|
||||
|
||||
export const registerLoginRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
@ -93,6 +94,8 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => {
|
||||
secure: cfg.HTTPS_ENABLED
|
||||
});
|
||||
|
||||
addAuthOriginDomainCookie(res);
|
||||
|
||||
void res.cookie("infisical-project-assume-privileges", "", {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
@ -155,6 +158,8 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => {
|
||||
secure: appCfg.HTTPS_ENABLED
|
||||
});
|
||||
|
||||
addAuthOriginDomainCookie(res);
|
||||
|
||||
void res.cookie("infisical-project-assume-privileges", "", {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
|
@ -4,6 +4,7 @@ import { UsersSchema } from "@app/db/schemas";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { ForbiddenRequestError } from "@app/lib/errors";
|
||||
import { authRateLimit, smtpRateLimit } from "@app/server/config/rateLimiter";
|
||||
import { addAuthOriginDomainCookie } from "@app/server/lib/cookie";
|
||||
import { GenericResourceNameSchema } from "@app/server/lib/schemas";
|
||||
import { getServerCfg } from "@app/services/super-admin/super-admin-service";
|
||||
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
|
||||
@ -170,6 +171,8 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => {
|
||||
secure: appCfg.HTTPS_ENABLED
|
||||
});
|
||||
|
||||
addAuthOriginDomainCookie(res);
|
||||
|
||||
return { message: "Successfully set up account", user, token: accessToken, organizationId };
|
||||
}
|
||||
});
|
||||
@ -239,6 +242,8 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => {
|
||||
});
|
||||
// TODO(akhilmhdh-pg): add telemetry service
|
||||
|
||||
addAuthOriginDomainCookie(res);
|
||||
|
||||
return { message: "Successfully set up account", user, token: accessToken };
|
||||
}
|
||||
});
|
||||
|
@ -31,12 +31,16 @@ export const validateOnePassConnectionCredentials = async (config: TOnePassConne
|
||||
const { apiToken } = config.credentials;
|
||||
|
||||
try {
|
||||
await request.get(`${instanceUrl}/v1/vaults`, {
|
||||
const res = await request.get(`${instanceUrl}/v1/vaults`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
Accept: "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
if (!Array.isArray(res.data)) {
|
||||
throw new AxiosError("Invalid response from 1Password API");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw new BadRequestError({
|
||||
|
@ -31,7 +31,8 @@ export enum AppConnection {
|
||||
Zabbix = "zabbix",
|
||||
Railway = "railway",
|
||||
Bitbucket = "bitbucket",
|
||||
Checkly = "checkly"
|
||||
Checkly = "checkly",
|
||||
Supabase = "supabase"
|
||||
}
|
||||
|
||||
export enum AWSRegion {
|
||||
|
@ -95,6 +95,11 @@ import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postg
|
||||
import { getRailwayConnectionListItem, validateRailwayConnectionCredentials } from "./railway";
|
||||
import { RenderConnectionMethod } from "./render/render-connection-enums";
|
||||
import { getRenderConnectionListItem, validateRenderConnectionCredentials } from "./render/render-connection-fns";
|
||||
import {
|
||||
getSupabaseConnectionListItem,
|
||||
SupabaseConnectionMethod,
|
||||
validateSupabaseConnectionCredentials
|
||||
} from "./supabase";
|
||||
import {
|
||||
getTeamCityConnectionListItem,
|
||||
TeamCityConnectionMethod,
|
||||
@ -148,7 +153,8 @@ export const listAppConnectionOptions = () => {
|
||||
getZabbixConnectionListItem(),
|
||||
getRailwayConnectionListItem(),
|
||||
getBitbucketConnectionListItem(),
|
||||
getChecklyConnectionListItem()
|
||||
getChecklyConnectionListItem(),
|
||||
getSupabaseConnectionListItem()
|
||||
].sort((a, b) => a.name.localeCompare(b.name));
|
||||
};
|
||||
|
||||
@ -232,7 +238,8 @@ export const validateAppConnectionCredentials = async (
|
||||
[AppConnection.Zabbix]: validateZabbixConnectionCredentials as TAppConnectionCredentialsValidator,
|
||||
[AppConnection.Railway]: validateRailwayConnectionCredentials as TAppConnectionCredentialsValidator,
|
||||
[AppConnection.Bitbucket]: validateBitbucketConnectionCredentials as TAppConnectionCredentialsValidator,
|
||||
[AppConnection.Checkly]: validateChecklyConnectionCredentials as TAppConnectionCredentialsValidator
|
||||
[AppConnection.Checkly]: validateChecklyConnectionCredentials as TAppConnectionCredentialsValidator,
|
||||
[AppConnection.Supabase]: validateSupabaseConnectionCredentials as TAppConnectionCredentialsValidator
|
||||
};
|
||||
|
||||
return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection);
|
||||
@ -292,6 +299,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
|
||||
case RenderConnectionMethod.ApiKey:
|
||||
case ChecklyConnectionMethod.ApiKey:
|
||||
return "API Key";
|
||||
case SupabaseConnectionMethod.AccessToken:
|
||||
return "Access Token";
|
||||
default:
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
throw new Error(`Unhandled App Connection Method: ${method}`);
|
||||
@ -355,7 +364,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record<
|
||||
[AppConnection.Zabbix]: platformManagedCredentialsNotSupported,
|
||||
[AppConnection.Railway]: platformManagedCredentialsNotSupported,
|
||||
[AppConnection.Bitbucket]: platformManagedCredentialsNotSupported,
|
||||
[AppConnection.Checkly]: platformManagedCredentialsNotSupported
|
||||
[AppConnection.Checkly]: platformManagedCredentialsNotSupported,
|
||||
[AppConnection.Supabase]: platformManagedCredentialsNotSupported
|
||||
};
|
||||
|
||||
export const enterpriseAppCheck = async (
|
||||
|
@ -33,7 +33,8 @@ export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
|
||||
[AppConnection.Zabbix]: "Zabbix",
|
||||
[AppConnection.Railway]: "Railway",
|
||||
[AppConnection.Bitbucket]: "Bitbucket",
|
||||
[AppConnection.Checkly]: "Checkly"
|
||||
[AppConnection.Checkly]: "Checkly",
|
||||
[AppConnection.Supabase]: "Supabase"
|
||||
};
|
||||
|
||||
export const APP_CONNECTION_PLAN_MAP: Record<AppConnection, AppConnectionPlanType> = {
|
||||
@ -69,5 +70,6 @@ export const APP_CONNECTION_PLAN_MAP: Record<AppConnection, AppConnectionPlanTyp
|
||||
[AppConnection.Zabbix]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.Railway]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.Bitbucket]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.Checkly]: AppConnectionPlanType.Regular
|
||||
[AppConnection.Checkly]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.Supabase]: AppConnectionPlanType.Regular
|
||||
};
|
||||
|
@ -78,6 +78,8 @@ import { ValidateRailwayConnectionCredentialsSchema } from "./railway";
|
||||
import { railwayConnectionService } from "./railway/railway-connection-service";
|
||||
import { ValidateRenderConnectionCredentialsSchema } from "./render/render-connection-schema";
|
||||
import { renderConnectionService } from "./render/render-connection-service";
|
||||
import { ValidateSupabaseConnectionCredentialsSchema } from "./supabase";
|
||||
import { supabaseConnectionService } from "./supabase/supabase-connection-service";
|
||||
import { ValidateTeamCityConnectionCredentialsSchema } from "./teamcity";
|
||||
import { teamcityConnectionService } from "./teamcity/teamcity-connection-service";
|
||||
import { ValidateTerraformCloudConnectionCredentialsSchema } from "./terraform-cloud";
|
||||
@ -131,7 +133,8 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
|
||||
[AppConnection.Zabbix]: ValidateZabbixConnectionCredentialsSchema,
|
||||
[AppConnection.Railway]: ValidateRailwayConnectionCredentialsSchema,
|
||||
[AppConnection.Bitbucket]: ValidateBitbucketConnectionCredentialsSchema,
|
||||
[AppConnection.Checkly]: ValidateChecklyConnectionCredentialsSchema
|
||||
[AppConnection.Checkly]: ValidateChecklyConnectionCredentialsSchema,
|
||||
[AppConnection.Supabase]: ValidateSupabaseConnectionCredentialsSchema
|
||||
};
|
||||
|
||||
export const appConnectionServiceFactory = ({
|
||||
@ -545,6 +548,7 @@ export const appConnectionServiceFactory = ({
|
||||
zabbix: zabbixConnectionService(connectAppConnectionById),
|
||||
railway: railwayConnectionService(connectAppConnectionById),
|
||||
bitbucket: bitbucketConnectionService(connectAppConnectionById),
|
||||
checkly: checklyConnectionService(connectAppConnectionById)
|
||||
checkly: checklyConnectionService(connectAppConnectionById),
|
||||
supabase: supabaseConnectionService(connectAppConnectionById)
|
||||
};
|
||||
};
|
||||
|
@ -159,6 +159,12 @@ import {
|
||||
TRenderConnectionInput,
|
||||
TValidateRenderConnectionCredentialsSchema
|
||||
} from "./render/render-connection-types";
|
||||
import {
|
||||
TSupabaseConnection,
|
||||
TSupabaseConnectionConfig,
|
||||
TSupabaseConnectionInput,
|
||||
TValidateSupabaseConnectionCredentialsSchema
|
||||
} from "./supabase";
|
||||
import {
|
||||
TTeamCityConnection,
|
||||
TTeamCityConnectionConfig,
|
||||
@ -224,6 +230,7 @@ export type TAppConnection = { id: string } & (
|
||||
| TZabbixConnection
|
||||
| TRailwayConnection
|
||||
| TChecklyConnection
|
||||
| TSupabaseConnection
|
||||
);
|
||||
|
||||
export type TAppConnectionRaw = NonNullable<Awaited<ReturnType<TAppConnectionDALFactory["findById"]>>>;
|
||||
@ -264,6 +271,7 @@ export type TAppConnectionInput = { id: string } & (
|
||||
| TZabbixConnectionInput
|
||||
| TRailwayConnectionInput
|
||||
| TChecklyConnectionInput
|
||||
| TSupabaseConnectionInput
|
||||
);
|
||||
|
||||
export type TSqlConnectionInput =
|
||||
@ -311,7 +319,8 @@ export type TAppConnectionConfig =
|
||||
| TBitbucketConnectionConfig
|
||||
| TZabbixConnectionConfig
|
||||
| TRailwayConnectionConfig
|
||||
| TChecklyConnectionConfig;
|
||||
| TChecklyConnectionConfig
|
||||
| TSupabaseConnectionConfig;
|
||||
|
||||
export type TValidateAppConnectionCredentialsSchema =
|
||||
| TValidateAwsConnectionCredentialsSchema
|
||||
@ -346,7 +355,8 @@ export type TValidateAppConnectionCredentialsSchema =
|
||||
| TValidateBitbucketConnectionCredentialsSchema
|
||||
| TValidateZabbixConnectionCredentialsSchema
|
||||
| TValidateRailwayConnectionCredentialsSchema
|
||||
| TValidateChecklyConnectionCredentialsSchema;
|
||||
| TValidateChecklyConnectionCredentialsSchema
|
||||
| TValidateSupabaseConnectionCredentialsSchema;
|
||||
|
||||
export type TListAwsConnectionKmsKeys = {
|
||||
connectionId: string;
|
||||
|
@ -9,6 +9,7 @@ import { getAppConnectionMethodName } from "@app/services/app-connection/app-con
|
||||
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
|
||||
|
||||
import { AppConnection } from "../app-connection-enums";
|
||||
import { GithubTokenRespData, isGithubErrorResponse } from "../github/github-connection-fns";
|
||||
import { GitHubRadarConnectionMethod } from "./github-radar-connection-enums";
|
||||
import {
|
||||
TGitHubRadarConnection,
|
||||
@ -71,13 +72,6 @@ export const listGitHubRadarRepositories = async (appConnection: TGitHubRadarCon
|
||||
return repositories;
|
||||
};
|
||||
|
||||
type TokenRespData = {
|
||||
access_token: string;
|
||||
scope: string;
|
||||
token_type: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const validateGitHubRadarConnectionCredentials = async (config: TGitHubRadarConnectionConfig) => {
|
||||
const { credentials, method } = config;
|
||||
|
||||
@ -93,10 +87,10 @@ export const validateGitHubRadarConnectionCredentials = async (config: TGitHubRa
|
||||
});
|
||||
}
|
||||
|
||||
let tokenResp: AxiosResponse<TokenRespData>;
|
||||
let tokenResp: AxiosResponse<GithubTokenRespData>;
|
||||
|
||||
try {
|
||||
tokenResp = await request.get<TokenRespData>("https://github.com/login/oauth/access_token", {
|
||||
tokenResp = await request.get<GithubTokenRespData>("https://github.com/login/oauth/access_token", {
|
||||
params: {
|
||||
client_id: INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_ID,
|
||||
client_secret: INF_APP_CONNECTION_GITHUB_RADAR_APP_CLIENT_SECRET,
|
||||
@ -108,19 +102,27 @@ export const validateGitHubRadarConnectionCredentials = async (config: TGitHubRa
|
||||
"Accept-Encoding": "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
if (isGithubErrorResponse(tokenResp?.data)) {
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate credentials: GitHub responded with an error: ${tokenResp.data.error} - ${tokenResp.data.error_description}`
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof BadRequestError) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate connection: verify credentials`
|
||||
});
|
||||
}
|
||||
|
||||
if (tokenResp.status !== 200) {
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate credentials: GitHub responded with a status code of ${tokenResp.status} (${tokenResp.statusText}). Verify credentials and try again.`
|
||||
});
|
||||
if (method === GitHubRadarConnectionMethod.App) {
|
||||
if (!tokenResp.data.access_token) {
|
||||
throw new InternalServerError({ message: `Missing access token: ${tokenResp.data.error}` });
|
||||
}
|
||||
|
||||
if (method === GitHubRadarConnectionMethod.App) {
|
||||
const installationsResp = await request.get<{
|
||||
installations: {
|
||||
id: number;
|
||||
@ -149,10 +151,6 @@ export const validateGitHubRadarConnectionCredentials = async (config: TGitHubRa
|
||||
}
|
||||
}
|
||||
|
||||
if (!tokenResp.data.access_token) {
|
||||
throw new InternalServerError({ message: `Missing access token: ${tokenResp.data.error}` });
|
||||
}
|
||||
|
||||
switch (method) {
|
||||
case GitHubRadarConnectionMethod.App:
|
||||
return {
|
||||
|
@ -144,13 +144,21 @@ export const getGitHubEnvironments = async (appConnection: TGitHubConnection, ow
|
||||
}
|
||||
};
|
||||
|
||||
type TokenRespData = {
|
||||
access_token: string;
|
||||
export type GithubTokenRespData = {
|
||||
access_token?: string;
|
||||
scope: string;
|
||||
token_type: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function isGithubErrorResponse(data: GithubTokenRespData): data is GithubTokenRespData & {
|
||||
error: string;
|
||||
error_description: string;
|
||||
error_uri: string;
|
||||
} {
|
||||
return "error" in data;
|
||||
}
|
||||
|
||||
export const validateGitHubConnectionCredentials = async (config: TGitHubConnectionConfig) => {
|
||||
const { credentials, method } = config;
|
||||
|
||||
@ -183,10 +191,10 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect
|
||||
});
|
||||
}
|
||||
|
||||
let tokenResp: AxiosResponse<TokenRespData>;
|
||||
let tokenResp: AxiosResponse<GithubTokenRespData>;
|
||||
|
||||
try {
|
||||
tokenResp = await request.get<TokenRespData>("https://github.com/login/oauth/access_token", {
|
||||
tokenResp = await request.get<GithubTokenRespData>("https://github.com/login/oauth/access_token", {
|
||||
params: {
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
@ -198,7 +206,17 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect
|
||||
"Accept-Encoding": "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
if (isGithubErrorResponse(tokenResp?.data)) {
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate credentials: GitHub responded with an error: ${tokenResp.data.error} - ${tokenResp.data.error_description}`
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof BadRequestError) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: `Unable to validate connection: verify credentials`
|
||||
});
|
||||
@ -211,6 +229,10 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect
|
||||
}
|
||||
|
||||
if (method === GitHubConnectionMethod.App) {
|
||||
if (!tokenResp.data.access_token) {
|
||||
throw new InternalServerError({ message: `Missing access token: ${tokenResp.data.error}` });
|
||||
}
|
||||
|
||||
const installationsResp = await request.get<{
|
||||
installations: {
|
||||
id: number;
|
||||
@ -239,10 +261,6 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect
|
||||
}
|
||||
}
|
||||
|
||||
if (!tokenResp.data.access_token) {
|
||||
throw new InternalServerError({ message: `Missing access token: ${tokenResp.data.error}` });
|
||||
}
|
||||
|
||||
switch (method) {
|
||||
case GitHubConnectionMethod.App:
|
||||
return {
|
||||
|
4
backend/src/services/app-connection/supabase/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./supabase-connection-constants";
|
||||
export * from "./supabase-connection-fns";
|
||||
export * from "./supabase-connection-schemas";
|
||||
export * from "./supabase-connection-types";
|
@ -0,0 +1,3 @@
|
||||
export enum SupabaseConnectionMethod {
|
||||
AccessToken = "access-token"
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
|
||||
import { SupabaseConnectionMethod } from "./supabase-connection-constants";
|
||||
import { SupabasePublicAPI } from "./supabase-connection-public-client";
|
||||
import { TSupabaseConnection, TSupabaseConnectionConfig } from "./supabase-connection-types";
|
||||
|
||||
export const getSupabaseConnectionListItem = () => {
|
||||
return {
|
||||
name: "Supabase" as const,
|
||||
app: AppConnection.Supabase as const,
|
||||
methods: Object.values(SupabaseConnectionMethod)
|
||||
};
|
||||
};
|
||||
|
||||
export const validateSupabaseConnectionCredentials = async (config: TSupabaseConnectionConfig) => {
|
||||
const { credentials } = config;
|
||||
|
||||
try {
|
||||
await SupabasePublicAPI.healthcheck(config);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to validate credentials: ${error.message || "Unknown error"}`
|
||||
});
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: "Unable to validate connection - verify credentials"
|
||||
});
|
||||
}
|
||||
|
||||
return credentials;
|
||||
};
|
||||
|
||||
export const listProjects = async (appConnection: TSupabaseConnection) => {
|
||||
try {
|
||||
return await SupabasePublicAPI.getProjects(appConnection);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to list projects: ${error.message || "Unknown error"}`
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof BadRequestError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: "Unable to list projects",
|
||||
error
|
||||
});
|
||||
}
|
||||
};
|
@ -0,0 +1,133 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
/* eslint-disable class-methods-use-this */
|
||||
import { AxiosInstance, AxiosRequestConfig, AxiosResponse, HttpStatusCode } from "axios";
|
||||
|
||||
import { createRequestClient } from "@app/lib/config/request";
|
||||
import { delay } from "@app/lib/delay";
|
||||
import { removeTrailingSlash } from "@app/lib/fn";
|
||||
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
|
||||
|
||||
import { SupabaseConnectionMethod } from "./supabase-connection-constants";
|
||||
import { TSupabaseConnectionConfig, TSupabaseProject, TSupabaseSecret } from "./supabase-connection-types";
|
||||
|
||||
export const getSupabaseInstanceUrl = async (config: TSupabaseConnectionConfig) => {
|
||||
const instanceUrl = config.credentials.instanceUrl
|
||||
? removeTrailingSlash(config.credentials.instanceUrl)
|
||||
: "https://api.supabase.com";
|
||||
|
||||
await blockLocalAndPrivateIpAddresses(instanceUrl);
|
||||
|
||||
return instanceUrl;
|
||||
};
|
||||
|
||||
export function getSupabaseAuthHeaders(connection: TSupabaseConnectionConfig): Record<string, string> {
|
||||
switch (connection.method) {
|
||||
case SupabaseConnectionMethod.AccessToken:
|
||||
return {
|
||||
Authorization: `Bearer ${connection.credentials.accessKey}`
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unsupported Supabase connection method`);
|
||||
}
|
||||
}
|
||||
|
||||
export function getSupabaseRatelimiter(response: AxiosResponse): {
|
||||
maxAttempts: number;
|
||||
isRatelimited: boolean;
|
||||
wait: () => Promise<void>;
|
||||
} {
|
||||
const wait = () => {
|
||||
return delay(60 * 1000);
|
||||
};
|
||||
|
||||
return {
|
||||
isRatelimited: response.status === HttpStatusCode.TooManyRequests,
|
||||
wait,
|
||||
maxAttempts: 3
|
||||
};
|
||||
}
|
||||
|
||||
class SupabasePublicClient {
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.client = createRequestClient({
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async send<T>(
|
||||
connection: TSupabaseConnectionConfig,
|
||||
config: AxiosRequestConfig,
|
||||
retryAttempt = 0
|
||||
): Promise<T | undefined> {
|
||||
const response = await this.client.request<T>({
|
||||
...config,
|
||||
baseURL: await getSupabaseInstanceUrl(connection),
|
||||
validateStatus: (status) => (status >= 200 && status < 300) || status === HttpStatusCode.TooManyRequests,
|
||||
headers: getSupabaseAuthHeaders(connection)
|
||||
});
|
||||
|
||||
const limiter = getSupabaseRatelimiter(response);
|
||||
|
||||
if (limiter.isRatelimited && retryAttempt <= limiter.maxAttempts) {
|
||||
await limiter.wait();
|
||||
return this.send(connection, config, retryAttempt + 1);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async healthcheck(connection: TSupabaseConnectionConfig) {
|
||||
switch (connection.method) {
|
||||
case SupabaseConnectionMethod.AccessToken:
|
||||
return void (await this.getProjects(connection));
|
||||
default:
|
||||
throw new Error(`Unsupported Supabase connection method`);
|
||||
}
|
||||
}
|
||||
|
||||
async getVariables(connection: TSupabaseConnectionConfig, projectRef: string) {
|
||||
const res = await this.send<TSupabaseSecret[]>(connection, {
|
||||
method: "GET",
|
||||
url: `/v1/projects/${projectRef}/secrets`
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// Supabase does not support updating variables directly
|
||||
// Instead, just call create again with the same key and it will overwrite the existing variable
|
||||
async createVariables(connection: TSupabaseConnectionConfig, projectRef: string, ...variables: TSupabaseSecret[]) {
|
||||
const res = await this.send<TSupabaseSecret>(connection, {
|
||||
method: "POST",
|
||||
url: `/v1/projects/${projectRef}/secrets`,
|
||||
data: variables
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async deleteVariables(connection: TSupabaseConnectionConfig, projectRef: string, ...variables: string[]) {
|
||||
const res = await this.send(connection, {
|
||||
method: "DELETE",
|
||||
url: `/v1/projects/${projectRef}/secrets`,
|
||||
data: variables
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async getProjects(connection: TSupabaseConnectionConfig) {
|
||||
const res = await this.send<TSupabaseProject[]>(connection, {
|
||||
method: "GET",
|
||||
url: `/v1/projects`
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export const SupabasePublicAPI = new SupabasePublicClient();
|
@ -0,0 +1,70 @@
|
||||
import z from "zod";
|
||||
|
||||
import { AppConnections } from "@app/lib/api-docs";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import {
|
||||
BaseAppConnectionSchema,
|
||||
GenericCreateAppConnectionFieldsSchema,
|
||||
GenericUpdateAppConnectionFieldsSchema
|
||||
} from "@app/services/app-connection/app-connection-schemas";
|
||||
|
||||
import { SupabaseConnectionMethod } from "./supabase-connection-constants";
|
||||
|
||||
export const SupabaseConnectionMethodSchema = z
|
||||
.nativeEnum(SupabaseConnectionMethod)
|
||||
.describe(AppConnections.CREATE(AppConnection.Supabase).method);
|
||||
|
||||
export const SupabaseConnectionAccessTokenCredentialsSchema = z.object({
|
||||
accessKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Access Key required")
|
||||
.max(255)
|
||||
.describe(AppConnections.CREDENTIALS.SUPABASE.accessKey),
|
||||
instanceUrl: z.string().trim().url().max(255).describe(AppConnections.CREDENTIALS.SUPABASE.instanceUrl).optional()
|
||||
});
|
||||
|
||||
const BaseSupabaseConnectionSchema = BaseAppConnectionSchema.extend({
|
||||
app: z.literal(AppConnection.Supabase)
|
||||
});
|
||||
|
||||
export const SupabaseConnectionSchema = BaseSupabaseConnectionSchema.extend({
|
||||
method: SupabaseConnectionMethodSchema,
|
||||
credentials: SupabaseConnectionAccessTokenCredentialsSchema
|
||||
});
|
||||
|
||||
export const SanitizedSupabaseConnectionSchema = z.discriminatedUnion("method", [
|
||||
BaseSupabaseConnectionSchema.extend({
|
||||
method: SupabaseConnectionMethodSchema,
|
||||
credentials: SupabaseConnectionAccessTokenCredentialsSchema.pick({
|
||||
instanceUrl: true
|
||||
})
|
||||
})
|
||||
]);
|
||||
|
||||
export const ValidateSupabaseConnectionCredentialsSchema = z.discriminatedUnion("method", [
|
||||
z.object({
|
||||
method: SupabaseConnectionMethodSchema,
|
||||
credentials: SupabaseConnectionAccessTokenCredentialsSchema.describe(
|
||||
AppConnections.CREATE(AppConnection.Supabase).credentials
|
||||
)
|
||||
})
|
||||
]);
|
||||
|
||||
export const CreateSupabaseConnectionSchema = ValidateSupabaseConnectionCredentialsSchema.and(
|
||||
GenericCreateAppConnectionFieldsSchema(AppConnection.Supabase)
|
||||
);
|
||||
|
||||
export const UpdateSupabaseConnectionSchema = z
|
||||
.object({
|
||||
credentials: SupabaseConnectionAccessTokenCredentialsSchema.optional().describe(
|
||||
AppConnections.UPDATE(AppConnection.Supabase).credentials
|
||||
)
|
||||
})
|
||||
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Supabase));
|
||||
|
||||
export const SupabaseConnectionListItemSchema = z.object({
|
||||
name: z.literal("Supabase"),
|
||||
app: z.literal(AppConnection.Supabase),
|
||||
methods: z.nativeEnum(SupabaseConnectionMethod).array()
|
||||
});
|
@ -0,0 +1,30 @@
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
|
||||
import { AppConnection } from "../app-connection-enums";
|
||||
import { listProjects as getSupabaseProjects } from "./supabase-connection-fns";
|
||||
import { TSupabaseConnection } from "./supabase-connection-types";
|
||||
|
||||
type TGetAppConnectionFunc = (
|
||||
app: AppConnection,
|
||||
connectionId: string,
|
||||
actor: OrgServiceActor
|
||||
) => Promise<TSupabaseConnection>;
|
||||
|
||||
export const supabaseConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
|
||||
const listProjects = async (connectionId: string, actor: OrgServiceActor) => {
|
||||
const appConnection = await getAppConnection(AppConnection.Supabase, connectionId, actor);
|
||||
try {
|
||||
const projects = await getSupabaseProjects(appConnection);
|
||||
|
||||
return projects ?? [];
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to establish connection with Supabase");
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
listProjects
|
||||
};
|
||||
};
|
@ -0,0 +1,44 @@
|
||||
import z from "zod";
|
||||
|
||||
import { DiscriminativePick } from "@app/lib/types";
|
||||
|
||||
import { AppConnection } from "../app-connection-enums";
|
||||
import {
|
||||
CreateSupabaseConnectionSchema,
|
||||
SupabaseConnectionSchema,
|
||||
ValidateSupabaseConnectionCredentialsSchema
|
||||
} from "./supabase-connection-schemas";
|
||||
|
||||
export type TSupabaseConnection = z.infer<typeof SupabaseConnectionSchema>;
|
||||
|
||||
export type TSupabaseConnectionInput = z.infer<typeof CreateSupabaseConnectionSchema> & {
|
||||
app: AppConnection.Supabase;
|
||||
};
|
||||
|
||||
export type TValidateSupabaseConnectionCredentialsSchema = typeof ValidateSupabaseConnectionCredentialsSchema;
|
||||
|
||||
export type TSupabaseConnectionConfig = DiscriminativePick<TSupabaseConnection, "method" | "app" | "credentials"> & {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export type TSupabaseProject = {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
name: string;
|
||||
region: string;
|
||||
created_at: Date;
|
||||
status: string;
|
||||
database: TSupabaseDatabase;
|
||||
};
|
||||
|
||||
type TSupabaseDatabase = {
|
||||
host: string;
|
||||
version: string;
|
||||
postgres_engine: string;
|
||||
release_channel: string;
|
||||
};
|
||||
|
||||
export type TSupabaseSecret = {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
@ -218,7 +218,7 @@ export const certificateAuthorityDALFactory = (db: TDbClient) => {
|
||||
};
|
||||
|
||||
const findWithAssociatedCa = async (
|
||||
filter: Parameters<(typeof caOrm)["find"]>[0] & { dn?: string; type?: string },
|
||||
filter: Parameters<(typeof caOrm)["find"]>[0] & { dn?: string; type?: string; serialNumber?: string },
|
||||
{ offset, limit, sort = [["createdAt", "desc"]] }: TFindOpt<TCertificateAuthorities> = {},
|
||||
tx?: Knex
|
||||
) => {
|
||||
|
@ -1068,11 +1068,11 @@ export const internalCertificateAuthorityServiceFactory = ({
|
||||
throw new BadRequestError({ message: "Invalid certificate chain" });
|
||||
|
||||
const parentCertObj = chainItems[1];
|
||||
const parentCertSubject = parentCertObj.subject;
|
||||
const parentSerialNumber = parentCertObj.serialNumber;
|
||||
|
||||
const [parentCa] = await certificateAuthorityDAL.findWithAssociatedCa({
|
||||
[`${TableName.CertificateAuthority}.projectId` as "projectId"]: ca.projectId,
|
||||
[`${TableName.InternalCertificateAuthority}.dn` as "dn"]: parentCertSubject
|
||||
[`${TableName.InternalCertificateAuthority}.serialNumber` as "serialNumber"]: parentSerialNumber
|
||||
});
|
||||
|
||||
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
|
||||
|
@ -47,6 +47,14 @@ export enum ResourceType {
|
||||
FOLDER = "folder"
|
||||
}
|
||||
|
||||
export type TCommitResourceChangeDTO = {
|
||||
type: string;
|
||||
secretVersionId?: string;
|
||||
folderVersionId?: string;
|
||||
isUpdate?: boolean;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
type TCreateCommitDTO = {
|
||||
actor: {
|
||||
type: string;
|
||||
@ -57,13 +65,7 @@ type TCreateCommitDTO = {
|
||||
};
|
||||
message?: string;
|
||||
folderId: string;
|
||||
changes: {
|
||||
type: string;
|
||||
secretVersionId?: string;
|
||||
folderVersionId?: string;
|
||||
isUpdate?: boolean;
|
||||
folderId?: string;
|
||||
}[];
|
||||
changes: TCommitResourceChangeDTO[];
|
||||
omitIgnoreFilter?: boolean;
|
||||
};
|
||||
|
||||
|
@ -6,7 +6,8 @@ export type TLoginOciAuthDTO = {
|
||||
headers: {
|
||||
authorization: string;
|
||||
host: string;
|
||||
"x-date": string;
|
||||
"x-date"?: string;
|
||||
date?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -1,4 +1,6 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
import { Knex } from "knex";
|
||||
import path from "path";
|
||||
import { v4 as uuidv4, validate as uuidValidate } from "uuid";
|
||||
|
||||
@ -12,14 +14,21 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { OrderByDirection, OrgServiceActor } from "@app/lib/types";
|
||||
import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns";
|
||||
|
||||
import { ChangeType, CommitType, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
|
||||
import {
|
||||
ChangeType,
|
||||
CommitType,
|
||||
TCommitResourceChangeDTO,
|
||||
TFolderCommitServiceFactory
|
||||
} from "../folder-commit/folder-commit-service";
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
|
||||
import { TSecretFolderDALFactory } from "./secret-folder-dal";
|
||||
import {
|
||||
TCreateFolderDTO,
|
||||
TCreateManyFoldersDTO,
|
||||
TDeleteFolderDTO,
|
||||
TDeleteManyFoldersDTO,
|
||||
TGetFolderByIdDTO,
|
||||
TGetFolderDTO,
|
||||
TGetFoldersDeepByEnvsDTO,
|
||||
@ -236,19 +245,29 @@ export const secretFolderServiceFactory = ({
|
||||
actor,
|
||||
actorId,
|
||||
projectSlug,
|
||||
projectId: providedProjectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
folders
|
||||
}: TUpdateManyFoldersDTO) => {
|
||||
folders,
|
||||
tx: providedTx,
|
||||
commitChanges
|
||||
}: TUpdateManyFoldersDTO & { tx?: Knex; commitChanges?: TCommitResourceChangeDTO[]; projectId?: string }) => {
|
||||
let projectId = providedProjectId;
|
||||
if (!projectId && projectSlug) {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) {
|
||||
throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
|
||||
}
|
||||
projectId = project.id;
|
||||
}
|
||||
if (!projectId) {
|
||||
throw new BadRequestError({ message: "Must provide either project slug or projectId" });
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: project.id,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
});
|
||||
@ -260,12 +279,12 @@ export const secretFolderServiceFactory = ({
|
||||
);
|
||||
});
|
||||
|
||||
const result = await folderDAL.transaction(async (tx) =>
|
||||
Promise.all(
|
||||
const executeBulkUpdate = async (tx: Knex) => {
|
||||
return Promise.all(
|
||||
folders.map(async (newFolder) => {
|
||||
const { environment, path: secretPath, id, name, description } = newFolder;
|
||||
|
||||
const parentFolder = await folderDAL.findBySecretPath(project.id, environment, secretPath);
|
||||
const parentFolder = await folderDAL.findBySecretPath(projectId as string, environment, secretPath, tx);
|
||||
if (!parentFolder) {
|
||||
throw new NotFoundError({
|
||||
message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found`,
|
||||
@ -273,10 +292,10 @@ export const secretFolderServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
const env = await projectEnvDAL.findOne({ projectId: project.id, slug: environment });
|
||||
const env = await projectEnvDAL.findOne({ projectId, slug: environment }, tx);
|
||||
if (!env) {
|
||||
throw new NotFoundError({
|
||||
message: `Environment with slug '${environment}' in project with ID '${project.id}' not found`,
|
||||
message: `Environment with slug '${environment}' in project with ID '${projectId}' not found`,
|
||||
name: "UpdateManyFolders"
|
||||
});
|
||||
}
|
||||
@ -323,6 +342,13 @@ export const secretFolderServiceFactory = ({
|
||||
},
|
||||
tx
|
||||
);
|
||||
if (commitChanges) {
|
||||
commitChanges.push({
|
||||
type: CommitType.ADD,
|
||||
isUpdate: true,
|
||||
folderVersionId: folderVersion.id
|
||||
});
|
||||
} else {
|
||||
await folderCommitService.createCommit(
|
||||
{
|
||||
actor: {
|
||||
@ -343,6 +369,7 @@ export const secretFolderServiceFactory = ({
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
if (!doc) {
|
||||
throw new NotFoundError({
|
||||
message: `Failed to update folder with id '${id}', not found`,
|
||||
@ -352,13 +379,16 @@ export const secretFolderServiceFactory = ({
|
||||
|
||||
return { oldFolder: folder, newFolder: doc };
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Execute with provided transaction or create new one
|
||||
const result = providedTx ? await executeBulkUpdate(providedTx) : await folderDAL.transaction(executeBulkUpdate);
|
||||
|
||||
await Promise.all(result.map(async (res) => snapshotService.performSnapshot(res.newFolder.parentId as string)));
|
||||
|
||||
return {
|
||||
projectId: project.id,
|
||||
projectId,
|
||||
newFolders: result.map((res) => res.newFolder),
|
||||
oldFolders: result.map((res) => res.oldFolder)
|
||||
};
|
||||
@ -974,6 +1004,361 @@ export const secretFolderServiceFactory = ({
|
||||
}));
|
||||
};
|
||||
|
||||
const createManyFolders = async ({
|
||||
projectId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
folders,
|
||||
tx: providedTx,
|
||||
commitChanges
|
||||
}: TCreateManyFoldersDTO & { tx?: Knex; commitChanges?: TCommitResourceChangeDTO[] }) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
});
|
||||
|
||||
folders.forEach(({ environment, path: secretPath }) => {
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })
|
||||
);
|
||||
});
|
||||
|
||||
const foldersByEnv = folders.reduce(
|
||||
(acc, folder) => {
|
||||
if (!acc[folder.environment]) {
|
||||
acc[folder.environment] = [];
|
||||
}
|
||||
acc[folder.environment].push(folder);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof folders>
|
||||
);
|
||||
|
||||
const executeBulkCreate = async (tx: Knex) => {
|
||||
const createdFolders = [];
|
||||
|
||||
for (const [environment, envFolders] of Object.entries(foldersByEnv)) {
|
||||
const env = await projectEnvDAL.findOne({ projectId, slug: environment });
|
||||
if (!env) {
|
||||
throw new NotFoundError({
|
||||
message: `Environment with slug '${environment}' in project with ID '${projectId}' not found`
|
||||
});
|
||||
}
|
||||
|
||||
await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.CreateFolder(env.id, env.projectId)]);
|
||||
|
||||
for (const folderSpec of envFolders) {
|
||||
const { name, path: secretPath, description } = folderSpec;
|
||||
|
||||
const pathWithFolder = path.join(secretPath, name);
|
||||
const parentFolder = await folderDAL.findClosestFolder(projectId, environment, pathWithFolder, tx);
|
||||
|
||||
if (!parentFolder) {
|
||||
throw new NotFoundError({
|
||||
message: `Parent folder for path '${pathWithFolder}' not found`
|
||||
});
|
||||
}
|
||||
|
||||
// Check if the exact folder already exists
|
||||
const existingFolder = await folderDAL.findOne(
|
||||
{
|
||||
envId: env.id,
|
||||
parentId: parentFolder.id,
|
||||
name,
|
||||
isReserved: false
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (existingFolder) {
|
||||
createdFolders.push(existingFolder);
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle exact folder case
|
||||
if (parentFolder.path === pathWithFolder) {
|
||||
createdFolders.push(parentFolder);
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
let currentParentId = parentFolder.id;
|
||||
|
||||
// Build the full path we need by processing each segment
|
||||
if (parentFolder.path !== secretPath) {
|
||||
const missingSegments = secretPath.substring(parentFolder.path.length).split("/").filter(Boolean);
|
||||
const newFolders: TSecretFoldersInsert[] = [];
|
||||
|
||||
for (const segment of missingSegments) {
|
||||
const existingSegment = await folderDAL.findOne(
|
||||
{
|
||||
name: segment,
|
||||
parentId: currentParentId,
|
||||
envId: env.id,
|
||||
isReserved: false
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (existingSegment) {
|
||||
currentParentId = existingSegment.id;
|
||||
} else {
|
||||
const newFolder = {
|
||||
name: segment,
|
||||
parentId: currentParentId,
|
||||
id: uuidv4(),
|
||||
envId: env.id,
|
||||
version: 1
|
||||
};
|
||||
|
||||
currentParentId = newFolder.id;
|
||||
newFolders.push(newFolder);
|
||||
}
|
||||
}
|
||||
|
||||
if (newFolders.length) {
|
||||
const docs = await folderDAL.insertMany(newFolders, tx);
|
||||
const folderVersions = await folderVersionDAL.insertMany(
|
||||
docs.map((doc) => ({
|
||||
name: doc.name,
|
||||
envId: doc.envId,
|
||||
version: doc.version,
|
||||
folderId: doc.id,
|
||||
description: doc.description
|
||||
})),
|
||||
tx
|
||||
);
|
||||
await folderCommitService.createCommit(
|
||||
{
|
||||
actor: {
|
||||
type: actor,
|
||||
metadata: {
|
||||
id: actorId
|
||||
}
|
||||
},
|
||||
message: "Folders created (batch)",
|
||||
folderId: currentParentId,
|
||||
changes: folderVersions.map((fv) => ({
|
||||
type: CommitType.ADD,
|
||||
folderVersionId: fv.id
|
||||
}))
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the target folder
|
||||
const doc = await folderDAL.create(
|
||||
{ name, envId: env.id, version: 1, parentId: currentParentId, description },
|
||||
tx
|
||||
);
|
||||
|
||||
const folderVersion = await folderVersionDAL.create(
|
||||
{
|
||||
name: doc.name,
|
||||
envId: doc.envId,
|
||||
version: doc.version,
|
||||
folderId: doc.id,
|
||||
description: doc.description
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (commitChanges) {
|
||||
commitChanges.push({
|
||||
type: CommitType.ADD,
|
||||
folderVersionId: folderVersion.id
|
||||
});
|
||||
} else {
|
||||
await folderCommitService.createCommit(
|
||||
{
|
||||
actor: {
|
||||
type: actor,
|
||||
metadata: {
|
||||
id: actorId
|
||||
}
|
||||
},
|
||||
message: "Folder created (batch)",
|
||||
folderId: doc.id,
|
||||
changes: [
|
||||
{
|
||||
type: CommitType.ADD,
|
||||
folderVersionId: folderVersion.id
|
||||
}
|
||||
]
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
createdFolders.push(doc);
|
||||
}
|
||||
}
|
||||
|
||||
return createdFolders;
|
||||
};
|
||||
const result = providedTx ? await executeBulkCreate(providedTx) : await folderDAL.transaction(executeBulkCreate);
|
||||
const uniqueParentIds = [...new Set(result.map((folder) => folder.parentId).filter(Boolean))];
|
||||
await Promise.all(uniqueParentIds.map((parentId) => snapshotService.performSnapshot(parentId as string)));
|
||||
|
||||
return {
|
||||
folders: result,
|
||||
count: result.length
|
||||
};
|
||||
};
|
||||
|
||||
const deleteManyFolders = async ({
|
||||
projectId,
|
||||
actor,
|
||||
actorId,
|
||||
actorOrgId,
|
||||
actorAuthMethod,
|
||||
folders,
|
||||
tx: providedTx,
|
||||
commitChanges
|
||||
}: TDeleteManyFoldersDTO & { tx?: Knex; commitChanges?: TCommitResourceChangeDTO[] }) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
});
|
||||
|
||||
folders.forEach(({ environment, path: secretPath }) => {
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Delete,
|
||||
subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })
|
||||
);
|
||||
});
|
||||
|
||||
const foldersByEnv = folders.reduce(
|
||||
(acc, folder) => {
|
||||
if (!acc[folder.environment]) {
|
||||
acc[folder.environment] = [];
|
||||
}
|
||||
acc[folder.environment].push(folder);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof folders>
|
||||
);
|
||||
|
||||
const executeBulkDelete = async (tx: Knex) => {
|
||||
const deletedFolders = [];
|
||||
|
||||
for (const [environment, envFolders] of Object.entries(foldersByEnv)) {
|
||||
const env = await projectEnvDAL.findOne({ projectId, slug: environment });
|
||||
if (!env) {
|
||||
throw new NotFoundError({
|
||||
message: `Environment with slug '${environment}' not found`
|
||||
});
|
||||
}
|
||||
|
||||
for (const folderSpec of envFolders) {
|
||||
const { path: secretPath, idOrName } = folderSpec;
|
||||
|
||||
const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath, tx);
|
||||
if (!parentFolder) {
|
||||
throw new NotFoundError({
|
||||
message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found`
|
||||
});
|
||||
}
|
||||
|
||||
await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName });
|
||||
|
||||
let folderToDelete = await folderDAL
|
||||
.findOne({
|
||||
envId: env.id,
|
||||
name: idOrName,
|
||||
parentId: parentFolder.id,
|
||||
isReserved: false
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!folderToDelete && uuidValidate(idOrName)) {
|
||||
folderToDelete = await folderDAL
|
||||
.findOne({
|
||||
envId: env.id,
|
||||
id: idOrName,
|
||||
parentId: parentFolder.id,
|
||||
isReserved: false
|
||||
})
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
if (!folderToDelete) {
|
||||
throw new NotFoundError({
|
||||
message: `Folder with ID/name '${idOrName}' not found`
|
||||
});
|
||||
}
|
||||
|
||||
const [doc] = await folderDAL.delete(
|
||||
{
|
||||
envId: env.id,
|
||||
id: folderToDelete.id,
|
||||
parentId: parentFolder.id,
|
||||
isReserved: false
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
const folderVersions = await folderVersionDAL.findLatestFolderVersions([doc.id], tx);
|
||||
|
||||
if (commitChanges) {
|
||||
commitChanges.push({
|
||||
type: CommitType.DELETE,
|
||||
folderVersionId: folderVersions[doc.id].id,
|
||||
folderId: doc.id
|
||||
});
|
||||
} else {
|
||||
await folderCommitService.createCommit(
|
||||
{
|
||||
actor: {
|
||||
type: actor,
|
||||
metadata: {
|
||||
id: actorId
|
||||
}
|
||||
},
|
||||
message: "Folder deleted (batch)",
|
||||
folderId: parentFolder.id,
|
||||
changes: [
|
||||
{
|
||||
type: CommitType.DELETE,
|
||||
folderVersionId: folderVersions[doc.id].id,
|
||||
folderId: doc.id
|
||||
}
|
||||
]
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
|
||||
deletedFolders.push(doc);
|
||||
}
|
||||
}
|
||||
|
||||
return deletedFolders;
|
||||
};
|
||||
|
||||
const result = providedTx ? await executeBulkDelete(providedTx) : await folderDAL.transaction(executeBulkDelete);
|
||||
|
||||
const uniqueParentIds = [...new Set(result.map((folder) => folder.parentId).filter(Boolean))];
|
||||
await Promise.all(uniqueParentIds.map((parentId) => snapshotService.performSnapshot(parentId as string)));
|
||||
|
||||
return {
|
||||
folders: result,
|
||||
count: result.length
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
createFolder,
|
||||
updateFolder,
|
||||
@ -986,6 +1371,8 @@ export const secretFolderServiceFactory = ({
|
||||
getFoldersDeepByEnvs,
|
||||
getProjectEnvironmentsFolders,
|
||||
getFolderVersionsByIds,
|
||||
getFolderVersions
|
||||
getFolderVersions,
|
||||
createManyFolders,
|
||||
deleteManyFolders
|
||||
};
|
||||
};
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
|
||||
import { SecretsOrderBy } from "@app/services/secret/secret-types";
|
||||
|
||||
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||
|
||||
export enum ReservedFolders {
|
||||
SecretReplication = "__reserve_replication_"
|
||||
}
|
||||
@ -21,7 +23,7 @@ export type TUpdateFolderDTO = {
|
||||
} & TProjectPermission;
|
||||
|
||||
export type TUpdateManyFoldersDTO = {
|
||||
projectSlug: string;
|
||||
projectSlug?: string;
|
||||
folders: {
|
||||
environment: string;
|
||||
path: string;
|
||||
@ -62,3 +64,30 @@ export type TGetFoldersDeepByEnvsDTO = {
|
||||
export type TFindFoldersDeepByParentIdsDTO = {
|
||||
parentIds: string[];
|
||||
};
|
||||
|
||||
export type TCreateManyFoldersDTO = {
|
||||
projectId: string;
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
actorOrgId?: string;
|
||||
folders: Array<{
|
||||
name: string;
|
||||
environment: string;
|
||||
path: string;
|
||||
description?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type TDeleteManyFoldersDTO = {
|
||||
projectId: string;
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
actorOrgId?: string;
|
||||
folders: Array<{
|
||||
environment: string;
|
||||
path: string;
|
||||
idOrName: string;
|
||||
}>;
|
||||
};
|
||||
|
@ -174,6 +174,7 @@ export const fnSecretsV2FromImports = async ({
|
||||
skipMultilineEncoding?: boolean | null;
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
secretKey: string;
|
||||
}) => Promise<string | undefined>;
|
||||
hasSecretAccess: (environment: string, secretPath: string, secretName: string, secretTagSlugs: string[]) => boolean;
|
||||
}) => {
|
||||
@ -293,7 +294,8 @@ export const fnSecretsV2FromImports = async ({
|
||||
value: decryptedSecret.secretValue,
|
||||
secretPath: processedImport.secretPath,
|
||||
environment: processedImport.environment,
|
||||
skipMultilineEncoding: decryptedSecret.skipMultilineEncoding
|
||||
skipMultilineEncoding: decryptedSecret.skipMultilineEncoding,
|
||||
secretKey: decryptedSecret.secretKey
|
||||
});
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
processedImport.secrets[index].secretValue = expandedSecretValue || "";
|
||||
|
@ -1,4 +1,6 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
|
||||
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
|
||||
@ -71,7 +73,7 @@ const putEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, secr
|
||||
);
|
||||
};
|
||||
|
||||
const deleteEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, secret: TRenderSecret) => {
|
||||
const deleteEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, secret: Pick<TRenderSecret, "key">) => {
|
||||
const {
|
||||
destinationConfig,
|
||||
connection: {
|
||||
@ -79,6 +81,7 @@ const deleteEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, s
|
||||
}
|
||||
} = secretSync;
|
||||
|
||||
try {
|
||||
await request.delete(
|
||||
`${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars/${secret.key}`,
|
||||
{
|
||||
@ -88,6 +91,14 @@ const deleteEnvironmentSecret = async (secretSync: TRenderSyncWithCredentials, s
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (isAxiosError(error) && error.response?.status === 404) {
|
||||
// If the secret does not exist, we can ignore this error
|
||||
return;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const sleep = async () =>
|
||||
@ -99,6 +110,11 @@ export const RenderSyncFns = {
|
||||
syncSecrets: async (secretSync: TRenderSyncWithCredentials, secretMap: TSecretMap) => {
|
||||
const renderSecrets = await getRenderEnvironmentSecrets(secretSync);
|
||||
for await (const key of Object.keys(secretMap)) {
|
||||
// If value is empty skip it as render does not allow empty variables
|
||||
if (secretMap[key].value === "") {
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
await putEnvironmentSecret(secretSync, secretMap, key);
|
||||
await sleep();
|
||||
}
|
||||
|
@ -22,7 +22,7 @@ export enum SecretSync {
|
||||
GitLab = "gitlab",
|
||||
CloudflarePages = "cloudflare-pages",
|
||||
CloudflareWorkers = "cloudflare-workers",
|
||||
|
||||
Supabase = "supabase",
|
||||
Zabbix = "zabbix",
|
||||
Railway = "railway",
|
||||
Checkly = "checkly"
|
||||
|
@ -46,6 +46,7 @@ import { RAILWAY_SYNC_LIST_OPTION } from "./railway/railway-sync-constants";
|
||||
import { RailwaySyncFns } from "./railway/railway-sync-fns";
|
||||
import { RENDER_SYNC_LIST_OPTION, RenderSyncFns } from "./render";
|
||||
import { SECRET_SYNC_PLAN_MAP } from "./secret-sync-maps";
|
||||
import { SUPABASE_SYNC_LIST_OPTION, SupabaseSyncFns } from "./supabase";
|
||||
import { TEAMCITY_SYNC_LIST_OPTION, TeamCitySyncFns } from "./teamcity";
|
||||
import { TERRAFORM_CLOUD_SYNC_LIST_OPTION, TerraformCloudSyncFns } from "./terraform-cloud";
|
||||
import { VERCEL_SYNC_LIST_OPTION, VercelSyncFns } from "./vercel";
|
||||
@ -76,7 +77,7 @@ const SECRET_SYNC_LIST_OPTIONS: Record<SecretSync, TSecretSyncListItem> = {
|
||||
[SecretSync.GitLab]: GITLAB_SYNC_LIST_OPTION,
|
||||
[SecretSync.CloudflarePages]: CLOUDFLARE_PAGES_SYNC_LIST_OPTION,
|
||||
[SecretSync.CloudflareWorkers]: CLOUDFLARE_WORKERS_SYNC_LIST_OPTION,
|
||||
|
||||
[SecretSync.Supabase]: SUPABASE_SYNC_LIST_OPTION,
|
||||
[SecretSync.Zabbix]: ZABBIX_SYNC_LIST_OPTION,
|
||||
[SecretSync.Railway]: RAILWAY_SYNC_LIST_OPTION,
|
||||
[SecretSync.Checkly]: CHECKLY_SYNC_LIST_OPTION
|
||||
@ -255,6 +256,8 @@ export const SecretSyncFns = {
|
||||
return RailwaySyncFns.syncSecrets(secretSync, schemaSecretMap);
|
||||
case SecretSync.Checkly:
|
||||
return ChecklySyncFns.syncSecrets(secretSync, schemaSecretMap);
|
||||
case SecretSync.Supabase:
|
||||
return SupabaseSyncFns.syncSecrets(secretSync, schemaSecretMap);
|
||||
default:
|
||||
throw new Error(
|
||||
`Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
|
||||
@ -359,6 +362,9 @@ export const SecretSyncFns = {
|
||||
case SecretSync.Checkly:
|
||||
secretMap = await ChecklySyncFns.getSecrets(secretSync);
|
||||
break;
|
||||
case SecretSync.Supabase:
|
||||
secretMap = await SupabaseSyncFns.getSecrets(secretSync);
|
||||
break;
|
||||
default:
|
||||
throw new Error(
|
||||
`Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
|
||||
@ -444,6 +450,8 @@ export const SecretSyncFns = {
|
||||
return RailwaySyncFns.removeSecrets(secretSync, schemaSecretMap);
|
||||
case SecretSync.Checkly:
|
||||
return ChecklySyncFns.removeSecrets(secretSync, schemaSecretMap);
|
||||
case SecretSync.Supabase:
|
||||
return SupabaseSyncFns.removeSecrets(secretSync, schemaSecretMap);
|
||||
default:
|
||||
throw new Error(
|
||||
`Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
|
||||
|
@ -25,7 +25,7 @@ export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
|
||||
[SecretSync.GitLab]: "GitLab",
|
||||
[SecretSync.CloudflarePages]: "Cloudflare Pages",
|
||||
[SecretSync.CloudflareWorkers]: "Cloudflare Workers",
|
||||
|
||||
[SecretSync.Supabase]: "Supabase",
|
||||
[SecretSync.Zabbix]: "Zabbix",
|
||||
[SecretSync.Railway]: "Railway",
|
||||
[SecretSync.Checkly]: "Checkly"
|
||||
@ -55,7 +55,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
|
||||
[SecretSync.GitLab]: AppConnection.GitLab,
|
||||
[SecretSync.CloudflarePages]: AppConnection.Cloudflare,
|
||||
[SecretSync.CloudflareWorkers]: AppConnection.Cloudflare,
|
||||
|
||||
[SecretSync.Supabase]: AppConnection.Supabase,
|
||||
[SecretSync.Zabbix]: AppConnection.Zabbix,
|
||||
[SecretSync.Railway]: AppConnection.Railway,
|
||||
[SecretSync.Checkly]: AppConnection.Checkly
|
||||
@ -85,7 +85,7 @@ export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
|
||||
[SecretSync.GitLab]: SecretSyncPlanType.Regular,
|
||||
[SecretSync.CloudflarePages]: SecretSyncPlanType.Regular,
|
||||
[SecretSync.CloudflareWorkers]: SecretSyncPlanType.Regular,
|
||||
|
||||
[SecretSync.Supabase]: SecretSyncPlanType.Regular,
|
||||
[SecretSync.Zabbix]: SecretSyncPlanType.Regular,
|
||||
[SecretSync.Railway]: SecretSyncPlanType.Regular,
|
||||
[SecretSync.Checkly]: SecretSyncPlanType.Regular
|
||||
|
@ -231,7 +231,8 @@ export const secretSyncQueueFactory = ({
|
||||
environment: environment.slug,
|
||||
secretPath: folder.path,
|
||||
skipMultilineEncoding: secret.skipMultilineEncoding,
|
||||
value: secretValue
|
||||
value: secretValue,
|
||||
secretKey
|
||||
});
|
||||
secretMap[secretKey] = { value: expandedSecretValue || "" };
|
||||
|
||||
|
@ -118,6 +118,12 @@ import {
|
||||
TRenderSyncListItem,
|
||||
TRenderSyncWithCredentials
|
||||
} from "./render/render-sync-types";
|
||||
import {
|
||||
TSupabaseSync,
|
||||
TSupabaseSyncInput,
|
||||
TSupabaseSyncListItem,
|
||||
TSupabaseSyncWithCredentials
|
||||
} from "./supabase/supabase-sync-types";
|
||||
import {
|
||||
TTeamCitySync,
|
||||
TTeamCitySyncInput,
|
||||
@ -159,7 +165,8 @@ export type TSecretSync =
|
||||
| TCloudflareWorkersSync
|
||||
| TZabbixSync
|
||||
| TRailwaySync
|
||||
| TChecklySync;
|
||||
| TChecklySync
|
||||
| TSupabaseSync;
|
||||
|
||||
export type TSecretSyncWithCredentials =
|
||||
| TAwsParameterStoreSyncWithCredentials
|
||||
@ -187,7 +194,8 @@ export type TSecretSyncWithCredentials =
|
||||
| TCloudflareWorkersSyncWithCredentials
|
||||
| TZabbixSyncWithCredentials
|
||||
| TRailwaySyncWithCredentials
|
||||
| TChecklySyncWithCredentials;
|
||||
| TChecklySyncWithCredentials
|
||||
| TSupabaseSyncWithCredentials;
|
||||
|
||||
export type TSecretSyncInput =
|
||||
| TAwsParameterStoreSyncInput
|
||||
@ -215,7 +223,8 @@ export type TSecretSyncInput =
|
||||
| TCloudflareWorkersSyncInput
|
||||
| TZabbixSyncInput
|
||||
| TRailwaySyncInput
|
||||
| TChecklySyncInput;
|
||||
| TChecklySyncInput
|
||||
| TSupabaseSyncInput;
|
||||
|
||||
export type TSecretSyncListItem =
|
||||
| TAwsParameterStoreSyncListItem
|
||||
@ -243,7 +252,8 @@ export type TSecretSyncListItem =
|
||||
| TCloudflareWorkersSyncListItem
|
||||
| TZabbixSyncListItem
|
||||
| TRailwaySyncListItem
|
||||
| TChecklySyncListItem;
|
||||
| TChecklySyncListItem
|
||||
| TSupabaseSyncListItem;
|
||||
|
||||
export type TSyncOptionsConfig = {
|
||||
canImportSecrets: boolean;
|
||||
|
4
backend/src/services/secret-sync/supabase/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./supabase-sync-constants";
|
||||
export * from "./supabase-sync-fns";
|
||||
export * from "./supabase-sync-schemas";
|
||||
export * from "./supabase-sync-types";
|
@ -0,0 +1,10 @@
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||
import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types";
|
||||
|
||||
export const SUPABASE_SYNC_LIST_OPTION: TSecretSyncListItem = {
|
||||
name: "Supabase",
|
||||
destination: SecretSync.Supabase,
|
||||
connection: AppConnection.Supabase,
|
||||
canImportSecrets: false
|
||||
};
|
102
backend/src/services/secret-sync/supabase/supabase-sync-fns.ts
Normal file
@ -0,0 +1,102 @@
|
||||
/* eslint-disable no-continue */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
|
||||
import { chunkArray } from "@app/lib/fn";
|
||||
import { TSupabaseSecret } from "@app/services/app-connection/supabase";
|
||||
import { SupabasePublicAPI } from "@app/services/app-connection/supabase/supabase-connection-public-client";
|
||||
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
|
||||
|
||||
import { SecretSyncError } from "../secret-sync-errors";
|
||||
import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps";
|
||||
import { TSecretMap } from "../secret-sync-types";
|
||||
import { TSupabaseSyncWithCredentials } from "./supabase-sync-types";
|
||||
|
||||
const SUPABASE_INTERNAL_SECRETS = ["SUPABASE_URL", "SUPABASE_ANON_KEY", "SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_DB_URL"];
|
||||
|
||||
export const SupabaseSyncFns = {
|
||||
async getSecrets(secretSync: TSupabaseSyncWithCredentials) {
|
||||
throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`);
|
||||
},
|
||||
|
||||
async syncSecrets(secretSync: TSupabaseSyncWithCredentials, secretMap: TSecretMap) {
|
||||
const {
|
||||
environment,
|
||||
syncOptions: { disableSecretDeletion, keySchema }
|
||||
} = secretSync;
|
||||
const config = secretSync.destinationConfig;
|
||||
|
||||
const variables = await SupabasePublicAPI.getVariables(secretSync.connection, config.projectId);
|
||||
|
||||
const supabaseSecrets = new Map(variables!.map((variable) => [variable.name, variable]));
|
||||
|
||||
const toCreate: TSupabaseSecret[] = [];
|
||||
|
||||
for (const key of Object.keys(secretMap)) {
|
||||
const variable: TSupabaseSecret = { name: key, value: secretMap[key].value ?? "" };
|
||||
toCreate.push(variable);
|
||||
}
|
||||
|
||||
for await (const batch of chunkArray(toCreate, 100)) {
|
||||
try {
|
||||
await SupabasePublicAPI.createVariables(secretSync.connection, config.projectId, ...batch);
|
||||
} catch (error) {
|
||||
throw new SecretSyncError({
|
||||
error,
|
||||
secretKey: batch[0].name // Use the first key in the batch for error reporting
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (disableSecretDeletion) return;
|
||||
|
||||
const toDelete: string[] = [];
|
||||
|
||||
for (const key of supabaseSecrets.keys()) {
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!matchesSchema(key, environment?.slug || "", keySchema) || SUPABASE_INTERNAL_SECRETS.includes(key)) continue;
|
||||
|
||||
if (!secretMap[key]) {
|
||||
toDelete.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
for await (const batch of chunkArray(toDelete, 100)) {
|
||||
try {
|
||||
await SupabasePublicAPI.deleteVariables(secretSync.connection, config.projectId, ...batch);
|
||||
} catch (error) {
|
||||
throw new SecretSyncError({
|
||||
error,
|
||||
secretKey: batch[0] // Use the first key in the batch for error reporting
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async removeSecrets(secretSync: TSupabaseSyncWithCredentials, secretMap: TSecretMap) {
|
||||
const config = secretSync.destinationConfig;
|
||||
|
||||
const variables = await SupabasePublicAPI.getVariables(secretSync.connection, config.projectId);
|
||||
|
||||
const supabaseSecrets = new Map(variables!.map((variable) => [variable.name, variable]));
|
||||
|
||||
const toDelete: string[] = [];
|
||||
|
||||
for (const key of supabaseSecrets.keys()) {
|
||||
if (SUPABASE_INTERNAL_SECRETS.includes(key) || !(key in secretMap)) continue;
|
||||
|
||||
toDelete.push(key);
|
||||
}
|
||||
|
||||
for await (const batch of chunkArray(toDelete, 100)) {
|
||||
try {
|
||||
await SupabasePublicAPI.deleteVariables(secretSync.connection, config.projectId, ...batch);
|
||||
} catch (error) {
|
||||
throw new SecretSyncError({
|
||||
error,
|
||||
secretKey: batch[0] // Use the first key in the batch for error reporting
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
@ -0,0 +1,43 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||
import {
|
||||
BaseSecretSyncSchema,
|
||||
GenericCreateSecretSyncFieldsSchema,
|
||||
GenericUpdateSecretSyncFieldsSchema
|
||||
} from "@app/services/secret-sync/secret-sync-schemas";
|
||||
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
|
||||
|
||||
const SupabaseSyncDestinationConfigSchema = z.object({
|
||||
projectId: z.string().max(255).min(1, "Project ID is required"),
|
||||
projectName: z.string().max(255).min(1, "Project Name is required")
|
||||
});
|
||||
|
||||
const SupabaseSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false };
|
||||
|
||||
export const SupabaseSyncSchema = BaseSecretSyncSchema(SecretSync.Supabase, SupabaseSyncOptionsConfig).extend({
|
||||
destination: z.literal(SecretSync.Supabase),
|
||||
destinationConfig: SupabaseSyncDestinationConfigSchema
|
||||
});
|
||||
|
||||
export const CreateSupabaseSyncSchema = GenericCreateSecretSyncFieldsSchema(
|
||||
SecretSync.Supabase,
|
||||
SupabaseSyncOptionsConfig
|
||||
).extend({
|
||||
destinationConfig: SupabaseSyncDestinationConfigSchema
|
||||
});
|
||||
|
||||
export const UpdateSupabaseSyncSchema = GenericUpdateSecretSyncFieldsSchema(
|
||||
SecretSync.Supabase,
|
||||
SupabaseSyncOptionsConfig
|
||||
).extend({
|
||||
destinationConfig: SupabaseSyncDestinationConfigSchema.optional()
|
||||
});
|
||||
|
||||
export const SupabaseSyncListItemSchema = z.object({
|
||||
name: z.literal("Supabase"),
|
||||
connection: z.literal(AppConnection.Supabase),
|
||||
destination: z.literal(SecretSync.Supabase),
|
||||
canImportSecrets: z.literal(false)
|
||||
});
|
@ -0,0 +1,21 @@
|
||||
import z from "zod";
|
||||
|
||||
import { TSupabaseConnection } from "@app/services/app-connection/supabase";
|
||||
|
||||
import { CreateSupabaseSyncSchema, SupabaseSyncListItemSchema, SupabaseSyncSchema } from "./supabase-sync-schemas";
|
||||
|
||||
export type TSupabaseSyncListItem = z.infer<typeof SupabaseSyncListItemSchema>;
|
||||
|
||||
export type TSupabaseSync = z.infer<typeof SupabaseSyncSchema>;
|
||||
|
||||
export type TSupabaseSyncInput = z.infer<typeof CreateSupabaseSyncSchema>;
|
||||
|
||||
export type TSupabaseSyncWithCredentials = TSupabaseSync & {
|
||||
connection: TSupabaseConnection;
|
||||
};
|
||||
|
||||
export type TSupabaseVariablesGraphResponse = {
|
||||
data: {
|
||||
variables: Record<string, string>;
|
||||
};
|
||||
};
|
@ -67,6 +67,7 @@ export const getAllSecretReferences = (maybeSecretReference: string) => {
|
||||
export const fnSecretBulkInsert = async ({
|
||||
// TODO: Pick types here
|
||||
folderId,
|
||||
commitChanges,
|
||||
orgId,
|
||||
inputSecrets,
|
||||
secretDAL,
|
||||
@ -134,14 +135,17 @@ export const fnSecretBulkInsert = async ({
|
||||
tx
|
||||
);
|
||||
|
||||
const commitChanges = secretVersions
|
||||
const changes = secretVersions
|
||||
.filter(({ type }) => type === SecretType.Shared)
|
||||
.map((sv) => ({
|
||||
type: CommitType.ADD,
|
||||
secretVersionId: sv.id
|
||||
}));
|
||||
|
||||
if (commitChanges.length > 0) {
|
||||
if (changes.length > 0) {
|
||||
if (commitChanges) {
|
||||
commitChanges.push(...changes);
|
||||
} else {
|
||||
await folderCommitService.createCommit(
|
||||
{
|
||||
actor: {
|
||||
@ -152,11 +156,12 @@ export const fnSecretBulkInsert = async ({
|
||||
},
|
||||
message: "Secret Created",
|
||||
folderId,
|
||||
changes: commitChanges
|
||||
changes
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await secretDAL.upsertSecretReferences(
|
||||
inputSecrets.map(({ references = [], key }) => ({
|
||||
@ -209,6 +214,7 @@ export const fnSecretBulkUpdate = async ({
|
||||
tx,
|
||||
inputSecrets,
|
||||
folderId,
|
||||
commitChanges,
|
||||
orgId,
|
||||
secretDAL,
|
||||
secretVersionDAL,
|
||||
@ -359,14 +365,17 @@ export const fnSecretBulkUpdate = async ({
|
||||
{ tx }
|
||||
);
|
||||
|
||||
const commitChanges = secretVersions
|
||||
const changes = secretVersions
|
||||
.filter(({ type }) => type === SecretType.Shared)
|
||||
.map((sv) => ({
|
||||
type: CommitType.ADD,
|
||||
isUpdate: true,
|
||||
secretVersionId: sv.id
|
||||
}));
|
||||
if (commitChanges.length > 0) {
|
||||
if (changes.length > 0) {
|
||||
if (commitChanges) {
|
||||
commitChanges.push(...changes);
|
||||
} else {
|
||||
await folderCommitService.createCommit(
|
||||
{
|
||||
actor: {
|
||||
@ -377,11 +386,12 @@ export const fnSecretBulkUpdate = async ({
|
||||
},
|
||||
message: "Secret Updated",
|
||||
folderId,
|
||||
changes: commitChanges
|
||||
changes
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return secretsWithTags.map((secret) => ({ ...secret, _id: secret.id }));
|
||||
};
|
||||
@ -395,7 +405,8 @@ export const fnSecretBulkDelete = async ({
|
||||
secretDAL,
|
||||
secretQueueService,
|
||||
folderCommitService,
|
||||
secretVersionDAL
|
||||
secretVersionDAL,
|
||||
commitChanges
|
||||
}: TFnSecretBulkDelete) => {
|
||||
const deletedSecrets = await secretDAL.deleteMany(
|
||||
inputSecrets.map(({ type, secretKey }) => ({
|
||||
@ -421,13 +432,16 @@ export const fnSecretBulkDelete = async ({
|
||||
tx
|
||||
);
|
||||
|
||||
const commitChanges = deletedSecrets
|
||||
const changes = deletedSecrets
|
||||
.filter(({ type }) => type === SecretType.Shared)
|
||||
.map(({ id }) => ({
|
||||
type: CommitType.DELETE,
|
||||
secretVersionId: secretVersions[id].id
|
||||
}));
|
||||
if (commitChanges.length > 0) {
|
||||
if (changes.length > 0) {
|
||||
if (commitChanges) {
|
||||
commitChanges.push(...changes);
|
||||
} else {
|
||||
await folderCommitService.createCommit(
|
||||
{
|
||||
actor: {
|
||||
@ -438,11 +452,12 @@ export const fnSecretBulkDelete = async ({
|
||||
},
|
||||
message: "Secret Deleted",
|
||||
folderId,
|
||||
changes: commitChanges
|
||||
changes
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return deletedSecrets;
|
||||
};
|
||||
@ -614,6 +629,7 @@ export const expandSecretReferencesFactory = ({
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
shouldStackTrace?: boolean;
|
||||
secretKey: string;
|
||||
}) => {
|
||||
const stackTrace = { ...dto, key: "root", children: [] } as TSecretReferenceTraceNode;
|
||||
|
||||
@ -656,7 +672,7 @@ export const expandSecretReferencesFactory = ({
|
||||
const referredValue = await fetchSecret(environment, secretPath, secretKey);
|
||||
if (!canExpandValue(environment, secretPath, secretKey, referredValue.tags))
|
||||
throw new ForbiddenRequestError({
|
||||
message: `You are attempting to reference secret named ${secretKey} from environment ${environment} in path ${secretPath} which you do not have access to read value on.`
|
||||
message: `You do not have permission to read secret '${secretKey}' in environment '${environment}' at path '${secretPath}', which is referenced by secret '${dto.secretKey}' in environment '${dto.environment}' at path '${dto.secretPath}'.`
|
||||
});
|
||||
|
||||
const cacheKey = getCacheUniqueKey(environment, secretPath);
|
||||
@ -675,7 +691,7 @@ export const expandSecretReferencesFactory = ({
|
||||
const referedValue = await fetchSecret(secretReferenceEnvironment, secretReferencePath, secretReferenceKey);
|
||||
if (!canExpandValue(secretReferenceEnvironment, secretReferencePath, secretReferenceKey, referedValue.tags))
|
||||
throw new ForbiddenRequestError({
|
||||
message: `You are attempting to reference secret named ${secretReferenceKey} from environment ${secretReferenceEnvironment} in path ${secretReferencePath} which you do not have access to read value on.`
|
||||
message: `You do not have permission to read secret '${secretReferenceKey}' in environment '${secretReferenceEnvironment}' at path '${secretReferencePath}', which is referenced by secret '${dto.secretKey}' in environment '${dto.environment}' at path '${dto.secretPath}'.`
|
||||
});
|
||||
|
||||
const cacheKey = getCacheUniqueKey(secretReferenceEnvironment, secretReferencePath);
|
||||
@ -692,6 +708,7 @@ export const expandSecretReferencesFactory = ({
|
||||
secretPath: referencedSecretPath,
|
||||
environment: referencedSecretEnvironmentSlug,
|
||||
depth: depth + 1,
|
||||
secretKey: referencedSecretKey,
|
||||
trace
|
||||
};
|
||||
|
||||
@ -726,6 +743,7 @@ export const expandSecretReferencesFactory = ({
|
||||
skipMultilineEncoding?: boolean | null;
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
secretKey: string;
|
||||
}) => {
|
||||
if (!inputSecret.value) return inputSecret.value;
|
||||
|
||||
@ -741,6 +759,7 @@ export const expandSecretReferencesFactory = ({
|
||||
value?: string;
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
secretKey: string;
|
||||
}) => {
|
||||
const { stackTrace, expandedValue } = await recursivelyExpandSecret({ ...inputSecret, shouldStackTrace: true });
|
||||
return { stackTrace, expandedValue };
|
||||
|
@ -28,7 +28,7 @@ import { logger } from "@app/lib/logger";
|
||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||
|
||||
import { ActorType } from "../auth/auth-type";
|
||||
import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
|
||||
import { TCommitResourceChangeDTO, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
|
||||
import { TKmsServiceFactory } from "../kms/kms-service";
|
||||
import { KmsDataKey } from "../kms/kms-types";
|
||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||
@ -1105,7 +1105,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
|
||||
if (shouldExpandSecretReferences) {
|
||||
const secretsGroupByPath = groupBy(decryptedSecrets, (i) => i.secretPath);
|
||||
await Promise.allSettled(
|
||||
const settledPromises = await Promise.allSettled(
|
||||
Object.keys(secretsGroupByPath).map((groupedPath) =>
|
||||
Promise.allSettled(
|
||||
secretsGroupByPath[groupedPath].map(async (decryptedSecret, index) => {
|
||||
@ -1113,7 +1113,8 @@ export const secretV2BridgeServiceFactory = ({
|
||||
value: decryptedSecret.secretValue,
|
||||
secretPath: groupedPath,
|
||||
environment,
|
||||
skipMultilineEncoding: decryptedSecret.skipMultilineEncoding
|
||||
skipMultilineEncoding: decryptedSecret.skipMultilineEncoding,
|
||||
secretKey: decryptedSecret.secretKey
|
||||
});
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
secretsGroupByPath[groupedPath][index].secretValue = expandedSecretValue || "";
|
||||
@ -1121,6 +1122,35 @@ export const secretV2BridgeServiceFactory = ({
|
||||
)
|
||||
)
|
||||
);
|
||||
const errors: { path: string; error: string }[] = [];
|
||||
|
||||
settledPromises.forEach((outerResult: PromiseSettledResult<PromiseSettledResult<void>[]>, outerIndex) => {
|
||||
const groupedPath = Object.keys(secretsGroupByPath)[outerIndex];
|
||||
|
||||
if (outerResult.status === "rejected") {
|
||||
errors.push({
|
||||
path: groupedPath,
|
||||
error: `Failed to process secret group: ${outerResult.reason}`
|
||||
});
|
||||
} else {
|
||||
// Check inner promise results
|
||||
outerResult.value.forEach((innerResult: PromiseSettledResult<void>) => {
|
||||
if (innerResult.status === "rejected") {
|
||||
const reason = innerResult.reason as ForbiddenRequestError;
|
||||
errors.push({
|
||||
path: groupedPath,
|
||||
error: reason.message
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
if (errors.length > 0) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Failed to expand one or more secret references",
|
||||
details: errors.map((err) => err.error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!includeImports) {
|
||||
@ -1424,7 +1454,8 @@ export const secretV2BridgeServiceFactory = ({
|
||||
environment,
|
||||
secretPath: path,
|
||||
value: secretValue,
|
||||
skipMultilineEncoding: secret.skipMultilineEncoding
|
||||
skipMultilineEncoding: secret.skipMultilineEncoding,
|
||||
secretKey: secret.key
|
||||
});
|
||||
|
||||
secretValue = expandedSecretValue || "";
|
||||
@ -1474,8 +1505,10 @@ export const secretV2BridgeServiceFactory = ({
|
||||
actorOrgId,
|
||||
environment,
|
||||
projectId,
|
||||
secrets: inputSecrets
|
||||
}: TCreateManySecretDTO) => {
|
||||
secrets: inputSecrets,
|
||||
tx: providedTx,
|
||||
commitChanges
|
||||
}: TCreateManySecretDTO & { tx?: Knex; commitChanges?: TCommitResourceChangeDTO[] }) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
@ -1558,8 +1591,8 @@ export const secretV2BridgeServiceFactory = ({
|
||||
const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } =
|
||||
await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId });
|
||||
|
||||
const newSecrets = await secretDAL.transaction(async (tx) =>
|
||||
fnSecretBulkInsert({
|
||||
const executeBulkInsert = async (tx: Knex) => {
|
||||
return fnSecretBulkInsert({
|
||||
inputSecrets: inputSecrets.map((el) => {
|
||||
const references = secretReferencesGroupByInputSecretKey[el.secretKey]?.nestedReferences;
|
||||
|
||||
@ -1581,6 +1614,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
};
|
||||
}),
|
||||
folderId,
|
||||
commitChanges,
|
||||
orgId: actorOrgId,
|
||||
secretDAL,
|
||||
resourceMetadataDAL,
|
||||
@ -1593,8 +1627,13 @@ export const secretV2BridgeServiceFactory = ({
|
||||
actorId
|
||||
},
|
||||
tx
|
||||
})
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const newSecrets = providedTx
|
||||
? await executeBulkInsert(providedTx)
|
||||
: await secretDAL.transaction(executeBulkInsert);
|
||||
|
||||
await secretDAL.invalidateSecretCacheByProjectId(projectId);
|
||||
await snapshotService.performSnapshot(folderId);
|
||||
await secretQueueService.syncSecrets({
|
||||
@ -1641,8 +1680,10 @@ export const secretV2BridgeServiceFactory = ({
|
||||
projectId,
|
||||
secretPath: defaultSecretPath = "/",
|
||||
secrets: inputSecrets,
|
||||
mode: updateMode
|
||||
}: TUpdateManySecretDTO) => {
|
||||
mode: updateMode,
|
||||
tx: providedTx,
|
||||
commitChanges
|
||||
}: TUpdateManySecretDTO & { tx?: Knex; commitChanges?: TCommitResourceChangeDTO[] }) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
@ -1671,6 +1712,8 @@ export const secretV2BridgeServiceFactory = ({
|
||||
const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } =
|
||||
await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId });
|
||||
|
||||
// Function to execute the bulk update operation
|
||||
const executeBulkUpdate = async (tx: Knex) => {
|
||||
const updatedSecrets: Array<
|
||||
TSecretsV2 & {
|
||||
secretPath: string;
|
||||
@ -1682,7 +1725,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
}[];
|
||||
}
|
||||
> = [];
|
||||
await secretDAL.transaction(async (tx) => {
|
||||
|
||||
for await (const folder of folders) {
|
||||
if (!folder) throw new NotFoundError({ message: "Folder not found" });
|
||||
|
||||
@ -1801,7 +1844,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
{
|
||||
operator: "eq",
|
||||
field: `${TableName.SecretV2}.key` as "key",
|
||||
value: el.secretKey
|
||||
value: el.newSecretName as string
|
||||
},
|
||||
{
|
||||
operator: "eq",
|
||||
@ -1855,6 +1898,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
orgId: actorOrgId,
|
||||
folderCommitService,
|
||||
tx,
|
||||
commitChanges,
|
||||
inputSecrets: secretsToUpdate.map((el) => {
|
||||
const originalSecret = secretsToUpdateInDBGroupedByKey[el.secretKey][0];
|
||||
const encryptedValue =
|
||||
@ -1934,7 +1978,13 @@ export const secretV2BridgeServiceFactory = ({
|
||||
updatedSecrets.push(...bulkInsertedSecrets.map((el) => ({ ...el, secretPath: folder.path })));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return updatedSecrets;
|
||||
};
|
||||
|
||||
const updatedSecrets = providedTx
|
||||
? await executeBulkUpdate(providedTx)
|
||||
: await secretDAL.transaction(executeBulkUpdate);
|
||||
|
||||
await secretDAL.invalidateSecretCacheByProjectId(projectId);
|
||||
await Promise.allSettled(folders.map((el) => (el?.id ? snapshotService.performSnapshot(el.id) : undefined)));
|
||||
@ -1991,8 +2041,10 @@ export const secretV2BridgeServiceFactory = ({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: TDeleteManySecretDTO) => {
|
||||
actorOrgId,
|
||||
tx: providedTx,
|
||||
commitChanges
|
||||
}: TDeleteManySecretDTO & { tx?: Knex; commitChanges?: TCommitResourceChangeDTO[] }) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
@ -2051,9 +2103,8 @@ export const secretV2BridgeServiceFactory = ({
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
const secretsDeleted = await secretDAL.transaction(async (tx) =>
|
||||
fnSecretBulkDelete({
|
||||
const executeBulkDelete = async (tx: Knex) => {
|
||||
return fnSecretBulkDelete({
|
||||
secretDAL,
|
||||
secretQueueService,
|
||||
folderCommitService,
|
||||
@ -2066,9 +2117,15 @@ export const secretV2BridgeServiceFactory = ({
|
||||
folderId,
|
||||
actorId,
|
||||
actorType: actor,
|
||||
commitChanges,
|
||||
tx
|
||||
})
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const secretsDeleted = providedTx
|
||||
? await executeBulkDelete(providedTx)
|
||||
: await secretDAL.transaction(executeBulkDelete);
|
||||
|
||||
await secretDAL.invalidateSecretCacheByProjectId(projectId);
|
||||
await snapshotService.performSnapshot(folderId);
|
||||
@ -2722,7 +2779,8 @@ export const secretV2BridgeServiceFactory = ({
|
||||
const { expandedValue, stackTrace } = await getExpandedSecretStackTrace({
|
||||
environment,
|
||||
secretPath,
|
||||
value: decryptedSecretValue
|
||||
value: decryptedSecretValue,
|
||||
secretKey: secretName
|
||||
});
|
||||
|
||||
return { tree: stackTrace, value: expandedValue };
|
||||
|
@ -8,7 +8,7 @@ import { SecretsOrderBy } from "@app/services/secret/secret-types";
|
||||
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
|
||||
|
||||
import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
|
||||
import { TCommitResourceChangeDTO, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
|
||||
import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal";
|
||||
import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema";
|
||||
import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal";
|
||||
@ -167,6 +167,7 @@ export type TFnSecretBulkInsert = {
|
||||
folderId: string;
|
||||
orgId: string;
|
||||
tx?: Knex;
|
||||
commitChanges?: TCommitResourceChangeDTO[];
|
||||
inputSecrets: Array<
|
||||
Omit<TSecretsV2Insert, "folderId"> & {
|
||||
tagIds?: string[];
|
||||
@ -214,6 +215,7 @@ export type TFnSecretBulkUpdate = {
|
||||
actorId?: string;
|
||||
};
|
||||
tx?: Knex;
|
||||
commitChanges?: TCommitResourceChangeDTO[];
|
||||
};
|
||||
|
||||
export type TFnSecretBulkDelete = {
|
||||
@ -223,6 +225,7 @@ export type TFnSecretBulkDelete = {
|
||||
actorId: string;
|
||||
actorType?: string;
|
||||
tx?: Knex;
|
||||
commitChanges?: TCommitResourceChangeDTO[];
|
||||
secretDAL: Pick<TSecretV2BridgeDALFactory, "deleteMany">;
|
||||
secretQueueService: {
|
||||
removeSecretReminder: (data: TRemoveSecretReminderDTO, tx?: Knex) => Promise<void>;
|
||||
|
@ -426,7 +426,8 @@ export const secretQueueFactory = ({
|
||||
environment: dto.environment,
|
||||
secretPath: dto.secretPath,
|
||||
skipMultilineEncoding: secret.skipMultilineEncoding,
|
||||
value: secretValue
|
||||
value: secretValue,
|
||||
secretKey
|
||||
});
|
||||
content[secretKey] = { value: expandedSecretValue || "" };
|
||||
|
||||
|
@ -544,3 +544,33 @@ export enum SecretProtectionType {
|
||||
}
|
||||
|
||||
export type TStartSecretsV2MigrationDTO = TProjectPermission;
|
||||
|
||||
export type TProcessNewCommitRawDTO = {
|
||||
secrets: {
|
||||
create?: {
|
||||
secretKey: string;
|
||||
secretValue: string;
|
||||
secretComment?: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
tagIds?: string[];
|
||||
secretMetadata?: ResourceMetadataDTO;
|
||||
metadata?: { source?: string };
|
||||
}[];
|
||||
update?: {
|
||||
secretKey: string;
|
||||
newSecretName?: string;
|
||||
secretValue?: string;
|
||||
secretComment?: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
tagIds?: string[];
|
||||
secretMetadata?: ResourceMetadataDTO;
|
||||
metadata?: { source?: string };
|
||||
}[];
|
||||
delete?: { secretKey: string }[];
|
||||
};
|
||||
folders: {
|
||||
create?: { folderName: string; description?: string }[];
|
||||
update?: { folderName: string; description?: string | null; id: string }[];
|
||||
delete?: { folderName: string; id: string }[];
|
||||
};
|
||||
};
|
||||
|
@ -33,6 +33,7 @@ Every feature/problem is unique, but your design docs should generally include t
|
||||
- A high-level summary of the problem and proposed solution. Keep it brief (max 3 paragraphs).
|
||||
3. **Context**
|
||||
- Explain the problem's background, why it's important to solve now, and any constraints (e.g., technical, sales, or timeline-related). What do we get out of solving this problem? (needed to close a deal, scale, performance, etc.).
|
||||
- Consider whether this feature has notable sales implications (e.g., affects pricing, customer commitments, go-to-market strategy, or competitive positioning) that would require Sales team input and approval.
|
||||
4. **Solution**
|
||||
|
||||
- Provide a big-picture explanation of the solution, followed by detailed technical architecture.
|
||||
@ -76,3 +77,11 @@ Before sharing your design docs with others, review your design doc as if you we
|
||||
- Ask a relevant engineer(s) to review your document. Their role is to identify blind spots, challenge assumptions, and ensure everything is clear. Once you and the reviewer are on the same page on the approach, update the document with any missing details they brought up.
|
||||
4. **Team Review and Feedback**
|
||||
- Invite the relevant engineers to a design doc review meeting and give them 10-15 minutes to read through the document. After everyone has had a chance to review it, open the floor up for discussion. Address any feedback or concerns raised during this meeting. If significant points were overlooked during your initial planning, you may need to revisit the drawing board. Your goal is to think about the feature holistically and minimize the need for drastic changes to your design doc later on.
|
||||
5. **Sales Approval (When Applicable)**
|
||||
- If your design document has notable sales implications, get explicit approval from the Sales team before proceeding to implementation. This includes features that:
|
||||
- Affect pricing models or billing structures
|
||||
- Impact customer commitments or contractual obligations
|
||||
- Change core product functionality that's actively being sold
|
||||
- Introduce new capabilities that could affect competitive positioning
|
||||
- Modify user experience in ways that could impact customer acquisition or retention
|
||||
- Share the design document with the Sales team to ensure alignment between the proposed technical approach and sales strategy, pricing models, and market positioning.
|
||||
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Available"
|
||||
openapi: "GET /api/v1/app-connections/supabase/available"
|
||||
---
|
@ -0,0 +1,8 @@
|
||||
---
|
||||
title: "Create"
|
||||
openapi: "POST /api/v1/app-connections/supabase"
|
||||
---
|
||||
|
||||
<Note>
|
||||
Check out the configuration docs for [Supabase Connections](/integrations/app-connections/supabase) to learn how to obtain the required credentials.
|
||||
</Note>
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Delete"
|
||||
openapi: "DELETE /api/v1/app-connections/supabase/{connectionId}"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Get by ID"
|
||||
openapi: "GET /api/v1/app-connections/supabase/{connectionId}"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Get by Name"
|
||||
openapi: "GET /api/v1/app-connections/supabase/connection-name/{connectionName}"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "List"
|
||||
openapi: "GET /api/v1/app-connections/supabase"
|
||||
---
|
@ -0,0 +1,8 @@
|
||||
---
|
||||
title: "Update"
|
||||
openapi: "PATCH /api/v1/app-connections/supabase/{connectionId}"
|
||||
---
|
||||
|
||||
<Note>
|
||||
Check out the configuration docs for [Supabase Connections](/integrations/app-connections/supabase) to learn how to obtain the required credentials.
|
||||
</Note>
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Create"
|
||||
openapi: "POST /api/v1/secret-syncs/supabase"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Delete"
|
||||
openapi: "DELETE /api/v1/secret-syncs/supabase/{syncId}"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Get by ID"
|
||||
openapi: "GET /api/v1/secret-syncs/supabase/{syncId}"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Get by Name"
|
||||
openapi: "GET /api/v1/secret-syncs/supabase/sync-name/{syncName}"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "List"
|
||||
openapi: "GET /api/v1/secret-syncs/supabase"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Remove Secrets"
|
||||
openapi: "POST /api/v1/secret-syncs/supabase/{syncId}/remove-secrets"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Sync Secrets"
|
||||
openapi: "POST /api/v1/secret-syncs/supabase/{syncId}/sync-secrets"
|
||||
---
|
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Update"
|
||||
openapi: "PATCH /api/v1/secret-syncs/supabase/{syncId}"
|
||||
---
|
@ -2,3 +2,8 @@
|
||||
title: "Login"
|
||||
openapi: "POST /api/v1/auth/tls-cert-auth/login"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
Infisical US/EU and dedicated instances are deployed with AWS ALB. TLS Certificate Auth must flow through our ALB mTLS pass-through in order to authenticate.
|
||||
When you are authenticating with TLS Certificate Auth, you must use the port `8443` instead of the default `443`. Example: `https://app.infisical.com:8443/api/v1/auth/tls-cert-auth/login`
|
||||
</Warning>
|
@ -9,7 +9,7 @@ infisical export [options]
|
||||
|
||||
## Description
|
||||
|
||||
Export environment variables from the platform into a file format.
|
||||
Export environment variables from the platform into a file format. By default, output is sent to stdout (standard output), but you can use the `--output-file` flag to save directly to a file.
|
||||
|
||||
## Subcommands & flags
|
||||
|
||||
@ -21,18 +21,19 @@ $ infisical export
|
||||
|
||||
# Export variables to a .env file
|
||||
infisical export > .env
|
||||
infisical export --output-file=./.env
|
||||
|
||||
# Export variables to a .env file (with export keyword)
|
||||
infisical export --format=dotenv-export > .env
|
||||
|
||||
# Export variables to a CSV file
|
||||
infisical export --format=csv > secrets.csv
|
||||
infisical export --format=dotenv-export --output-file=./.env
|
||||
|
||||
# Export variables to a JSON file
|
||||
infisical export --format=json > secrets.json
|
||||
infisical export --format=json --output-file=./secrets.json
|
||||
|
||||
# Export variables to a YAML file
|
||||
infisical export --format=yaml > secrets.yaml
|
||||
infisical export --format=yaml --output-file=./secrets.yaml
|
||||
|
||||
# Render secrets using a custom template file
|
||||
infisical export --template=<path to template>
|
||||
@ -73,6 +74,34 @@ infisical export --template=<path to template>
|
||||
|
||||
### flags
|
||||
|
||||
<Accordion title="--output-file">
|
||||
The path to write the output file to. Can be a full file path, directory, or filename.
|
||||
|
||||
```bash
|
||||
# Export to specific file
|
||||
infisical export --format=json --output-file=./secrets.json
|
||||
|
||||
# Export to directory (uses default filename based on format)
|
||||
infisical export --format=yaml --output-file=./
|
||||
```
|
||||
|
||||
**When `--output-file` is specified:**
|
||||
- Secrets are saved directly to the specified file
|
||||
- A success message is displayed showing the file path
|
||||
- For directories: adds default filename `secrets.{format}` (e.g., `secrets.json`, `secrets.yaml`)
|
||||
- For dotenv formats in directories: uses `.env` as the filename
|
||||
|
||||
**When `--output-file` is NOT specified (default behavior):**
|
||||
- Output is sent to stdout (standard output)
|
||||
- You can use shell redirection like `infisical export > secrets.json`
|
||||
- Maintains backwards compatibility with existing scripts
|
||||
|
||||
<Warning>
|
||||
If you're using shell redirection and your token expires, re-authentication will fail because the prompt can't display properly due to the redirection.
|
||||
</Warning>
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="--template">
|
||||
The `--template` flag specifies the path to the template file used for rendering secrets. When using templates, you can omit the other format flags.
|
||||
|
||||
@ -94,6 +123,7 @@ infisical export --template=<path to template>
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="--env">
|
||||
Used to set the environment that secrets are pulled from.
|
||||
|
||||
@ -162,7 +192,7 @@ infisical export --template=<path to template>
|
||||
|
||||
```bash
|
||||
# Example
|
||||
infisical run --tags=tag1,tag2,tag3 -- npm run dev
|
||||
infisical export --tags=tag1,tag2,tag3 --env=dev
|
||||
```
|
||||
|
||||
Note: you must reference the tag by its slug name not its fully qualified name. Go to project settings to view all tag slugs.
|
||||
|
@ -78,10 +78,7 @@
|
||||
},
|
||||
{
|
||||
"group": "Infisical SSH",
|
||||
"pages": [
|
||||
"documentation/platform/ssh/overview",
|
||||
"documentation/platform/ssh/host-groups"
|
||||
]
|
||||
"pages": ["documentation/platform/ssh/overview", "documentation/platform/ssh/host-groups"]
|
||||
},
|
||||
{
|
||||
"group": "Key Management (KMS)",
|
||||
@ -378,10 +375,7 @@
|
||||
},
|
||||
{
|
||||
"group": "Architecture",
|
||||
"pages": [
|
||||
"internals/architecture/components",
|
||||
"internals/architecture/cloud"
|
||||
]
|
||||
"pages": ["internals/architecture/components", "internals/architecture/cloud"]
|
||||
},
|
||||
"internals/security",
|
||||
"internals/service-tokens"
|
||||
@ -491,6 +485,7 @@
|
||||
"integrations/app-connections/postgres",
|
||||
"integrations/app-connections/railway",
|
||||
"integrations/app-connections/render",
|
||||
"integrations/app-connections/supabase",
|
||||
"integrations/app-connections/teamcity",
|
||||
"integrations/app-connections/terraform-cloud",
|
||||
"integrations/app-connections/vercel",
|
||||
@ -528,6 +523,7 @@
|
||||
"integrations/secret-syncs/oci-vault",
|
||||
"integrations/secret-syncs/railway",
|
||||
"integrations/secret-syncs/render",
|
||||
"integrations/secret-syncs/supabase",
|
||||
"integrations/secret-syncs/teamcity",
|
||||
"integrations/secret-syncs/terraform-cloud",
|
||||
"integrations/secret-syncs/vercel",
|
||||
@ -555,10 +551,7 @@
|
||||
"integrations/cloud/gcp-secret-manager",
|
||||
{
|
||||
"group": "Cloudflare",
|
||||
"pages": [
|
||||
"integrations/cloud/cloudflare-pages",
|
||||
"integrations/cloud/cloudflare-workers"
|
||||
]
|
||||
"pages": ["integrations/cloud/cloudflare-pages", "integrations/cloud/cloudflare-workers"]
|
||||
},
|
||||
"integrations/cloud/terraform-cloud",
|
||||
"integrations/cloud/databricks",
|
||||
@ -670,11 +663,7 @@
|
||||
"cli/commands/reset",
|
||||
{
|
||||
"group": "infisical scan",
|
||||
"pages": [
|
||||
"cli/commands/scan",
|
||||
"cli/commands/scan-git-changes",
|
||||
"cli/commands/scan-install"
|
||||
]
|
||||
"pages": ["cli/commands/scan", "cli/commands/scan-git-changes", "cli/commands/scan-install"]
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -998,9 +987,7 @@
|
||||
"pages": [
|
||||
{
|
||||
"group": "Kubernetes",
|
||||
"pages": [
|
||||
"api-reference/endpoints/dynamic-secrets/kubernetes/create-lease"
|
||||
]
|
||||
"pages": ["api-reference/endpoints/dynamic-secrets/kubernetes/create-lease"]
|
||||
},
|
||||
"api-reference/endpoints/dynamic-secrets/create",
|
||||
"api-reference/endpoints/dynamic-secrets/update",
|
||||
@ -1557,6 +1544,18 @@
|
||||
"api-reference/endpoints/app-connections/render/delete"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Supabase",
|
||||
"pages": [
|
||||
"api-reference/endpoints/app-connections/supabase/list",
|
||||
"api-reference/endpoints/app-connections/supabase/available",
|
||||
"api-reference/endpoints/app-connections/supabase/get-by-id",
|
||||
"api-reference/endpoints/app-connections/supabase/get-by-name",
|
||||
"api-reference/endpoints/app-connections/supabase/create",
|
||||
"api-reference/endpoints/app-connections/supabase/update",
|
||||
"api-reference/endpoints/app-connections/supabase/delete"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "TeamCity",
|
||||
"pages": [
|
||||
@ -1908,6 +1907,19 @@
|
||||
"api-reference/endpoints/secret-syncs/render/remove-secrets"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Supabase",
|
||||
"pages": [
|
||||
"api-reference/endpoints/secret-syncs/supabase/list",
|
||||
"api-reference/endpoints/secret-syncs/supabase/get-by-id",
|
||||
"api-reference/endpoints/secret-syncs/supabase/get-by-name",
|
||||
"api-reference/endpoints/secret-syncs/supabase/create",
|
||||
"api-reference/endpoints/secret-syncs/supabase/update",
|
||||
"api-reference/endpoints/secret-syncs/supabase/delete",
|
||||
"api-reference/endpoints/secret-syncs/supabase/sync-secrets",
|
||||
"api-reference/endpoints/secret-syncs/supabase/remove-secrets"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "TeamCity",
|
||||
"pages": [
|
||||
|
@ -42,10 +42,14 @@ To be more specific:
|
||||
Most of the time, the Infisical server will be behind a load balancer or
|
||||
proxy. To propagate the TLS certificate from the load balancer to the
|
||||
instance, you can configure the TLS to send the client certificate as a header
|
||||
that is set as an [environment
|
||||
variable](/self-hosting/configuration/envars#param-identity-tls-cert-auth-client-certificate-header-key).
|
||||
that is set as an [environment variable](/self-hosting/configuration/envars#param-identity-tls-cert-auth-client-certificate-header-key).
|
||||
</Accordion>
|
||||
|
||||
<Note>
|
||||
Infisical US/EU and dedicated instances are deployed with AWS ALB. TLS Certificate Auth must flow through our ALB mTLS pass-through in order to authenticate.
|
||||
When you are authenticating with TLS Certificate Auth, you must use the port `8443` instead of the default `443`. Example: `https://app.infisical.com:8443/api/v1/auth/tls-cert-auth/login`
|
||||
</Note>
|
||||
|
||||
## Guide
|
||||
|
||||
In the following steps, we explore how to create and use identities for your workloads and applications on TLS Certificate to
|
||||
@ -123,7 +127,7 @@ try {
|
||||
const clientCertificate = fs.readFileSync("client-cert.pem", "utf8");
|
||||
const clientKeyCertificate = fs.readFileSync("client-key.pem", "utf8");
|
||||
|
||||
const infisicalUrl = "https://app.infisical.com"; // or your self-hosted Infisical URL
|
||||
const infisicalUrl = "https://app.infisical.com:8443"; // or your self-hosted Infisical URL
|
||||
const identityId = "<your-identity-id>";
|
||||
|
||||
// Create HTTPS agent with client certificate and key
|
||||
|
BIN
docs/images/app-connections/supabase/app-connection-api-keys.png
Normal file
After Width: | Height: | Size: 251 KiB |
After Width: | Height: | Size: 219 KiB |
After Width: | Height: | Size: 275 KiB |
BIN
docs/images/app-connections/supabase/app-connection-form.png
Normal file
After Width: | Height: | Size: 494 KiB |
After Width: | Height: | Size: 684 KiB |
After Width: | Height: | Size: 253 KiB |
BIN
docs/images/app-connections/supabase/app-connection-option.png
Normal file
After Width: | Height: | Size: 413 KiB |
After Width: | Height: | Size: 210 KiB |
BIN
docs/images/secret-syncs/supabase/select-option.png
Normal file
After Width: | Height: | Size: 558 KiB |
BIN
docs/images/secret-syncs/supabase/sync-created.png
Normal file
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/images/secret-syncs/supabase/sync-destination.png
Normal file
After Width: | Height: | Size: 635 KiB |
BIN
docs/images/secret-syncs/supabase/sync-details.png
Normal file
After Width: | Height: | Size: 639 KiB |
BIN
docs/images/secret-syncs/supabase/sync-options.png
Normal file
After Width: | Height: | Size: 700 KiB |
BIN
docs/images/secret-syncs/supabase/sync-review.png
Normal file
After Width: | Height: | Size: 658 KiB |
BIN
docs/images/secret-syncs/supabase/sync-source.png
Normal file
After Width: | Height: | Size: 620 KiB |
107
docs/integrations/app-connections/supabase.mdx
Normal file
@ -0,0 +1,107 @@
|
||||
---
|
||||
title: "Supabase Connection"
|
||||
description: "Learn how to configure a Supabase Connection for Infisical."
|
||||
---
|
||||
|
||||
Infisical supports the use of [Personal Access Tokens](https://supabase.com/dashboard/account/tokens) to connect with Supabase.
|
||||
|
||||
## Create a Supabase Personal Access Token
|
||||
|
||||
<Steps>
|
||||
<Step title="Click the profile image in the top-right corner and select 'Account Preferences'">
|
||||

|
||||
</Step>
|
||||
<Step title="In the sidebar, select 'Access Tokens'">
|
||||

|
||||
</Step>
|
||||
<Step title="In the access tokens page, click on 'Generate New Token'">
|
||||

|
||||
</Step>
|
||||
<Step title="Enter a token name and click on 'Generate Token'">
|
||||
Provide a descriptive name for the token.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
<Step title="Copy the generated token and save it">
|
||||

|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Create a Supabase Connection in Infisical
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Infisical UI">
|
||||
<Steps>
|
||||
<Step title="Navigate to App Connections">
|
||||
In your Infisical dashboard, go to **Organization Settings** and open the [**App Connections**](https://app.infisical.com/organization/app-connections) tab.
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Select Supabase Connection">
|
||||
Click **+ Add Connection** and choose **Supabase Connection** from the list of integrations.
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Fill out the Supabase Connection form">
|
||||
Complete the form by providing:
|
||||
- A descriptive name for the connection
|
||||
- An optional description
|
||||
- Supabase instance URL (e.g., `https://your-domain.com` or `https://api.supabase.com`)
|
||||
- The Access Token value from the previous step
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Connection created">
|
||||
After submitting the form, your **Supabase Connection** will be successfully created and ready to use with your Infisical projects.
|
||||
|
||||

|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
</Tab>
|
||||
|
||||
<Tab title="API">
|
||||
To create a Supabase Connection via API, send a request to the [Create Supabase Connection](/api-reference/endpoints/app-connections/supabase/create) endpoint.
|
||||
|
||||
### Sample request
|
||||
|
||||
```bash Request
|
||||
curl --request POST \
|
||||
--url https://app.infisical.com/api/v1/app-connections/supabase \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"name": "my-supabase-connection",
|
||||
"method": "access-token",
|
||||
"credentials": {
|
||||
"accessToken": "[Access Token]",
|
||||
"instanceUrl": "https://api.supabase.com"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Sample response
|
||||
|
||||
```bash Response
|
||||
{
|
||||
"appConnection": {
|
||||
"id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6",
|
||||
"name": "my-supabase-connection",
|
||||
"description": null,
|
||||
"version": 1,
|
||||
"orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c",
|
||||
"createdAt": "2025-04-23T19:46:34.831Z",
|
||||
"updatedAt": "2025-04-23T19:46:34.831Z",
|
||||
"isPlatformManagedCredentials": false,
|
||||
"credentialsHash": "7c2d371dec195f82a6a0d5b41c970a229cfcaf88e894a5b6395e2dbd0280661f",
|
||||
"app": "supabase",
|
||||
"method": "access-token",
|
||||
"credentials": {
|
||||
"instanceUrl": "https://api.supabase.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
163
docs/integrations/secret-syncs/supabase.mdx
Normal file
@ -0,0 +1,163 @@
|
||||
---
|
||||
title: "Supabase Sync"
|
||||
description: "Learn how to configure a Supabase Sync for Infisical."
|
||||
---
|
||||
|
||||
**Prerequisites:**
|
||||
|
||||
- Create a [Supabase Connection](/integrations/app-connections/supabase)
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Infisical UI">
|
||||
<Steps>
|
||||
<Step title="Add Sync">
|
||||
Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button.
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Select 'Supabase'">
|
||||

|
||||
</Step>
|
||||
<Step title="Configure source">
|
||||
Configure the **Source** from where secrets should be retrieved, then click **Next**.
|
||||
|
||||

|
||||
|
||||
- **Environment**: The project environment to retrieve secrets from.
|
||||
- **Secret Path**: The folder path to retrieve secrets from.
|
||||
|
||||
<Tip>
|
||||
If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports).
|
||||
</Tip>
|
||||
</Step>
|
||||
<Step title="Configure destination">
|
||||
Configure the **Destination** to where secrets should be deployed, then click **Next**.
|
||||
|
||||

|
||||
|
||||
- **Supabase Connection**: The Supabase Connection to authenticate with.
|
||||
- **Project**: The Supabase project to sync secrets to.
|
||||
</Step>
|
||||
<Step title="Configure Sync Options">
|
||||
Configure the **Sync Options** to specify how secrets should be synced, then click **Next**.
|
||||
|
||||

|
||||
|
||||
- **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync.
|
||||
- **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical.
|
||||
<Note>
|
||||
Supabase does not support importing secrets.
|
||||
</Note>
|
||||
- **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment.
|
||||
<Note>
|
||||
We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched.
|
||||
</Note>
|
||||
- **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only.
|
||||
- **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical.
|
||||
</Step>
|
||||
<Step title="Configure details">
|
||||
Configure the **Details** of your Supabase Sync, then click **Next**.
|
||||
|
||||

|
||||
|
||||
- **Name**: The name of your sync. Must be slug-friendly.
|
||||
- **Description**: An optional description for your sync.
|
||||
</Step>
|
||||
<Step title="Review configuration">
|
||||
Review your Supabase Sync configuration, then click **Create Sync**.
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Sync created">
|
||||
If enabled, your Supabase Sync will begin syncing your secrets to the destination endpoint.
|
||||
|
||||

|
||||
</Step>
|
||||
</Steps>
|
||||
</Tab>
|
||||
<Tab title="API">
|
||||
To create a **Supabase Sync**, make an API request to the [Create Supabase Sync](/api-reference/endpoints/secret-syncs/supabase/create) API endpoint.
|
||||
|
||||
### Sample request
|
||||
|
||||
```bash Request
|
||||
curl --request POST \
|
||||
--url https://app.infisical.com/api/v1/secret-syncs/supabase \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"name": "my-supabase-sync",
|
||||
"projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
|
||||
"description": "an example sync",
|
||||
"connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
|
||||
"environment": "dev",
|
||||
"secretPath": "/my-secrets",
|
||||
"isEnabled": true,
|
||||
"syncOptions": {
|
||||
"initialSyncBehavior": "overwrite-destination",
|
||||
"autoSyncEnabled": true,
|
||||
"disableSecretDeletion": false
|
||||
},
|
||||
"destinationConfig": {
|
||||
"projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
|
||||
"projectName": "Example Project"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Sample response
|
||||
|
||||
```bash Response
|
||||
{
|
||||
"secretSync": {
|
||||
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
|
||||
"name": "my-supabase-sync",
|
||||
"description": "an example sync",
|
||||
"isEnabled": true,
|
||||
"version": 1,
|
||||
"folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
|
||||
"connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
|
||||
"createdAt": "2023-11-07T05:31:56Z",
|
||||
"updatedAt": "2023-11-07T05:31:56Z",
|
||||
"syncStatus": "succeeded",
|
||||
"lastSyncJobId": "123",
|
||||
"lastSyncMessage": null,
|
||||
"lastSyncedAt": "2023-11-07T05:31:56Z",
|
||||
"importStatus": null,
|
||||
"lastImportJobId": null,
|
||||
"lastImportMessage": null,
|
||||
"lastImportedAt": null,
|
||||
"removeStatus": null,
|
||||
"lastRemoveJobId": null,
|
||||
"lastRemoveMessage": null,
|
||||
"lastRemovedAt": null,
|
||||
"syncOptions": {
|
||||
"initialSyncBehavior": "overwrite-destination",
|
||||
"autoSyncEnabled": true,
|
||||
"disableSecretDeletion": false
|
||||
},
|
||||
"projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
|
||||
"connection": {
|
||||
"app": "supabase",
|
||||
"name": "my-supabase-connection",
|
||||
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a"
|
||||
},
|
||||
"environment": {
|
||||
"slug": "dev",
|
||||
"name": "Development",
|
||||
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a"
|
||||
},
|
||||
"folder": {
|
||||
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
|
||||
"path": "/my-secrets"
|
||||
},
|
||||
"destination": "supabase",
|
||||
"destinationConfig": {
|
||||
"projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
|
||||
"projectName": "Example Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
|
||||
</Tabs>
|
@ -24,6 +24,7 @@ import { HumanitecSyncFields } from "./HumanitecSyncFields";
|
||||
import { OCIVaultSyncFields } from "./OCIVaultSyncFields";
|
||||
import { RailwaySyncFields } from "./RailwaySyncFields";
|
||||
import { RenderSyncFields } from "./RenderSyncFields";
|
||||
import { SupabaseSyncFields } from "./SupabaseSyncFields";
|
||||
import { TeamCitySyncFields } from "./TeamCitySyncFields";
|
||||
import { TerraformCloudSyncFields } from "./TerraformCloudSyncFields";
|
||||
import { VercelSyncFields } from "./VercelSyncFields";
|
||||
@ -88,6 +89,8 @@ export const SecretSyncDestinationFields = () => {
|
||||
return <RailwaySyncFields />;
|
||||
case SecretSync.Checkly:
|
||||
return <ChecklySyncFields />;
|
||||
case SecretSync.Supabase:
|
||||
return <SupabaseSyncFields />;
|
||||
default:
|
||||
throw new Error(`Unhandled Destination Config Field: ${destination}`);
|
||||
}
|
||||
|
@ -0,0 +1,65 @@
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import { SingleValue } from "react-select";
|
||||
|
||||
import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField";
|
||||
import { FilterableSelect, FormControl } from "@app/components/v2";
|
||||
import {
|
||||
TSupabaseProject,
|
||||
useSupabaseConnectionListProjects
|
||||
} from "@app/hooks/api/appConnections/supabase";
|
||||
import { SecretSync } from "@app/hooks/api/secretSyncs";
|
||||
|
||||
import { TSecretSyncForm } from "../schemas";
|
||||
|
||||
export const SupabaseSyncFields = () => {
|
||||
const { control, setValue } = useFormContext<
|
||||
TSecretSyncForm & { destination: SecretSync.Supabase }
|
||||
>();
|
||||
|
||||
const connectionId = useWatch({ name: "connection.id", control });
|
||||
|
||||
const { data: projects = [], isPending: isProjectsLoading } = useSupabaseConnectionListProjects(
|
||||
connectionId,
|
||||
{
|
||||
enabled: Boolean(connectionId)
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SecretSyncConnectionField
|
||||
onChange={() => {
|
||||
setValue("destinationConfig.projectName", "");
|
||||
setValue("destinationConfig.projectId", "");
|
||||
}}
|
||||
/>
|
||||
<Controller
|
||||
name="destinationConfig.projectId"
|
||||
control={control}
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
label="Select a project"
|
||||
tooltipClassName="max-w-md"
|
||||
>
|
||||
<FilterableSelect
|
||||
isLoading={isProjectsLoading && Boolean(connectionId)}
|
||||
isDisabled={!connectionId}
|
||||
value={projects.find((p) => p.id === value) ?? null}
|
||||
onChange={(option) => {
|
||||
const v = option as SingleValue<TSupabaseProject>;
|
||||
onChange(v?.id ?? null);
|
||||
setValue("destinationConfig.projectName", v?.name ?? "");
|
||||
}}
|
||||
options={projects}
|
||||
placeholder="Select project..."
|
||||
getOptionLabel={(option) => option.name}
|
||||
getOptionValue={(option) => option.id}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -62,6 +62,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => {
|
||||
case SecretSync.Zabbix:
|
||||
case SecretSync.Railway:
|
||||
case SecretSync.Checkly:
|
||||
case SecretSync.Supabase:
|
||||
AdditionalSyncOptionsFieldsComponent = null;
|
||||
break;
|
||||
default:
|
||||
|