deconflict merge

This commit is contained in:
Scott Wilson
2025-04-16 15:40:18 -07:00
203 changed files with 3233 additions and 582 deletions
@@ -11,7 +11,8 @@ export enum AppConnection {
Postgres = "postgres",
MsSql = "mssql",
Camunda = "camunda",
Windmill = "windmill"
Windmill = "windmill",
Auth0 = "auth0"
}
export enum AWSRegion {
@@ -16,6 +16,7 @@ import {
TAppConnectionCredentialsValidator,
TAppConnectionTransitionCredentialsToPlatform
} from "./app-connection-types";
import { Auth0ConnectionMethod, getAuth0ConnectionListItem, validateAuth0ConnectionCredentials } from "./auth0";
import { AwsConnectionMethod, getAwsConnectionListItem, validateAwsConnectionCredentials } from "./aws";
import {
AzureAppConfigurationConnectionMethod,
@@ -69,7 +70,8 @@ export const listAppConnectionOptions = () => {
getPostgresConnectionListItem(),
getMsSqlConnectionListItem(),
getCamundaConnectionListItem(),
getWindmillConnectionListItem()
getWindmillConnectionListItem(),
getAuth0ConnectionListItem()
].sort((a, b) => a.name.localeCompare(b.name));
};
@@ -115,26 +117,29 @@ export const decryptAppConnectionCredentials = async ({
return JSON.parse(decryptedPlainTextBlob.toString()) as TAppConnection["credentials"];
};
const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TAppConnectionCredentialsValidator> = {
[AppConnection.AWS]: validateAwsConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Databricks]: validateDatabricksConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.GitHub]: validateGitHubConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.GCP]: validateGcpConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.AzureKeyVault]: validateAzureKeyVaultConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.AzureAppConfiguration]:
validateAzureAppConfigurationConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.TerraformCloud]: validateTerraformCloudConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Camunda]: validateCamundaConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Vercel]: validateVercelConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Windmill]: validateWindmillConnectionCredentials as TAppConnectionCredentialsValidator
};
export const validateAppConnectionCredentials = async (
appConnection: TAppConnectionConfig
): Promise<TAppConnection["credentials"]> => VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection);
): Promise<TAppConnection["credentials"]> => {
const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TAppConnectionCredentialsValidator> = {
[AppConnection.AWS]: validateAwsConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Databricks]: validateDatabricksConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.GitHub]: validateGitHubConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.GCP]: validateGcpConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.AzureKeyVault]: validateAzureKeyVaultConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.AzureAppConfiguration]:
validateAzureAppConfigurationConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Camunda]: validateCamundaConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Vercel]: validateVercelConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.TerraformCloud]: validateTerraformCloudConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Auth0]: validateAuth0ConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Windmill]: validateWindmillConnectionCredentials as TAppConnectionCredentialsValidator
};
return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection);
};
export const getAppConnectionMethodName = (method: TAppConnection["method"]) => {
switch (method) {
@@ -163,6 +168,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
return "Username & Password";
case WindmillConnectionMethod.AccessToken:
return "Access Token";
case Auth0ConnectionMethod.ClientCredentials:
return "Client Credentials";
default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`Unhandled App Connection Method: ${method}`);
@@ -206,5 +213,6 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record<
[AppConnection.TerraformCloud]: platformManagedCredentialsNotSupported,
[AppConnection.Camunda]: platformManagedCredentialsNotSupported,
[AppConnection.Vercel]: platformManagedCredentialsNotSupported,
[AppConnection.Windmill]: platformManagedCredentialsNotSupported
[AppConnection.Windmill]: platformManagedCredentialsNotSupported,
[AppConnection.Auth0]: platformManagedCredentialsNotSupported
};
@@ -13,5 +13,6 @@ export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
[AppConnection.Postgres]: "PostgreSQL",
[AppConnection.MsSql]: "Microsoft SQL Server",
[AppConnection.Camunda]: "Camunda",
[AppConnection.Windmill]: "Windmill"
[AppConnection.Windmill]: "Windmill",
[AppConnection.Auth0]: "Auth0"
};
@@ -14,6 +14,7 @@ import {
TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM,
validateAppConnectionCredentials
} from "@app/services/app-connection/app-connection-fns";
import { auth0ConnectionService } from "@app/services/app-connection/auth0/auth0-connection-service";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TAppConnectionDALFactory } from "./app-connection-dal";
@@ -27,6 +28,7 @@ import {
TUpdateAppConnectionDTO,
TValidateAppConnectionCredentialsSchema
} from "./app-connection-types";
import { ValidateAuth0ConnectionCredentialsSchema } from "./auth0";
import { ValidateAwsConnectionCredentialsSchema } from "./aws";
import { awsConnectionService } from "./aws/aws-connection-service";
import { ValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration";
@@ -71,7 +73,8 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
[AppConnection.Postgres]: ValidatePostgresConnectionCredentialsSchema,
[AppConnection.MsSql]: ValidateMsSqlConnectionCredentialsSchema,
[AppConnection.Camunda]: ValidateCamundaConnectionCredentialsSchema,
[AppConnection.Windmill]: ValidateWindmillConnectionCredentialsSchema
[AppConnection.Windmill]: ValidateWindmillConnectionCredentialsSchema,
[AppConnection.Auth0]: ValidateAuth0ConnectionCredentialsSchema
};
export const appConnectionServiceFactory = ({
@@ -446,6 +449,7 @@ export const appConnectionServiceFactory = ({
terraformCloud: terraformCloudConnectionService(connectAppConnectionById),
camunda: camundaConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
vercel: vercelConnectionService(connectAppConnectionById),
windmill: windmillConnectionService(connectAppConnectionById)
windmill: windmillConnectionService(connectAppConnectionById),
auth0: auth0ConnectionService(connectAppConnectionById, appConnectionDAL, kmsService)
};
};
@@ -3,6 +3,12 @@ import { TSqlConnectionConfig } from "@app/services/app-connection/shared/sql/sq
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { AWSRegion } from "./app-connection-enums";
import {
TAuth0Connection,
TAuth0ConnectionConfig,
TAuth0ConnectionInput,
TValidateAuth0ConnectionCredentialsSchema
} from "./auth0";
import {
TAwsConnection,
TAwsConnectionConfig,
@@ -90,6 +96,7 @@ export type TAppConnection = { id: string } & (
| TMsSqlConnection
| TCamundaConnection
| TWindmillConnection
| TAuth0Connection
);
export type TAppConnectionRaw = NonNullable<Awaited<ReturnType<TAppConnectionDALFactory["findById"]>>>;
@@ -110,6 +117,7 @@ export type TAppConnectionInput = { id: string } & (
| TMsSqlConnectionInput
| TCamundaConnectionInput
| TWindmillConnectionInput
| TAuth0ConnectionInput
);
export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput;
@@ -135,7 +143,8 @@ export type TAppConnectionConfig =
| TVercelConnectionConfig
| TSqlConnectionConfig
| TCamundaConnectionConfig
| TWindmillConnectionConfig;
| TWindmillConnectionConfig
| TAuth0ConnectionConfig;
export type TValidateAppConnectionCredentialsSchema =
| TValidateAwsConnectionCredentialsSchema
@@ -150,7 +159,8 @@ export type TValidateAppConnectionCredentialsSchema =
| TValidateCamundaConnectionCredentialsSchema
| TValidateTerraformCloudConnectionCredentialsSchema
| TValidateVercelConnectionCredentialsSchema
| TValidateWindmillConnectionCredentialsSchema;
| TValidateWindmillConnectionCredentialsSchema
| TValidateAuth0ConnectionCredentialsSchema;
export type TListAwsConnectionKmsKeys = {
connectionId: string;
@@ -0,0 +1,3 @@
export enum Auth0ConnectionMethod {
ClientCredentials = "client-credentials"
}
@@ -0,0 +1,97 @@
import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { encryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { Auth0ConnectionMethod } from "./auth0-connection-enums";
import { TAuth0AccessTokenResponse, TAuth0Connection, TAuth0ConnectionConfig } from "./auth0-connection-types";
export const getAuth0ConnectionListItem = () => {
return {
name: "Auth0" as const,
app: AppConnection.Auth0 as const,
methods: Object.values(Auth0ConnectionMethod) as [Auth0ConnectionMethod.ClientCredentials]
};
};
const authorizeAuth0Connection = async ({
clientId,
clientSecret,
domain,
audience
}: TAuth0ConnectionConfig["credentials"]) => {
const instanceUrl = domain.startsWith("http") ? domain : `https://${domain}`;
await blockLocalAndPrivateIpAddresses(instanceUrl);
const { data } = await request.request<TAuth0AccessTokenResponse>({
method: "POST",
url: `${removeTrailingSlash(instanceUrl)}/oauth/token`,
headers: { "content-type": "application/x-www-form-urlencoded" },
data: new URLSearchParams({
grant_type: "client_credentials", // this will need to be resolved if we support methods other than client credentials
client_id: clientId,
client_secret: clientSecret,
audience
})
});
if (data.token_type !== "Bearer") {
throw new Error(`Unhandled token type: ${data.token_type}`);
}
return {
accessToken: data.access_token,
// cap token lifespan to 10 minutes
expiresAt: Math.min(data.expires_in * 1000, 600000) + Date.now()
};
};
export const getAuth0ConnectionAccessToken = async (
{ id, orgId, credentials }: TAuth0Connection,
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
const { expiresAt, accessToken } = credentials;
// get new token if expired or less than 5 minutes until expiry
if (Date.now() < expiresAt - 300000) {
return accessToken;
}
const authData = await authorizeAuth0Connection(credentials);
const updatedCredentials: TAuth0Connection["credentials"] = {
...credentials,
...authData
};
const encryptedCredentials = await encryptAppConnectionCredentials({
credentials: updatedCredentials,
orgId,
kmsService
});
await appConnectionDAL.updateById(id, { encryptedCredentials });
return authData.accessToken;
};
export const validateAuth0ConnectionCredentials = async ({ credentials }: TAuth0ConnectionConfig) => {
try {
const { accessToken, expiresAt } = await authorizeAuth0Connection(credentials);
return {
...credentials,
accessToken,
expiresAt
};
} catch (e: unknown) {
throw new BadRequestError({
message: (e as Error).message ?? `Unable to validate connection: verify credentials`
});
}
};
@@ -0,0 +1,94 @@
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 { Auth0ConnectionMethod } from "./auth0-connection-enums";
export const Auth0ConnectionClientCredentialsInputCredentialsSchema = z.object({
domain: z.string().trim().min(1, "Domain required").describe(AppConnections.CREDENTIALS.AUTH0_CONNECTION.domain),
clientId: z
.string()
.trim()
.min(1, "Client ID required")
.describe(AppConnections.CREDENTIALS.AUTH0_CONNECTION.clientId),
clientSecret: z
.string()
.trim()
.min(1, "Client Secret required")
.describe(AppConnections.CREDENTIALS.AUTH0_CONNECTION.clientSecret),
audience: z
.string()
.trim()
.url()
.min(1, "Audience required")
.describe(AppConnections.CREDENTIALS.AUTH0_CONNECTION.audience)
});
const Auth0ConnectionClientCredentialsOutputCredentialsSchema = z
.object({
accessToken: z.string(),
expiresAt: z.number()
})
.merge(Auth0ConnectionClientCredentialsInputCredentialsSchema);
const BaseAuth0ConnectionSchema = BaseAppConnectionSchema.extend({
app: z.literal(AppConnection.Auth0)
});
export const Auth0ConnectionSchema = z.intersection(
BaseAuth0ConnectionSchema,
z.discriminatedUnion("method", [
z.object({
method: z.literal(Auth0ConnectionMethod.ClientCredentials),
credentials: Auth0ConnectionClientCredentialsOutputCredentialsSchema
})
])
);
export const SanitizedAuth0ConnectionSchema = z.discriminatedUnion("method", [
BaseAuth0ConnectionSchema.extend({
method: z.literal(Auth0ConnectionMethod.ClientCredentials),
credentials: Auth0ConnectionClientCredentialsInputCredentialsSchema.pick({
domain: true,
clientId: true,
audience: true
})
})
]);
export const ValidateAuth0ConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z
.literal(Auth0ConnectionMethod.ClientCredentials)
.describe(AppConnections.CREATE(AppConnection.Auth0).method),
credentials: Auth0ConnectionClientCredentialsInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.Auth0).credentials
)
})
]);
export const CreateAuth0ConnectionSchema = ValidateAuth0ConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.Auth0)
);
export const UpdateAuth0ConnectionSchema = z
.object({
credentials: Auth0ConnectionClientCredentialsInputCredentialsSchema.optional().describe(
AppConnections.UPDATE(AppConnection.Auth0).credentials
)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Auth0));
export const Auth0ConnectionListItemSchema = z.object({
name: z.literal("Auth0"),
app: z.literal(AppConnection.Auth0),
// the below is preferable but currently breaks with our zod to json schema parser
// methods: z.tuple([z.literal(AwsConnectionMethod.ServicePrincipal), z.literal(AwsConnectionMethod.AccessKey)]),
methods: z.nativeEnum(Auth0ConnectionMethod).array()
});
@@ -0,0 +1,71 @@
import { request } from "@app/lib/config/request";
import { OrgServiceActor } from "@app/lib/types";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { getAuth0ConnectionAccessToken } from "@app/services/app-connection/auth0/auth0-connection-fns";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TAuth0Connection, TAuth0ListClient, TAuth0ListClientsResponse } from "./auth0-connection-types";
type TGetAppConnectionFunc = (
app: AppConnection,
connectionId: string,
actor: OrgServiceActor
) => Promise<TAuth0Connection>;
const listAuth0Clients = async (
appConnection: TAuth0Connection,
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
const accessToken = await getAuth0ConnectionAccessToken(appConnection, appConnectionDAL, kmsService);
const { audience, clientId: connectionClientId } = appConnection.credentials;
await blockLocalAndPrivateIpAddresses(audience);
const clients: TAuth0ListClient[] = [];
let hasMore = true;
let page = 0;
while (hasMore) {
// eslint-disable-next-line no-await-in-loop
const { data: clientsPage } = await request.get<TAuth0ListClientsResponse>(`${audience}clients`, {
headers: {
Authorization: `Bearer ${accessToken}`,
"Accept-Encoding": "application/json"
},
params: {
include_totals: true,
per_page: 100,
page
}
});
clients.push(...clientsPage.clients);
page += 1;
hasMore = clientsPage.total > clients.length;
}
return (
clients.filter((client) => client.client_id !== connectionClientId && client.name !== "All Applications") ?? []
);
};
export const auth0ConnectionService = (
getAppConnection: TGetAppConnectionFunc,
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
const listClients = async (connectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.Auth0, connectionId, actor);
const clients = await listAuth0Clients(appConnection, appConnectionDAL, kmsService);
return clients.map((client) => ({ id: client.client_id, name: client.name }));
};
return {
listClients
};
};
@@ -0,0 +1,39 @@
import { z } from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
Auth0ConnectionSchema,
CreateAuth0ConnectionSchema,
ValidateAuth0ConnectionCredentialsSchema
} from "./auth0-connection-schemas";
export type TAuth0Connection = z.infer<typeof Auth0ConnectionSchema>;
export type TAuth0ConnectionInput = z.infer<typeof CreateAuth0ConnectionSchema> & {
app: AppConnection.Auth0;
};
export type TValidateAuth0ConnectionCredentialsSchema = typeof ValidateAuth0ConnectionCredentialsSchema;
export type TAuth0ConnectionConfig = DiscriminativePick<TAuth0Connection, "method" | "app" | "credentials"> & {
orgId: string;
};
export type TAuth0AccessTokenResponse = {
access_token: string;
expires_in: number;
scope: string;
token_type: string;
};
export type TAuth0ListClient = {
name: string;
client_id: string;
};
export type TAuth0ListClientsResponse = {
total: number;
clients: TAuth0ListClient[];
};
@@ -0,0 +1,4 @@
export * from "./auth0-connection-enums";
export * from "./auth0-connection-fns";
export * from "./auth0-connection-schemas";
export * from "./auth0-connection-types";
@@ -1,6 +1,7 @@
import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { encryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
@@ -26,6 +27,8 @@ const authorizeDatabricksConnection = async ({
clientSecret,
workspaceUrl
}: Pick<TDatabricksConnection["credentials"], "workspaceUrl" | "clientId" | "clientSecret">) => {
await blockLocalAndPrivateIpAddresses(workspaceUrl);
const { data } = await request.post<TAuthorizeDatabricksConnection>(
`${removeTrailingSlash(workspaceUrl)}/oidc/v1/token`,
"grant_type=client_credentials&scope=all-apis",
@@ -16,7 +16,7 @@ export const DatabricksConnectionServicePrincipalInputCredentialsSchema = z.obje
workspaceUrl: z.string().trim().url().min(1, "Workspace URL required")
});
export const DatabricksConnectionServicePrincipalOutputCredentialsSchema = z
const DatabricksConnectionServicePrincipalOutputCredentialsSchema = z
.object({
accessToken: z.string(),
expiresAt: z.number()
+37 -20
View File
@@ -2,7 +2,7 @@ import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";
import { Knex } from "knex";
import { TUsers, UserDeviceSchema } from "@app/db/schemas";
import { OrgMembershipRole, TUsers, UserDeviceSchema } from "@app/db/schemas";
import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns";
import { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request";
@@ -174,20 +174,25 @@ export const authLoginServiceFactory = ({
const userEnc = await userDAL.findUserEncKeyByUsername({
username: email
});
const serverCfg = await getServerCfg();
if (!userEnc || (userEnc && !userEnc.isAccepted)) {
throw new Error("Failed to find user");
}
if (
serverCfg.enabledLoginMethods &&
!serverCfg.enabledLoginMethods.includes(LoginMethod.EMAIL) &&
!providerAuthToken
) {
throw new BadRequestError({
message: "Login with email is disabled by administrator."
});
}
if (!userEnc || (userEnc && !userEnc.isAccepted)) {
throw new Error("Failed to find user");
// bypass server configuration when user is an organization admin - this is to prevent lockout
const userOrgs = await orgDAL.findAllOrgsByUserId(userEnc.userId);
if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) {
throw new BadRequestError({
message: "Login with email is disabled by administrator."
});
}
}
if (!userEnc.authMethods?.includes(AuthMethod.EMAIL)) {
@@ -573,28 +578,40 @@ export const authLoginServiceFactory = ({
switch (authMethod) {
case AuthMethod.GITHUB: {
if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITHUB)) {
throw new BadRequestError({
message: "Login with Github is disabled by administrator.",
name: "Oauth 2 login"
});
// bypass server configuration when user is an organization admin - this is to prevent lockout
const userOrgs = await orgDAL.findAllOrgsByUserId(user.id);
if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) {
throw new BadRequestError({
message: "Login with Github is disabled by administrator.",
name: "Oauth 2 login"
});
}
}
break;
}
case AuthMethod.GOOGLE: {
if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GOOGLE)) {
throw new BadRequestError({
message: "Login with Google is disabled by administrator.",
name: "Oauth 2 login"
});
// bypass server configuration when user is an organization admin - this is to prevent lockout
const userOrgs = await orgDAL.findAllOrgsByUserId(user.id);
if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) {
throw new BadRequestError({
message: "Login with Google is disabled by administrator.",
name: "Oauth 2 login"
});
}
}
break;
}
case AuthMethod.GITLAB: {
if (!serverCfg.enabledLoginMethods.includes(LoginMethod.GITLAB)) {
throw new BadRequestError({
message: "Login with Gitlab is disabled by administrator.",
name: "Oauth 2 login"
});
// bypass server configuration when user is an organization admin - this is to prevent lockout
const userOrgs = await orgDAL.findAllOrgsByUserId(user.id);
if (!userOrgs.some((org) => org.userRole === OrgMembershipRole.Admin)) {
throw new BadRequestError({
message: "Login with Gitlab is disabled by administrator.",
name: "Oauth 2 login"
});
}
}
break;
}
+5 -2
View File
@@ -96,7 +96,9 @@ export const orgDALFactory = (db: TDbClient) => {
};
// special query
const findAllOrgsByUserId = async (userId: string): Promise<(TOrganizations & { orgAuthMethod: string })[]> => {
const findAllOrgsByUserId = async (
userId: string
): Promise<(TOrganizations & { orgAuthMethod: string; userRole: string })[]> => {
try {
const org = (await db
.replicaNode()(TableName.OrgMembership)
@@ -117,6 +119,7 @@ export const orgDALFactory = (db: TDbClient) => {
);
})
.select(selectAllTableCols(TableName.Organization))
.select(db.ref("role").withSchema(TableName.OrgMembership).as("userRole"))
.select(
db.raw(`
CASE
@@ -125,7 +128,7 @@ export const orgDALFactory = (db: TDbClient) => {
ELSE ''
END as "orgAuthMethod"
`)
)) as (TOrganizations & { orgAuthMethod: string })[];
)) as (TOrganizations & { orgAuthMethod: string; userRole: string })[];
return org;
} catch (error) {
+2 -1
View File
@@ -16,5 +16,6 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
allowSecretSharingOutsideOrganization: true,
shouldUseNewPrivilegeSystem: true,
privilegeUpgradeInitiatedByUsername: true,
privilegeUpgradeInitiatedAt: true
privilegeUpgradeInitiatedAt: true,
bypassOrgAuthEnabled: true
});
+4 -2
View File
@@ -349,7 +349,8 @@ export const orgServiceFactory = ({
defaultMembershipRoleSlug,
enforceMfa,
selectedMfaMethod,
allowSecretSharingOutsideOrganization
allowSecretSharingOutsideOrganization,
bypassOrgAuthEnabled
}
}: TUpdateOrgDTO) => {
const appCfg = getConfig();
@@ -429,7 +430,8 @@ export const orgServiceFactory = ({
defaultMembershipRole,
enforceMfa,
selectedMfaMethod,
allowSecretSharingOutsideOrganization
allowSecretSharingOutsideOrganization,
bypassOrgAuthEnabled
});
if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
return org;
+1
View File
@@ -73,6 +73,7 @@ export type TUpdateOrgDTO = {
enforceMfa: boolean;
selectedMfaMethod: MfaMethod;
allowSecretSharingOutsideOrganization: boolean;
bypassOrgAuthEnabled: boolean;
}>;
} & TOrgPermission;
@@ -86,6 +86,7 @@ import {
TProjectAccessRequestDTO,
TSearchProjectsDTO,
TToggleProjectAutoCapitalizationDTO,
TToggleProjectDeleteProtectionDTO,
TUpdateAuditLogsRetentionDTO,
TUpdateProjectDTO,
TUpdateProjectKmsDTO,
@@ -482,6 +483,12 @@ export const projectServiceFactory = ({
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Project);
if (project.hasDeleteProtection) {
throw new ForbiddenRequestError({
message: "Project delete protection is enabled"
});
}
const deletedProject = await projectDAL.transaction(async (tx) => {
// delete these so that project custom roles can be deleted in cascade effect
// direct deletion of project without these will cause fk error
@@ -616,6 +623,7 @@ export const projectServiceFactory = ({
description: update.description,
autoCapitalization: update.autoCapitalization,
enforceCapitalization: update.autoCapitalization,
hasDeleteProtection: update.hasDeleteProtection,
slug: update.slug
});
@@ -648,6 +656,29 @@ export const projectServiceFactory = ({
return updatedProject;
};
const toggleDeleteProtection = async ({
projectId,
actor,
actorId,
actorOrgId,
actorAuthMethod,
hasDeleteProtection
}: TToggleProjectDeleteProtectionDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.Any
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings);
const updatedProject = await projectDAL.updateById(projectId, { hasDeleteProtection });
return updatedProject;
};
const updateVersionLimit = async ({
actor,
actorId,
@@ -1499,6 +1530,7 @@ export const projectServiceFactory = ({
getProjectUpgradeStatus,
getAProject,
toggleAutoCapitalization,
toggleDeleteProtection,
updateName,
upgradeProject,
listProjectCas,
@@ -66,6 +66,10 @@ export type TToggleProjectAutoCapitalizationDTO = {
autoCapitalization: boolean;
} & TProjectPermission;
export type TToggleProjectDeleteProtectionDTO = {
hasDeleteProtection: boolean;
} & TProjectPermission;
export type TUpdateProjectVersionLimitDTO = {
pitVersionLimit: number;
workspaceSlug: string;
@@ -86,6 +90,7 @@ export type TUpdateProjectDTO = {
name?: string;
description?: string;
autoCapitalization?: boolean;
hasDeleteProtection?: boolean;
slug?: string;
};
} & Omit<TProjectPermission, "projectId">;
@@ -10,6 +10,7 @@ import { TSecretVersionDALFactory } from "../secret/secret-version-dal";
import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal";
import { TSecretSharingDALFactory } from "../secret-sharing/secret-sharing-dal";
import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal";
import { TServiceTokenServiceFactory } from "../service-token/service-token-service";
type TDailyResourceCleanUpQueueServiceFactoryDep = {
auditLogDAL: Pick<TAuditLogDALFactory, "pruneAuditLog">;
@@ -21,6 +22,7 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = {
secretFolderVersionDAL: Pick<TSecretFolderVersionDALFactory, "pruneExcessVersions">;
snapshotDAL: Pick<TSnapshotDALFactory, "pruneExcessSnapshots">;
secretSharingDAL: Pick<TSecretSharingDALFactory, "pruneExpiredSharedSecrets" | "pruneExpiredSecretRequests">;
serviceTokenService: Pick<TServiceTokenServiceFactory, "notifyExpiringTokens">;
queueService: TQueueServiceFactory;
};
@@ -36,7 +38,8 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
identityAccessTokenDAL,
secretSharingDAL,
secretVersionV2DAL,
identityUniversalAuthClientSecretDAL
identityUniversalAuthClientSecretDAL,
serviceTokenService
}: TDailyResourceCleanUpQueueServiceFactoryDep) => {
queueService.start(QueueName.DailyResourceCleanUp, async () => {
logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`);
@@ -50,6 +53,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
await secretVersionDAL.pruneExcessVersions();
await secretVersionV2DAL.pruneExcessVersions();
await secretFolderVersionDAL.pruneExcessVersions();
await serviceTokenService.notifyExpiringTokens();
logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`);
});
@@ -819,10 +819,14 @@ export const secretImportServiceFactory = ({
actorOrgId,
actionProjectType: ActionProjectType.SecretManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.SecretImports, { environment, secretPath })
);
if (
permission.cannot(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.SecretImports, { environment, secretPath })
)
) {
return [];
}
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder) return [];
@@ -28,5 +28,36 @@ export const serviceTokenDALFactory = (db: TDbClient) => {
}
};
return { ...stOrm, findById };
const findExpiringTokens = async (tx?: Knex, batchSize = 500, offset = 0) => {
try {
const batch: { name: string; projectName: string; createdByEmail: string; id: string; projectId: string }[] =
await (tx || db.replicaNode())(TableName.ServiceToken)
.leftJoin<TUsers>(
TableName.Users,
`${TableName.Users}.id`,
db.raw(`${TableName.ServiceToken}."createdBy"::uuid`)
)
.join(TableName.Project, `${TableName.Project}.id`, `${TableName.ServiceToken}.projectId`)
.whereRaw(
`${TableName.ServiceToken}."expiresAt" < NOW() + INTERVAL '1 day' AND ${TableName.ServiceToken}."expiryNotificationSent" = false`
)
.whereNotNull(`${TableName.Users}.email`)
.select(
db.ref("id").withSchema(TableName.ServiceToken),
db.ref("name").withSchema(TableName.ServiceToken),
db.ref("projectId").withSchema(TableName.ServiceToken),
db.ref("createdBy").withSchema(TableName.ServiceToken),
db.ref("email").withSchema(TableName.Users).as("createdByEmail"),
db.ref("name").withSchema(TableName.Project).as("projectName")
)
.limit(batchSize)
.offset(offset);
return batch;
} catch (err) {
throw new DatabaseError({ error: err, name: "FindExpiredTokens" });
}
};
return { ...stOrm, findById, findExpiringTokens };
};
@@ -12,11 +12,13 @@ import {
} from "@app/ee/services/permission/project-permission";
import { getConfig } from "@app/lib/config/env";
import { ForbiddenRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { TAccessTokenQueueServiceFactory } from "../access-token-queue/access-token-queue";
import { ActorType } from "../auth/auth-type";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TUserDALFactory } from "../user/user-dal";
import { TServiceTokenDALFactory } from "./service-token-dal";
import {
@@ -33,6 +35,7 @@ type TServiceTokenServiceFactoryDep = {
projectEnvDAL: Pick<TProjectEnvDALFactory, "findBySlugs">;
projectDAL: Pick<TProjectDALFactory, "findById">;
accessTokenQueue: Pick<TAccessTokenQueueServiceFactory, "updateServiceTokenStatus">;
smtpService: Pick<TSmtpService, "sendMail">;
};
export type TServiceTokenServiceFactory = ReturnType<typeof serviceTokenServiceFactory>;
@@ -43,7 +46,8 @@ export const serviceTokenServiceFactory = ({
permissionService,
projectEnvDAL,
projectDAL,
accessTokenQueue
accessTokenQueue,
smtpService
}: TServiceTokenServiceFactoryDep) => {
const createServiceToken = async ({
iv,
@@ -185,11 +189,56 @@ export const serviceTokenServiceFactory = ({
return { ...serviceToken, lastUsed: new Date(), orgId: project.orgId };
};
const notifyExpiringTokens = async () => {
const appCfg = getConfig();
let processedCount = 0;
let hasMoreRecords = true;
let offset = 0;
const batchSize = 500;
while (hasMoreRecords) {
// eslint-disable-next-line no-await-in-loop
const expiringTokens = await serviceTokenDAL.findExpiringTokens(undefined, batchSize, offset);
if (expiringTokens.length === 0) {
hasMoreRecords = false;
break;
}
// eslint-disable-next-line no-await-in-loop
await Promise.all(
expiringTokens.map(async (token) => {
try {
await smtpService.sendMail({
recipients: [token.createdByEmail],
subjectLine: "Service Token Expiry Notice",
template: SmtpTemplates.ServiceTokenExpired,
substitutions: {
tokenName: token.name,
projectName: token.projectName,
url: `${appCfg.SITE_URL}/secret-manager/${token.projectId}/access-management?selectedTab=service-tokens`
}
});
await serviceTokenDAL.update({ id: token.id }, { expiryNotificationSent: true });
} catch (error) {
logger.error(error, `Failed to send expiration notification for token ${token.id}:`);
}
})
);
processedCount += expiringTokens.length;
offset += batchSize;
}
return processedCount;
};
return {
createServiceToken,
deleteServiceToken,
getServiceToken,
getProjectServiceTokens,
fnValidateServiceToken
fnValidateServiceToken,
notifyExpiringTokens
};
};
+2 -1
View File
@@ -43,7 +43,8 @@ export enum SmtpTemplates {
SecretRequestCompleted = "secretRequestCompleted.handlebars",
SecretRotationFailed = "secretRotationFailed.handlebars",
ProjectAccessRequest = "projectAccess.handlebars",
OrgAdminProjectDirectAccess = "orgAdminProjectGrantAccess.handlebars"
OrgAdminProjectDirectAccess = "orgAdminProjectGrantAccess.handlebars",
ServiceTokenExpired = "serviceTokenExpired.handlebars"
}
export enum SmtpHost {
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>Service Token Expiring Soon</title>
</head>
<body>
<h2>Service Token Expiry Notice</h2>
<p>Your service token <strong>"{{tokenName}}"</strong> will expire within 24 hours.</p>
<p>This token is currently being used on project "{{projectName}}". If this token is still needed for your workflow, please create a new one before it expires.</p>
<a href="{{url}}">Create New Token</a>
{{emailFooter}}
</body>
</html>