mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-12 00:29:04 +00:00
Merge pull request #4379 from Infisical/daniel/google-sso-enforcement
feat(sso): enforce google SSO on org-level
This commit is contained in:
Vendored
+1
@@ -148,6 +148,7 @@ declare module "fastify" {
|
||||
interface Session {
|
||||
callbackPort: string;
|
||||
isAdminLogin: boolean;
|
||||
orgSlug?: string;
|
||||
}
|
||||
|
||||
interface FastifyRequest {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
const GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME = "googleSsoAuthEnforced";
|
||||
const GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME = "googleSsoAuthLastUsed";
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasGoogleSsoAuthEnforcedColumn = await knex.schema.hasColumn(
|
||||
TableName.Organization,
|
||||
GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME
|
||||
);
|
||||
const hasGoogleSsoAuthLastUsedColumn = await knex.schema.hasColumn(
|
||||
TableName.Organization,
|
||||
GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME
|
||||
);
|
||||
|
||||
await knex.schema.alterTable(TableName.Organization, (table) => {
|
||||
if (!hasGoogleSsoAuthEnforcedColumn)
|
||||
table.boolean(GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME).defaultTo(false).notNullable();
|
||||
if (!hasGoogleSsoAuthLastUsedColumn) table.timestamp(GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME).nullable();
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasGoogleSsoAuthEnforcedColumn = await knex.schema.hasColumn(
|
||||
TableName.Organization,
|
||||
GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME
|
||||
);
|
||||
|
||||
const hasGoogleSsoAuthLastUsedColumn = await knex.schema.hasColumn(
|
||||
TableName.Organization,
|
||||
GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME
|
||||
);
|
||||
|
||||
await knex.schema.alterTable(TableName.Organization, (table) => {
|
||||
if (hasGoogleSsoAuthEnforcedColumn) table.dropColumn(GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME);
|
||||
if (hasGoogleSsoAuthLastUsedColumn) table.dropColumn(GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME);
|
||||
});
|
||||
}
|
||||
@@ -36,7 +36,9 @@ export const OrganizationsSchema = z.object({
|
||||
scannerProductEnabled: z.boolean().default(true).nullable().optional(),
|
||||
shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(),
|
||||
maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(),
|
||||
maxSharedSecretViewLimit: z.number().nullable().optional()
|
||||
maxSharedSecretViewLimit: z.number().nullable().optional(),
|
||||
googleSsoAuthEnforced: z.boolean().default(false),
|
||||
googleSsoAuthLastUsed: z.date().nullable().optional()
|
||||
});
|
||||
|
||||
export type TOrganizations = z.infer<typeof OrganizationsSchema>;
|
||||
|
||||
@@ -32,6 +32,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
|
||||
auditLogStreams: false,
|
||||
auditLogStreamLimit: 3,
|
||||
samlSSO: false,
|
||||
enforceGoogleSSO: false,
|
||||
hsm: false,
|
||||
oidcSSO: false,
|
||||
scim: false,
|
||||
|
||||
@@ -47,6 +47,7 @@ export type TFeatureSet = {
|
||||
auditLogStreamLimit: 3;
|
||||
githubOrgSync: false;
|
||||
samlSSO: false;
|
||||
enforceGoogleSSO: false;
|
||||
hsm: false;
|
||||
oidcSSO: false;
|
||||
secretAccessInsights: false;
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface TPermissionDALFactory {
|
||||
projectFavorites?: string[] | null | undefined;
|
||||
customRoleSlug?: string | null | undefined;
|
||||
orgAuthEnforced?: boolean | null | undefined;
|
||||
orgGoogleSsoAuthEnforced: boolean;
|
||||
} & {
|
||||
groups: {
|
||||
id: string;
|
||||
@@ -87,6 +88,7 @@ export interface TPermissionDALFactory {
|
||||
}[];
|
||||
orgId: string;
|
||||
orgAuthEnforced: boolean | null | undefined;
|
||||
orgGoogleSsoAuthEnforced: boolean;
|
||||
orgRole: OrgMembershipRole;
|
||||
userId: string;
|
||||
projectId: string;
|
||||
@@ -350,6 +352,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
||||
db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"),
|
||||
db.ref("permissions").withSchema(TableName.OrgRoles),
|
||||
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
|
||||
db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"),
|
||||
db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
|
||||
db.ref("groupId").withSchema("userGroups"),
|
||||
db.ref("groupOrgId").withSchema("userGroups"),
|
||||
@@ -369,6 +372,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
||||
OrgMembershipsSchema.extend({
|
||||
permissions: z.unknown(),
|
||||
orgAuthEnforced: z.boolean().optional().nullable(),
|
||||
orgGoogleSsoAuthEnforced: z.boolean(),
|
||||
bypassOrgAuthEnabled: z.boolean(),
|
||||
customRoleSlug: z.string().optional().nullable(),
|
||||
shouldUseNewPrivilegeSystem: z.boolean()
|
||||
@@ -988,6 +992,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
||||
db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"),
|
||||
db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"),
|
||||
db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"),
|
||||
db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"),
|
||||
db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"),
|
||||
db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"),
|
||||
db.ref("orgId").withSchema(TableName.Project),
|
||||
@@ -1003,6 +1008,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
||||
orgId,
|
||||
username,
|
||||
orgAuthEnforced,
|
||||
orgGoogleSsoAuthEnforced,
|
||||
orgRole,
|
||||
membershipId,
|
||||
groupMembershipId,
|
||||
@@ -1016,6 +1022,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
|
||||
}) => ({
|
||||
orgId,
|
||||
orgAuthEnforced,
|
||||
orgGoogleSsoAuthEnforced,
|
||||
orgRole: orgRole as OrgMembershipRole,
|
||||
userId,
|
||||
projectId,
|
||||
|
||||
@@ -121,6 +121,7 @@ function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) {
|
||||
function validateOrgSSO(
|
||||
actorAuthMethod: ActorAuthMethod,
|
||||
isOrgSsoEnforced: TOrganizations["authEnforced"],
|
||||
isOrgGoogleSsoEnforced: TOrganizations["googleSsoAuthEnforced"],
|
||||
isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"],
|
||||
orgRole: OrgMembershipRole
|
||||
) {
|
||||
@@ -128,10 +129,16 @@ function validateOrgSSO(
|
||||
throw new UnauthorizedError({ name: "No auth method defined" });
|
||||
}
|
||||
|
||||
if (isOrgSsoEnforced && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) {
|
||||
if ((isOrgSsoEnforced || isOrgGoogleSsoEnforced) && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) {
|
||||
return;
|
||||
}
|
||||
|
||||
// case: google sso is enforced, but the actor is not using google sso
|
||||
if (isOrgGoogleSsoEnforced && actorAuthMethod !== null && actorAuthMethod !== AuthMethod.GOOGLE) {
|
||||
throw new ForbiddenRequestError({ name: "Org auth enforced. Cannot access org-scoped resource" });
|
||||
}
|
||||
|
||||
// case: SAML SSO is enforced, but the actor is not using SAML SSO
|
||||
if (
|
||||
isOrgSsoEnforced &&
|
||||
actorAuthMethod !== null &&
|
||||
|
||||
@@ -146,6 +146,7 @@ export const permissionServiceFactory = ({
|
||||
validateOrgSSO(
|
||||
authMethod,
|
||||
membership.orgAuthEnforced,
|
||||
membership.orgGoogleSsoAuthEnforced,
|
||||
membership.bypassOrgAuthEnabled,
|
||||
membership.role as OrgMembershipRole
|
||||
);
|
||||
@@ -238,6 +239,7 @@ export const permissionServiceFactory = ({
|
||||
validateOrgSSO(
|
||||
authMethod,
|
||||
userProjectPermission.orgAuthEnforced,
|
||||
userProjectPermission.orgGoogleSsoAuthEnforced,
|
||||
userProjectPermission.bypassOrgAuthEnabled,
|
||||
userProjectPermission.orgRole
|
||||
);
|
||||
|
||||
@@ -279,6 +279,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
||||
name: GenericResourceNameSchema.optional(),
|
||||
slug: slugSchema({ max: 64 }).optional(),
|
||||
authEnforced: z.boolean().optional(),
|
||||
googleSsoAuthEnforced: z.boolean().optional(),
|
||||
scimEnabled: z.boolean().optional(),
|
||||
defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(),
|
||||
enforceMfa: z.boolean().optional(),
|
||||
|
||||
@@ -54,6 +54,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
|
||||
try {
|
||||
// @ts-expect-error this is because this is express type and not fastify
|
||||
const callbackPort = req.session.get("callbackPort");
|
||||
// @ts-expect-error this is because this is express type and not fastify
|
||||
const orgSlug = req.session.get("orgSlug");
|
||||
|
||||
const email = profile?.emails?.[0]?.value;
|
||||
if (!email)
|
||||
@@ -67,7 +69,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => {
|
||||
firstName: profile?.name?.givenName || "",
|
||||
lastName: profile?.name?.familyName || "",
|
||||
authMethod: AuthMethod.GOOGLE,
|
||||
callbackPort
|
||||
callbackPort,
|
||||
orgSlug
|
||||
});
|
||||
cb(null, { isUserCompleted, providerAuthToken });
|
||||
} catch (error) {
|
||||
@@ -215,6 +218,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
callback_port: z.string().optional(),
|
||||
org_slug: z.string().optional(),
|
||||
is_admin_login: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -223,12 +227,15 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
|
||||
},
|
||||
preValidation: [
|
||||
async (req, res) => {
|
||||
const { callback_port: callbackPort, is_admin_login: isAdminLogin } = req.query;
|
||||
const { callback_port: callbackPort, is_admin_login: isAdminLogin, org_slug: orgSlug } = req.query;
|
||||
// ensure fresh session state per login attempt
|
||||
await req.session.regenerate();
|
||||
if (callbackPort) {
|
||||
req.session.set("callbackPort", callbackPort);
|
||||
}
|
||||
if (orgSlug) {
|
||||
req.session.set("orgSlug", orgSlug);
|
||||
}
|
||||
if (isAdminLogin) {
|
||||
req.session.set("isAdminLogin", isAdminLogin);
|
||||
}
|
||||
|
||||
@@ -448,15 +448,34 @@ export const authLoginServiceFactory = ({
|
||||
|
||||
// Check if the user actually has access to the specified organization.
|
||||
const userOrgs = await orgDAL.findAllOrgsByUserId(user.id);
|
||||
const hasOrganizationMembership = userOrgs.some((org) => org.id === organizationId && org.userStatus !== "invited");
|
||||
|
||||
const selectedOrgMembership = userOrgs.find((org) => org.id === organizationId && org.userStatus !== "invited");
|
||||
|
||||
const selectedOrg = await orgDAL.findById(organizationId);
|
||||
|
||||
if (!hasOrganizationMembership) {
|
||||
if (!selectedOrgMembership) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: `User does not have access to the organization named ${selectedOrg?.name}`
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) {
|
||||
const canBypass = selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin;
|
||||
|
||||
if (!canBypass) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Google SSO is enforced for this organization. Please use Google SSO to login.",
|
||||
error: "GoogleSsoEnforced"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (decodedToken.authMethod === AuthMethod.GOOGLE) {
|
||||
await orgDAL.updateById(selectedOrg.id, {
|
||||
googleSsoAuthLastUsed: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled;
|
||||
const orgMfaMethod = selectedOrg.enforceMfa ? (selectedOrg.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined;
|
||||
const userMfaMethod = user.isMfaEnabled ? (user.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined;
|
||||
@@ -502,7 +521,8 @@ export const authLoginServiceFactory = ({
|
||||
selectedOrg.authEnforced &&
|
||||
selectedOrg.bypassOrgAuthEnabled &&
|
||||
!isAuthMethodSaml(decodedToken.authMethod) &&
|
||||
decodedToken.authMethod !== AuthMethod.OIDC
|
||||
decodedToken.authMethod !== AuthMethod.OIDC &&
|
||||
decodedToken.authMethod !== AuthMethod.GOOGLE
|
||||
) {
|
||||
await auditLogService.createAuditLog({
|
||||
orgId: organizationId,
|
||||
@@ -705,7 +725,7 @@ export const authLoginServiceFactory = ({
|
||||
/*
|
||||
* OAuth2 login for google,github, and other oauth2 provider
|
||||
* */
|
||||
const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => {
|
||||
const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort, orgSlug }: TOauthLoginDTO) => {
|
||||
// akhilmhdh: case sensitive email resolution
|
||||
const usersByUsername = await userDAL.findUserByUsername(email);
|
||||
let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0];
|
||||
@@ -759,6 +779,8 @@ export const authLoginServiceFactory = ({
|
||||
|
||||
const appCfg = getConfig();
|
||||
|
||||
let orgId = "";
|
||||
let orgName: undefined | string;
|
||||
if (!user) {
|
||||
// Create a new user based on oAuth
|
||||
if (!serverCfg?.allowSignUp) throw new BadRequestError({ message: "Sign up disabled", name: "Oauth 2 login" });
|
||||
@@ -784,7 +806,6 @@ export const authLoginServiceFactory = ({
|
||||
});
|
||||
|
||||
if (authMethod === AuthMethod.GITHUB && serverCfg.defaultAuthOrgId && !appCfg.isCloud) {
|
||||
let orgId = "";
|
||||
const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId);
|
||||
if (!defaultOrg) {
|
||||
throw new BadRequestError({
|
||||
@@ -824,11 +845,39 @@ export const authLoginServiceFactory = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (!orgId && orgSlug) {
|
||||
const org = await orgDAL.findOrgBySlug(orgSlug);
|
||||
|
||||
if (org) {
|
||||
// checks for the membership and only sets the orgId / orgName if the user is a member of the specified org
|
||||
const orgMembership = await orgDAL.findMembership({
|
||||
[`${TableName.OrgMembership}.userId` as "userId"]: user.id,
|
||||
[`${TableName.OrgMembership}.orgId` as "orgId"]: org.id,
|
||||
[`${TableName.OrgMembership}.isActive` as "isActive"]: true,
|
||||
[`${TableName.OrgMembership}.status` as "status"]: OrgMembershipStatus.Accepted
|
||||
});
|
||||
|
||||
if (orgMembership) {
|
||||
orgId = org.id;
|
||||
orgName = org.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isUserCompleted = user.isAccepted;
|
||||
const providerAuthToken = crypto.jwt().sign(
|
||||
{
|
||||
authTokenType: AuthTokenType.PROVIDER_TOKEN,
|
||||
userId: user.id,
|
||||
|
||||
...(orgId && orgSlug && orgName !== undefined
|
||||
? {
|
||||
organizationId: orgId,
|
||||
organizationName: orgName,
|
||||
organizationSlug: orgSlug
|
||||
}
|
||||
: {}),
|
||||
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
isEmailVerified: user.isEmailVerified,
|
||||
|
||||
@@ -32,6 +32,7 @@ export type TOauthLoginDTO = {
|
||||
lastName?: string;
|
||||
authMethod: AuthMethod;
|
||||
callbackPort?: string;
|
||||
orgSlug?: string;
|
||||
};
|
||||
|
||||
export type TOauthTokenExchangeDTO = {
|
||||
|
||||
@@ -8,6 +8,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
authEnforced: true,
|
||||
googleSsoAuthEnforced: true,
|
||||
scimEnabled: true,
|
||||
kmsDefaultKeyId: true,
|
||||
defaultMembershipRole: true,
|
||||
|
||||
@@ -364,6 +364,7 @@ export const orgServiceFactory = ({
|
||||
name,
|
||||
slug,
|
||||
authEnforced,
|
||||
googleSsoAuthEnforced,
|
||||
scimEnabled,
|
||||
defaultMembershipRoleSlug,
|
||||
enforceMfa,
|
||||
@@ -430,6 +431,21 @@ export const orgServiceFactory = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (googleSsoAuthEnforced !== undefined) {
|
||||
if (!plan.enforceGoogleSSO) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to enforce Google SSO due to plan restriction. Upgrade plan to enforce Google SSO."
|
||||
});
|
||||
}
|
||||
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso);
|
||||
}
|
||||
|
||||
if (authEnforced && googleSsoAuthEnforced) {
|
||||
throw new BadRequestError({
|
||||
message: "SAML/OIDC auth enforcement and Google SSO auth enforcement cannot be enabled at the same time."
|
||||
});
|
||||
}
|
||||
|
||||
if (authEnforced) {
|
||||
const samlCfg = await samlConfigDAL.findOne({
|
||||
orgId,
|
||||
@@ -460,6 +476,21 @@ export const orgServiceFactory = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (googleSsoAuthEnforced) {
|
||||
if (googleSsoAuthEnforced && currentOrg.authEnforced) {
|
||||
throw new BadRequestError({
|
||||
message: "Google SSO auth enforcement cannot be enabled when SAML/OIDC auth enforcement is enabled."
|
||||
});
|
||||
}
|
||||
|
||||
if (!currentOrg.googleSsoAuthLastUsed) {
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Google SSO auth enforcement cannot be enabled because Google SSO has not been used yet. Please log in via Google SSO at least once before enforcing it for your organization."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let defaultMembershipRole: string | undefined;
|
||||
if (defaultMembershipRoleSlug) {
|
||||
defaultMembershipRole = await getDefaultOrgMembershipRoleForUpdateOrg({
|
||||
@@ -474,6 +505,7 @@ export const orgServiceFactory = ({
|
||||
name,
|
||||
slug: slug ? slugify(slug) : undefined,
|
||||
authEnforced,
|
||||
googleSsoAuthEnforced,
|
||||
scimEnabled,
|
||||
defaultMembershipRole,
|
||||
enforceMfa,
|
||||
|
||||
@@ -74,6 +74,7 @@ export type TUpdateOrgDTO = {
|
||||
name: string;
|
||||
slug: string;
|
||||
authEnforced: boolean;
|
||||
googleSsoAuthEnforced: boolean;
|
||||
scimEnabled: boolean;
|
||||
defaultMembershipRoleSlug: string;
|
||||
enforceMfa: boolean;
|
||||
|
||||
Reference in New Issue
Block a user