mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 04:14:38 +00:00
Merge branch 'main' into fix/user-stuck-on-invited
This commit is contained in:
+172
-30
@@ -20,8 +20,9 @@ import {
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors";
|
||||
import { withGatewayProxy } from "@app/lib/gateway";
|
||||
import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
|
||||
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
import { ActorType, AuthTokenType } from "../auth/auth-type";
|
||||
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
|
||||
@@ -33,6 +34,7 @@ import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/su
|
||||
import { TIdentityKubernetesAuthDALFactory } from "./identity-kubernetes-auth-dal";
|
||||
import { extractK8sUsername } from "./identity-kubernetes-auth-fns";
|
||||
import {
|
||||
IdentityKubernetesAuthTokenReviewMode,
|
||||
TAttachKubernetesAuthDTO,
|
||||
TCreateTokenReviewResponse,
|
||||
TGetKubernetesAuthDTO,
|
||||
@@ -72,19 +74,25 @@ export const identityKubernetesAuthServiceFactory = ({
|
||||
gatewayId: string;
|
||||
targetHost: string;
|
||||
targetPort: number;
|
||||
caCert?: string;
|
||||
reviewTokenThroughGateway: boolean;
|
||||
},
|
||||
gatewayCallback: (host: string, port: number) => Promise<T>
|
||||
gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise<T>
|
||||
): Promise<T> => {
|
||||
const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId);
|
||||
const [relayHost, relayPort] = relayDetails.relayAddress.split(":");
|
||||
|
||||
const callbackResult = await withGatewayProxy(
|
||||
async (port) => {
|
||||
// Needs to be https protocol or the kubernetes API server will fail with "Client sent an HTTP request to an HTTPS server"
|
||||
const res = await gatewayCallback("https://localhost", port);
|
||||
async (port, httpsAgent) => {
|
||||
const res = await gatewayCallback(
|
||||
inputs.reviewTokenThroughGateway ? "http://localhost" : "https://localhost",
|
||||
port,
|
||||
httpsAgent
|
||||
);
|
||||
return res;
|
||||
},
|
||||
{
|
||||
protocol: inputs.reviewTokenThroughGateway ? GatewayProxyProtocol.Http : GatewayProxyProtocol.Tcp,
|
||||
targetHost: inputs.targetHost,
|
||||
targetPort: inputs.targetPort,
|
||||
relayHost,
|
||||
@@ -95,7 +103,12 @@ export const identityKubernetesAuthServiceFactory = ({
|
||||
ca: relayDetails.certChain,
|
||||
cert: relayDetails.certificate,
|
||||
key: relayDetails.privateKey.toString()
|
||||
}
|
||||
},
|
||||
// we always pass this, because its needed for both tcp and http protocol
|
||||
httpsAgent: new https.Agent({
|
||||
ca: inputs.caCert,
|
||||
rejectUnauthorized: Boolean(inputs.caCert)
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
@@ -129,22 +142,29 @@ export const identityKubernetesAuthServiceFactory = ({
|
||||
caCert = decryptor({ cipherTextBlob: identityKubernetesAuth.encryptedKubernetesCaCertificate }).toString();
|
||||
}
|
||||
|
||||
let tokenReviewerJwt = "";
|
||||
if (identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt) {
|
||||
tokenReviewerJwt = decryptor({
|
||||
cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt
|
||||
}).toString();
|
||||
} else {
|
||||
// if no token reviewer is provided means the incoming token has to act as reviewer
|
||||
tokenReviewerJwt = serviceAccountJwt;
|
||||
}
|
||||
const tokenReviewCallbackRaw = async (host: string = identityKubernetesAuth.kubernetesHost, port?: number) => {
|
||||
logger.info({ host, port }, "tokenReviewCallbackRaw: Processing kubernetes token review using raw API");
|
||||
let tokenReviewerJwt = "";
|
||||
if (identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt) {
|
||||
tokenReviewerJwt = decryptor({
|
||||
cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt
|
||||
}).toString();
|
||||
} else {
|
||||
// if no token reviewer is provided means the incoming token has to act as reviewer
|
||||
tokenReviewerJwt = serviceAccountJwt;
|
||||
}
|
||||
|
||||
let { kubernetesHost } = identityKubernetesAuth;
|
||||
if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) {
|
||||
kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, "");
|
||||
}
|
||||
let servername = identityKubernetesAuth.kubernetesHost;
|
||||
if (servername.startsWith("https://") || servername.startsWith("http://")) {
|
||||
servername = new RE2("^https?:\\/\\/").replace(servername, "");
|
||||
}
|
||||
|
||||
// get the last colon index, if it has a port, remove it, including the colon
|
||||
const lastColonIndex = servername.lastIndexOf(":");
|
||||
if (lastColonIndex !== -1) {
|
||||
servername = servername.substring(0, lastColonIndex);
|
||||
}
|
||||
|
||||
const tokenReviewCallback = async (host: string = identityKubernetesAuth.kubernetesHost, port?: number) => {
|
||||
const baseUrl = port ? `${host}:${port}` : host;
|
||||
|
||||
const res = await axios
|
||||
@@ -165,11 +185,10 @@ export const identityKubernetesAuthServiceFactory = ({
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
timeout: 10000,
|
||||
// if ca cert, rejectUnauthorized: true
|
||||
httpsAgent: new https.Agent({
|
||||
ca: caCert,
|
||||
rejectUnauthorized: Boolean(caCert),
|
||||
servername: kubernetesHost
|
||||
servername
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -192,18 +211,137 @@ export const identityKubernetesAuthServiceFactory = ({
|
||||
return res.data;
|
||||
};
|
||||
|
||||
const [k8sHost, k8sPort] = kubernetesHost.split(":");
|
||||
const tokenReviewCallbackThroughGateway = async (
|
||||
host: string = identityKubernetesAuth.kubernetesHost,
|
||||
port?: number,
|
||||
httpsAgent?: https.Agent
|
||||
) => {
|
||||
logger.info(
|
||||
{
|
||||
host,
|
||||
port
|
||||
},
|
||||
"tokenReviewCallbackThroughGateway: Processing kubernetes token review using gateway"
|
||||
);
|
||||
|
||||
const data = identityKubernetesAuth.gatewayId
|
||||
? await $gatewayProxyWrapper(
|
||||
const baseUrl = port ? `${host}:${port}` : host;
|
||||
|
||||
const res = await axios
|
||||
.post<TCreateTokenReviewResponse>(
|
||||
`${baseUrl}/apis/authentication.k8s.io/v1/tokenreviews`,
|
||||
{
|
||||
gatewayId: identityKubernetesAuth.gatewayId,
|
||||
targetHost: k8sHost,
|
||||
targetPort: k8sPort ? Number(k8sPort) : 443
|
||||
apiVersion: "authentication.k8s.io/v1",
|
||||
kind: "TokenReview",
|
||||
spec: {
|
||||
token: serviceAccountJwt,
|
||||
...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {})
|
||||
}
|
||||
},
|
||||
tokenReviewCallback
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
timeout: 10000,
|
||||
...(httpsAgent ? { httpsAgent } : {})
|
||||
}
|
||||
)
|
||||
: await tokenReviewCallback();
|
||||
.catch((err) => {
|
||||
if (err instanceof AxiosError) {
|
||||
if (err.response) {
|
||||
let { message } = err?.response?.data as unknown as { message?: string };
|
||||
|
||||
if (!message && typeof err.response.data === "string") {
|
||||
message = err.response.data;
|
||||
}
|
||||
|
||||
if (message) {
|
||||
throw new UnauthorizedError({
|
||||
message,
|
||||
name: "KubernetesTokenReviewRequestError"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
return res.data;
|
||||
};
|
||||
|
||||
let data: TCreateTokenReviewResponse | undefined;
|
||||
|
||||
if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) {
|
||||
const { kubernetesHost } = identityKubernetesAuth;
|
||||
|
||||
let urlString = kubernetesHost;
|
||||
if (!kubernetesHost.startsWith("http://") && !kubernetesHost.startsWith("https://")) {
|
||||
urlString = `https://${kubernetesHost}`;
|
||||
}
|
||||
|
||||
const url = new URL(urlString);
|
||||
let { port: k8sPort } = url;
|
||||
const { protocol, hostname: k8sHost } = url;
|
||||
|
||||
const cleanedProtocol = new RE2(/[^a-zA-Z0-9]/g).replace(protocol, "").toLowerCase();
|
||||
|
||||
if (!["https", "http"].includes(cleanedProtocol)) {
|
||||
throw new BadRequestError({
|
||||
message: "Invalid Kubernetes host URL, must start with http:// or https://"
|
||||
});
|
||||
}
|
||||
|
||||
if (!k8sPort) {
|
||||
k8sPort = cleanedProtocol === "https" ? "443" : "80";
|
||||
}
|
||||
|
||||
if (!identityKubernetesAuth.gatewayId) {
|
||||
throw new BadRequestError({
|
||||
message: "Gateway ID is required when token review mode is set to Gateway"
|
||||
});
|
||||
}
|
||||
|
||||
data = await $gatewayProxyWrapper(
|
||||
{
|
||||
gatewayId: identityKubernetesAuth.gatewayId,
|
||||
targetHost: `${cleanedProtocol}://${k8sHost}`, // note(daniel): must include the protocol (https|http)
|
||||
targetPort: k8sPort ? Number(k8sPort) : 443,
|
||||
caCert,
|
||||
reviewTokenThroughGateway: true
|
||||
},
|
||||
tokenReviewCallbackThroughGateway
|
||||
);
|
||||
} else if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api) {
|
||||
let { kubernetesHost } = identityKubernetesAuth;
|
||||
if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) {
|
||||
kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, "");
|
||||
}
|
||||
|
||||
const [k8sHost, k8sPort] = kubernetesHost.split(":");
|
||||
|
||||
data = identityKubernetesAuth.gatewayId
|
||||
? await $gatewayProxyWrapper(
|
||||
{
|
||||
gatewayId: identityKubernetesAuth.gatewayId,
|
||||
targetHost: k8sHost,
|
||||
targetPort: k8sPort ? Number(k8sPort) : 443,
|
||||
reviewTokenThroughGateway: false
|
||||
},
|
||||
tokenReviewCallbackRaw
|
||||
)
|
||||
: await tokenReviewCallbackRaw();
|
||||
} else {
|
||||
throw new BadRequestError({
|
||||
message: `Invalid token review mode: ${identityKubernetesAuth.tokenReviewMode}`
|
||||
});
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to review token"
|
||||
});
|
||||
}
|
||||
|
||||
if ("error" in data.status)
|
||||
throw new UnauthorizedError({ message: data.status.error, name: "KubernetesTokenReviewError" });
|
||||
@@ -298,6 +436,7 @@ export const identityKubernetesAuthServiceFactory = ({
|
||||
kubernetesHost,
|
||||
caCert,
|
||||
tokenReviewerJwt,
|
||||
tokenReviewMode,
|
||||
allowedNamespaces,
|
||||
allowedNames,
|
||||
allowedAudience,
|
||||
@@ -384,6 +523,7 @@ export const identityKubernetesAuthServiceFactory = ({
|
||||
{
|
||||
identityId: identityMembershipOrg.identityId,
|
||||
kubernetesHost,
|
||||
tokenReviewMode,
|
||||
allowedNamespaces,
|
||||
allowedNames,
|
||||
allowedAudience,
|
||||
@@ -410,6 +550,7 @@ export const identityKubernetesAuthServiceFactory = ({
|
||||
kubernetesHost,
|
||||
caCert,
|
||||
tokenReviewerJwt,
|
||||
tokenReviewMode,
|
||||
allowedNamespaces,
|
||||
allowedNames,
|
||||
allowedAudience,
|
||||
@@ -492,6 +633,7 @@ export const identityKubernetesAuthServiceFactory = ({
|
||||
|
||||
const updateQuery: TIdentityKubernetesAuthsUpdate = {
|
||||
kubernetesHost,
|
||||
tokenReviewMode,
|
||||
allowedNamespaces,
|
||||
allowedNames,
|
||||
allowedAudience,
|
||||
|
||||
@@ -5,11 +5,17 @@ export type TLoginKubernetesAuthDTO = {
|
||||
jwt: string;
|
||||
};
|
||||
|
||||
export enum IdentityKubernetesAuthTokenReviewMode {
|
||||
Api = "api",
|
||||
Gateway = "gateway"
|
||||
}
|
||||
|
||||
export type TAttachKubernetesAuthDTO = {
|
||||
identityId: string;
|
||||
kubernetesHost: string;
|
||||
caCert: string;
|
||||
tokenReviewerJwt?: string;
|
||||
tokenReviewMode: IdentityKubernetesAuthTokenReviewMode;
|
||||
allowedNamespaces: string;
|
||||
allowedNames: string;
|
||||
allowedAudience: string;
|
||||
@@ -26,6 +32,7 @@ export type TUpdateKubernetesAuthDTO = {
|
||||
kubernetesHost?: string;
|
||||
caCert?: string;
|
||||
tokenReviewerJwt?: string | null;
|
||||
tokenReviewMode?: IdentityKubernetesAuthTokenReviewMode;
|
||||
allowedNamespaces?: string;
|
||||
allowedNames?: string;
|
||||
allowedAudience?: string;
|
||||
|
||||
@@ -837,16 +837,22 @@ export const orgServiceFactory = ({
|
||||
|
||||
// if the user doesn't exist we create the user with the email
|
||||
if (!inviteeUser) {
|
||||
inviteeUser = await userDAL.create(
|
||||
{
|
||||
isAccepted: false,
|
||||
email: inviteeEmail,
|
||||
username: inviteeEmail,
|
||||
authMethods: [AuthMethod.EMAIL],
|
||||
isGhost: false
|
||||
},
|
||||
tx
|
||||
);
|
||||
// TODO(carlos): will be removed once the function receives usernames instead of emails
|
||||
const usersByEmail = await userDAL.findUserByEmail(inviteeEmail, tx);
|
||||
if (usersByEmail?.length === 1) {
|
||||
[inviteeUser] = usersByEmail;
|
||||
} else {
|
||||
inviteeUser = await userDAL.create(
|
||||
{
|
||||
isAccepted: false,
|
||||
email: inviteeEmail,
|
||||
username: inviteeEmail,
|
||||
authMethods: [AuthMethod.EMAIL],
|
||||
isGhost: false
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const inviteeUserId = inviteeUser?.id;
|
||||
|
||||
@@ -30,7 +30,7 @@ import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-
|
||||
import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal";
|
||||
import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal";
|
||||
import { TSshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal";
|
||||
import { TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
@@ -165,7 +165,7 @@ type TProjectServiceFactoryDep = {
|
||||
sshHostGroupDAL: Pick<TSshHostGroupDALFactory, "find" | "findSshHostGroupsWithLoginMappings">;
|
||||
permissionService: TPermissionServiceFactory;
|
||||
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan" | "invalidateGetPlan">;
|
||||
queueService: Pick<TQueueServiceFactory, "stopRepeatableJob">;
|
||||
smtpService: Pick<TSmtpService, "sendMail">;
|
||||
orgDAL: Pick<TOrgDALFactory, "findOne">;
|
||||
@@ -259,16 +259,17 @@ export const projectServiceFactory = ({
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace);
|
||||
|
||||
const plan = await licenseService.getPlan(organization.id);
|
||||
if (plan.workspaceLimit !== null && plan.workspacesUsed >= plan.workspaceLimit) {
|
||||
// case: limit imposed on number of workspaces allowed
|
||||
// case: number of workspaces used exceeds the number of workspaces allowed
|
||||
throw new BadRequestError({
|
||||
message: "Failed to create workspace due to plan limit reached. Upgrade plan to add more workspaces."
|
||||
});
|
||||
}
|
||||
|
||||
const results = await (trx || projectDAL).transaction(async (tx) => {
|
||||
await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.CreateProject(organization.id)]);
|
||||
|
||||
const plan = await licenseService.getPlan(organization.id);
|
||||
if (plan.workspaceLimit !== null && plan.workspacesUsed >= plan.workspaceLimit) {
|
||||
// case: limit imposed on number of workspaces allowed
|
||||
// case: number of workspaces used exceeds the number of workspaces allowed
|
||||
throw new BadRequestError({
|
||||
message: "Failed to create workspace due to plan limit reached. Upgrade plan to add more workspaces."
|
||||
});
|
||||
}
|
||||
const ghostUser = await orgService.addGhostUser(organization.id, tx);
|
||||
|
||||
if (kmsKeyId) {
|
||||
@@ -493,6 +494,10 @@ export const projectServiceFactory = ({
|
||||
);
|
||||
}
|
||||
|
||||
// no need to invalidate if there was no limit
|
||||
if (plan.workspaceLimit) {
|
||||
await licenseService.invalidateGetPlan(organization.id);
|
||||
}
|
||||
return {
|
||||
...project,
|
||||
environments: envs,
|
||||
|
||||
@@ -127,6 +127,7 @@ export const OnePassSyncFns = {
|
||||
syncSecrets: async (secretSync: TOnePassSyncWithCredentials, secretMap: TSecretMap) => {
|
||||
const {
|
||||
connection,
|
||||
environment,
|
||||
destinationConfig: { vaultId }
|
||||
} = secretSync;
|
||||
|
||||
@@ -164,7 +165,7 @@ export const OnePassSyncFns = {
|
||||
|
||||
for await (const [key, variable] of Object.entries(items)) {
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue;
|
||||
if (!matchesSchema(key, environment?.slug || "", secretSync.syncOptions.keySchema)) continue;
|
||||
|
||||
if (!(key in secretMap)) {
|
||||
try {
|
||||
|
||||
+2
-2
@@ -294,7 +294,7 @@ const deleteParametersBatch = async (
|
||||
|
||||
export const AwsParameterStoreSyncFns = {
|
||||
syncSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => {
|
||||
const { destinationConfig, syncOptions } = secretSync;
|
||||
const { destinationConfig, syncOptions, environment } = secretSync;
|
||||
|
||||
const ssm = await getSSM(secretSync);
|
||||
|
||||
@@ -391,7 +391,7 @@ export const AwsParameterStoreSyncFns = {
|
||||
const [key, parameter] = entry;
|
||||
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!matchesSchema(key, syncOptions.keySchema)) continue;
|
||||
if (!matchesSchema(key, environment?.slug || "", syncOptions.keySchema)) continue;
|
||||
|
||||
if (!(key in secretMap) || !secretMap[key].value) {
|
||||
parametersToDelete.push(parameter);
|
||||
|
||||
+16
-8
@@ -57,7 +57,11 @@ const sleep = async () =>
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
|
||||
const getSecretsRecord = async (client: SecretsManagerClient, keySchema?: string): Promise<TAwsSecretsRecord> => {
|
||||
const getSecretsRecord = async (
|
||||
client: SecretsManagerClient,
|
||||
environment: string,
|
||||
keySchema?: string
|
||||
): Promise<TAwsSecretsRecord> => {
|
||||
const awsSecretsRecord: TAwsSecretsRecord = {};
|
||||
let hasNext = true;
|
||||
let nextToken: string | undefined;
|
||||
@@ -72,7 +76,7 @@ const getSecretsRecord = async (client: SecretsManagerClient, keySchema?: string
|
||||
|
||||
if (output.SecretList) {
|
||||
output.SecretList.forEach((secretEntry) => {
|
||||
if (secretEntry.Name && matchesSchema(secretEntry.Name, keySchema)) {
|
||||
if (secretEntry.Name && matchesSchema(secretEntry.Name, environment, keySchema)) {
|
||||
awsSecretsRecord[secretEntry.Name] = secretEntry;
|
||||
}
|
||||
});
|
||||
@@ -307,11 +311,11 @@ const processTags = ({
|
||||
|
||||
export const AwsSecretsManagerSyncFns = {
|
||||
syncSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials, secretMap: TSecretMap) => {
|
||||
const { destinationConfig, syncOptions } = secretSync;
|
||||
const { destinationConfig, syncOptions, environment } = secretSync;
|
||||
|
||||
const client = await getSecretsManagerClient(secretSync);
|
||||
|
||||
const awsSecretsRecord = await getSecretsRecord(client, syncOptions.keySchema);
|
||||
const awsSecretsRecord = await getSecretsRecord(client, environment?.slug || "", syncOptions.keySchema);
|
||||
|
||||
const awsValuesRecord = await getSecretValuesRecord(client, awsSecretsRecord);
|
||||
|
||||
@@ -401,7 +405,7 @@ export const AwsSecretsManagerSyncFns = {
|
||||
|
||||
for await (const secretKey of Object.keys(awsSecretsRecord)) {
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!matchesSchema(secretKey, syncOptions.keySchema)) continue;
|
||||
if (!matchesSchema(secretKey, environment?.slug || "", syncOptions.keySchema)) continue;
|
||||
|
||||
if (!(secretKey in secretMap) || !secretMap[secretKey].value) {
|
||||
try {
|
||||
@@ -468,7 +472,11 @@ export const AwsSecretsManagerSyncFns = {
|
||||
getSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials): Promise<TSecretMap> => {
|
||||
const client = await getSecretsManagerClient(secretSync);
|
||||
|
||||
const awsSecretsRecord = await getSecretsRecord(client, secretSync.syncOptions.keySchema);
|
||||
const awsSecretsRecord = await getSecretsRecord(
|
||||
client,
|
||||
secretSync.environment?.slug || "",
|
||||
secretSync.syncOptions.keySchema
|
||||
);
|
||||
const awsValuesRecord = await getSecretValuesRecord(client, awsSecretsRecord);
|
||||
|
||||
const { destinationConfig } = secretSync;
|
||||
@@ -503,11 +511,11 @@ export const AwsSecretsManagerSyncFns = {
|
||||
}
|
||||
},
|
||||
removeSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials, secretMap: TSecretMap) => {
|
||||
const { destinationConfig, syncOptions } = secretSync;
|
||||
const { destinationConfig, syncOptions, environment } = secretSync;
|
||||
|
||||
const client = await getSecretsManagerClient(secretSync);
|
||||
|
||||
const awsSecretsRecord = await getSecretsRecord(client, syncOptions.keySchema);
|
||||
const awsSecretsRecord = await getSecretsRecord(client, environment?.slug || "", syncOptions.keySchema);
|
||||
|
||||
if (destinationConfig.mappingBehavior === AwsSecretsManagerSyncMappingBehavior.OneToOne) {
|
||||
for await (const secretKey of Object.keys(awsSecretsRecord)) {
|
||||
|
||||
+1
-1
@@ -141,7 +141,7 @@ export const azureAppConfigurationSyncFactory = ({
|
||||
|
||||
for await (const key of Object.keys(azureAppConfigSecrets)) {
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue;
|
||||
if (!matchesSchema(key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue;
|
||||
|
||||
const azureSecret = azureAppConfigSecrets[key];
|
||||
if (
|
||||
|
||||
@@ -194,7 +194,7 @@ export const azureKeyVaultSyncFactory = ({ kmsService, appConnectionDAL }: TAzur
|
||||
|
||||
for await (const deleteSecretKey of deleteSecrets.filter(
|
||||
(secret) =>
|
||||
matchesSchema(secret, secretSync.syncOptions.keySchema) &&
|
||||
matchesSchema(secret, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema) &&
|
||||
!setSecrets.find((setSecret) => setSecret.key === secret)
|
||||
)) {
|
||||
await request.delete(`${secretSync.destinationConfig.vaultBaseUrl}/secrets/${deleteSecretKey}?api-version=7.3`, {
|
||||
|
||||
@@ -118,7 +118,7 @@ export const camundaSyncFactory = ({ kmsService, appConnectionDAL }: TCamundaSec
|
||||
|
||||
for await (const secret of Object.keys(camundaSecrets)) {
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!matchesSchema(secret, secretSync.syncOptions.keySchema)) continue;
|
||||
if (!matchesSchema(secret, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue;
|
||||
|
||||
if (!(secret in secretMap) || !secretMap[secret].value) {
|
||||
try {
|
||||
|
||||
@@ -117,7 +117,7 @@ export const databricksSyncFactory = ({ kmsService, appConnectionDAL }: TDatabri
|
||||
|
||||
for await (const secret of databricksSecretKeys) {
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!matchesSchema(secret.key, secretSync.syncOptions.keySchema)) continue;
|
||||
if (!matchesSchema(secret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue;
|
||||
|
||||
if (!(secret.key in secretMap)) {
|
||||
await deleteDatabricksSecrets({
|
||||
|
||||
@@ -155,7 +155,7 @@ export const GcpSyncFns = {
|
||||
|
||||
for await (const key of Object.keys(gcpSecrets)) {
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue;
|
||||
if (!matchesSchema(key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue;
|
||||
|
||||
try {
|
||||
if (!(key in secretMap) || !secretMap[key].value) {
|
||||
|
||||
@@ -223,8 +223,9 @@ export const GithubSyncFns = {
|
||||
if (secretSync.syncOptions.disableSecretDeletion) return;
|
||||
|
||||
for await (const encryptedSecret of encryptedSecrets) {
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!matchesSchema(encryptedSecret.name, secretSync.syncOptions.keySchema)) continue;
|
||||
if (!matchesSchema(encryptedSecret.name, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema))
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
|
||||
if (!(encryptedSecret.name in secretMap)) {
|
||||
await deleteSecret(client, secretSync, encryptedSecret);
|
||||
|
||||
@@ -68,6 +68,7 @@ export const HCVaultSyncFns = {
|
||||
syncSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => {
|
||||
const {
|
||||
connection,
|
||||
environment,
|
||||
destinationConfig: { mount, path },
|
||||
syncOptions: { disableSecretDeletion, keySchema }
|
||||
} = secretSync;
|
||||
@@ -97,7 +98,7 @@ export const HCVaultSyncFns = {
|
||||
|
||||
for await (const [key] of Object.entries(variables)) {
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!matchesSchema(key, keySchema)) continue;
|
||||
if (!matchesSchema(key, environment?.slug || "", keySchema)) continue;
|
||||
|
||||
if (!(key in secretMap)) {
|
||||
delete variables[key];
|
||||
|
||||
@@ -200,8 +200,9 @@ export const HumanitecSyncFns = {
|
||||
if (secretSync.syncOptions.disableSecretDeletion) return;
|
||||
|
||||
for await (const humanitecSecret of humanitecSecrets) {
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!matchesSchema(humanitecSecret.key, secretSync.syncOptions.keySchema)) continue;
|
||||
if (!matchesSchema(humanitecSecret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema))
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
|
||||
if (!secretMap[humanitecSecret.key]) {
|
||||
await deleteSecret(secretSync, humanitecSecret);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AxiosError } from "axios";
|
||||
import RE2 from "re2";
|
||||
import handlebars from "handlebars";
|
||||
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { OCI_VAULT_SYNC_LIST_OPTION, OCIVaultSyncFns } from "@app/ee/services/secret-sync/oci-vault";
|
||||
@@ -68,13 +68,17 @@ type TSyncSecretDeps = {
|
||||
};
|
||||
|
||||
// Add schema to secret keys
|
||||
const addSchema = (unprocessedSecretMap: TSecretMap, schema?: string): TSecretMap => {
|
||||
const addSchema = (unprocessedSecretMap: TSecretMap, environment: string, schema?: string): TSecretMap => {
|
||||
if (!schema) return unprocessedSecretMap;
|
||||
|
||||
const processedSecretMap: TSecretMap = {};
|
||||
|
||||
for (const [key, value] of Object.entries(unprocessedSecretMap)) {
|
||||
const newKey = new RE2("{{secretKey}}").replace(schema, key);
|
||||
const newKey = handlebars.compile(schema)({
|
||||
secretKey: key,
|
||||
environment
|
||||
});
|
||||
|
||||
processedSecretMap[newKey] = value;
|
||||
}
|
||||
|
||||
@@ -82,10 +86,17 @@ const addSchema = (unprocessedSecretMap: TSecretMap, schema?: string): TSecretMa
|
||||
};
|
||||
|
||||
// Strip schema from secret keys
|
||||
const stripSchema = (unprocessedSecretMap: TSecretMap, schema?: string): TSecretMap => {
|
||||
const stripSchema = (unprocessedSecretMap: TSecretMap, environment: string, schema?: string): TSecretMap => {
|
||||
if (!schema) return unprocessedSecretMap;
|
||||
|
||||
const [prefix, suffix] = schema.split("{{secretKey}}");
|
||||
const compiledSchemaPattern = handlebars.compile(schema)({
|
||||
secretKey: "{{secretKey}}", // Keep secretKey
|
||||
environment
|
||||
});
|
||||
|
||||
const parts = compiledSchemaPattern.split("{{secretKey}}");
|
||||
const prefix = parts[0];
|
||||
const suffix = parts[parts.length - 1];
|
||||
|
||||
const strippedMap: TSecretMap = {};
|
||||
|
||||
@@ -103,21 +114,40 @@ const stripSchema = (unprocessedSecretMap: TSecretMap, schema?: string): TSecret
|
||||
};
|
||||
|
||||
// Checks if a key matches a schema
|
||||
export const matchesSchema = (key: string, schema?: string): boolean => {
|
||||
export const matchesSchema = (key: string, environment: string, schema?: string): boolean => {
|
||||
if (!schema) return true;
|
||||
|
||||
const [prefix, suffix] = schema.split("{{secretKey}}");
|
||||
if (prefix === undefined || suffix === undefined) return true;
|
||||
const compiledSchemaPattern = handlebars.compile(schema)({
|
||||
secretKey: "{{secretKey}}", // Keep secretKey
|
||||
environment
|
||||
});
|
||||
|
||||
return key.startsWith(prefix) && key.endsWith(suffix);
|
||||
// This edge-case shouldn't be possible
|
||||
if (!compiledSchemaPattern.includes("{{secretKey}}")) {
|
||||
return key === compiledSchemaPattern;
|
||||
}
|
||||
|
||||
const parts = compiledSchemaPattern.split("{{secretKey}}");
|
||||
const prefix = parts[0];
|
||||
const suffix = parts[parts.length - 1];
|
||||
|
||||
if (prefix === "" && suffix === "") return true;
|
||||
|
||||
// If prefix is empty, key must end with suffix
|
||||
if (prefix === "") return key.endsWith(suffix);
|
||||
|
||||
// If suffix is empty, key must start with prefix
|
||||
if (suffix === "") return key.startsWith(prefix);
|
||||
|
||||
return key.startsWith(prefix) && key.endsWith(suffix) && key.length >= prefix.length + suffix.length;
|
||||
};
|
||||
|
||||
// Filter only for secrets with keys that match the schema
|
||||
const filterForSchema = (secretMap: TSecretMap, schema?: string): TSecretMap => {
|
||||
const filterForSchema = (secretMap: TSecretMap, environment: string, schema?: string): TSecretMap => {
|
||||
const filteredMap: TSecretMap = {};
|
||||
|
||||
for (const [key, value] of Object.entries(secretMap)) {
|
||||
if (matchesSchema(key, schema)) {
|
||||
if (matchesSchema(key, environment, schema)) {
|
||||
filteredMap[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -131,7 +161,7 @@ export const SecretSyncFns = {
|
||||
secretMap: TSecretMap,
|
||||
{ kmsService, appConnectionDAL }: TSyncSecretDeps
|
||||
): Promise<void> => {
|
||||
const schemaSecretMap = addSchema(secretMap, secretSync.syncOptions.keySchema);
|
||||
const schemaSecretMap = addSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema);
|
||||
|
||||
switch (secretSync.destination) {
|
||||
case SecretSync.AWSParameterStore:
|
||||
@@ -255,14 +285,16 @@ export const SecretSyncFns = {
|
||||
);
|
||||
}
|
||||
|
||||
return stripSchema(filterForSchema(secretMap), secretSync.syncOptions.keySchema);
|
||||
const filtered = filterForSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema);
|
||||
const stripped = stripSchema(filtered, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema);
|
||||
return stripped;
|
||||
},
|
||||
removeSecrets: (
|
||||
secretSync: TSecretSyncWithCredentials,
|
||||
secretMap: TSecretMap,
|
||||
{ kmsService, appConnectionDAL }: TSyncSecretDeps
|
||||
): Promise<void> => {
|
||||
const schemaSecretMap = addSchema(secretMap, secretSync.syncOptions.keySchema);
|
||||
const schemaSecretMap = addSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema);
|
||||
|
||||
switch (secretSync.destination) {
|
||||
case SecretSync.AWSParameterStore:
|
||||
|
||||
@@ -28,10 +28,30 @@ const BaseSyncOptionsSchema = <T extends AnyZodObject | undefined = undefined>({
|
||||
keySchema: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((val) => !val || new RE2(/^(?:[a-zA-Z0-9_\-/]*)(?:\{\{secretKey\}\})(?:[a-zA-Z0-9_\-/]*)$/).test(val), {
|
||||
message:
|
||||
"Key schema must include one {{secretKey}} and only contain letters, numbers, dashes, underscores, slashes, and the {{secretKey}} placeholder."
|
||||
})
|
||||
.refine(
|
||||
(val) => {
|
||||
if (!val) return true;
|
||||
|
||||
const allowedOptionalPlaceholders = ["{{environment}}"];
|
||||
|
||||
const allowedPlaceholdersRegexPart = ["{{secretKey}}", ...allowedOptionalPlaceholders]
|
||||
.map((p) => p.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")) // Escape regex special characters
|
||||
.join("|");
|
||||
|
||||
const allowedContentRegex = new RE2(`^([a-zA-Z0-9_\\-/]|${allowedPlaceholdersRegexPart})*$`);
|
||||
const contentIsValid = allowedContentRegex.test(val);
|
||||
|
||||
// Check if {{secretKey}} is present
|
||||
const secretKeyRegex = new RE2(/\{\{secretKey\}\}/);
|
||||
const secretKeyIsPresent = secretKeyRegex.test(val);
|
||||
|
||||
return contentIsValid && secretKeyIsPresent;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Key schema must include exactly one {{secretKey}} placeholder. It can also include {{environment}} placeholders. Only alphanumeric characters (a-z, A-Z, 0-9), dashes (-), underscores (_), and slashes (/) are allowed besides the placeholders."
|
||||
}
|
||||
)
|
||||
.describe(SecretSyncs.SYNC_OPTIONS(destination).keySchema),
|
||||
disableSecretDeletion: z.boolean().optional().describe(SecretSyncs.SYNC_OPTIONS(destination).disableSecretDeletion)
|
||||
});
|
||||
|
||||
@@ -127,7 +127,7 @@ export const TeamCitySyncFns = {
|
||||
|
||||
for await (const [key, variable] of Object.entries(variables)) {
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue;
|
||||
if (!matchesSchema(key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue;
|
||||
|
||||
if (!(key in secretMap)) {
|
||||
try {
|
||||
|
||||
@@ -232,8 +232,11 @@ export const TerraformCloudSyncFns = {
|
||||
if (secretSync.syncOptions.disableSecretDeletion) return;
|
||||
|
||||
for (const terraformCloudVariable of terraformCloudVariables) {
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!matchesSchema(terraformCloudVariable.key, secretSync.syncOptions.keySchema)) continue;
|
||||
if (
|
||||
!matchesSchema(terraformCloudVariable.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)
|
||||
)
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(secretMap, terraformCloudVariable.key)) {
|
||||
await deleteVariable(secretSync, terraformCloudVariable);
|
||||
|
||||
@@ -291,8 +291,9 @@ export const VercelSyncFns = {
|
||||
if (secretSync.syncOptions.disableSecretDeletion) return;
|
||||
|
||||
for await (const vercelSecret of vercelSecrets) {
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!matchesSchema(vercelSecret.key, secretSync.syncOptions.keySchema)) continue;
|
||||
if (!matchesSchema(vercelSecret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema))
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
|
||||
if (!secretMap[vercelSecret.key]) {
|
||||
await deleteSecret(secretSync, vercelSecret);
|
||||
|
||||
@@ -128,6 +128,7 @@ export const WindmillSyncFns = {
|
||||
syncSecrets: async (secretSync: TWindmillSyncWithCredentials, secretMap: TSecretMap) => {
|
||||
const {
|
||||
connection,
|
||||
environment,
|
||||
destinationConfig: { path },
|
||||
syncOptions: { disableSecretDeletion, keySchema }
|
||||
} = secretSync;
|
||||
@@ -171,7 +172,7 @@ export const WindmillSyncFns = {
|
||||
|
||||
for await (const [key, variable] of Object.entries(variables)) {
|
||||
// eslint-disable-next-line no-continue
|
||||
if (!matchesSchema(key, keySchema)) continue;
|
||||
if (!matchesSchema(key, environment?.slug || "", keySchema)) continue;
|
||||
|
||||
if (!(key in secretMap)) {
|
||||
try {
|
||||
|
||||
@@ -21,6 +21,11 @@ export const userDALFactory = (db: TDbClient) => {
|
||||
const findUserByUsername = async (username: string, tx?: Knex) =>
|
||||
(tx || db)(TableName.Users).whereRaw('lower("username") = :username', { username: username.toLowerCase() });
|
||||
|
||||
const findUserByEmail = async (email: string, tx?: Knex) =>
|
||||
(tx || db)(TableName.Users).whereRaw('lower("email") = :email', { email: email.toLowerCase() }).where({
|
||||
isEmailVerified: true
|
||||
});
|
||||
|
||||
const getUsersByFilter = async ({
|
||||
limit,
|
||||
offset,
|
||||
@@ -234,6 +239,7 @@ export const userDALFactory = (db: TDbClient) => {
|
||||
findOneUserAction,
|
||||
createUserAction,
|
||||
getUsersByFilter,
|
||||
findAllMyAccounts
|
||||
findAllMyAccounts,
|
||||
findUserByEmail
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user