diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/CatalogApplication.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/CatalogApplication.java index eb89d0f7520..131617be50c 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/CatalogApplication.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/CatalogApplication.java @@ -210,15 +210,15 @@ public class CatalogApplication extends Application { filter = Class.forName(filterClazzName) .asSubclass(ContainerRequestFilter.class) - .getConstructor(AuthenticationConfiguration.class) - .newInstance(authenticationConfiguration); + .getConstructor(AuthenticationConfiguration.class, AuthorizerConfiguration.class) + .newInstance(authenticationConfiguration, authorizerConf); LOG.info("Registering ContainerRequestFilter: {}", filter.getClass().getCanonicalName()); environment.jersey().register(filter); } } else { LOG.info("Authorizer config not set, setting noop authorizer"); authorizer = new NoopAuthorizer(); - ContainerRequestFilter filter = new NoopFilter(authenticationConfiguration); + ContainerRequestFilter filter = new NoopFilter(authenticationConfiguration, null); environment.jersey().register(filter); } } diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/AuthenticationConfiguration.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/AuthenticationConfiguration.java index dd227da575b..753b74bfcd2 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/AuthenticationConfiguration.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/AuthenticationConfiguration.java @@ -31,7 +31,7 @@ public class AuthenticationConfiguration { @Getter @Setter private String authority; @Getter @Setter private String clientId; @Getter @Setter private String callbackUrl; - @Getter @Setter private List jwtPrincipalClaims = List.of("email", "preferred_username", "sub"); + @Getter @Setter private List jwtPrincipalClaims; @Override public String toString() { diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/AuthorizerConfiguration.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/AuthorizerConfiguration.java index ed7455df652..25b61b73c4f 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/AuthorizerConfiguration.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/AuthorizerConfiguration.java @@ -24,6 +24,7 @@ public class AuthorizerConfiguration { @NotEmpty @Getter @Setter private Set adminPrincipals; @NotEmpty @Getter @Setter private Set botPrincipals; @NotEmpty @Getter @Setter private String principalDomain; + @NotEmpty @Getter @Setter private Boolean enforcePrincipalDomain; @Override public String toString() { diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/CatalogOpenIdAuthorizationRequestFilter.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/CatalogOpenIdAuthorizationRequestFilter.java index b6f4291c01d..7d814d69193 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/CatalogOpenIdAuthorizationRequestFilter.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/CatalogOpenIdAuthorizationRequestFilter.java @@ -32,7 +32,7 @@ public class CatalogOpenIdAuthorizationRequestFilter implements ContainerRequest @SuppressWarnings("unused") private CatalogOpenIdAuthorizationRequestFilter() {} - public CatalogOpenIdAuthorizationRequestFilter(AuthenticationConfiguration config) {} + public CatalogOpenIdAuthorizationRequestFilter(AuthenticationConfiguration config, AuthorizerConfiguration conf) {} public void filter(ContainerRequestContext containerRequestContext) { if (isHealthEndpoint(containerRequestContext)) { diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/DefaultAuthorizer.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/DefaultAuthorizer.java index 6b4df5d8cac..aac0a0ab888 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/DefaultAuthorizer.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/DefaultAuthorizer.java @@ -14,6 +14,7 @@ package org.openmetadata.catalog.security; import static org.openmetadata.catalog.Entity.FIELD_OWNER; +import static org.openmetadata.catalog.security.SecurityUtil.DEFAULT_PRINCIPAL_DOMAIN; import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; import java.io.IOException; @@ -68,11 +69,12 @@ public class DefaultAuthorizer implements Authorizer { } addOrUpdateUser(user); } catch (EntityNotFoundException | IOException ex) { + String domain = principalDomain.isEmpty() ? DEFAULT_PRINCIPAL_DOMAIN : principalDomain; User user = new User() .withId(UUID.randomUUID()) .withName(adminUser) - .withEmail(adminUser + "@" + principalDomain) + .withEmail(adminUser + "@" + domain) .withIsAdmin(true) .withUpdatedBy(adminUser) .withUpdatedAt(System.currentTimeMillis()); @@ -92,11 +94,12 @@ public class DefaultAuthorizer implements Authorizer { } addOrUpdateUser(user); } catch (EntityNotFoundException | IOException ex) { + String domain = principalDomain.isEmpty() ? DEFAULT_PRINCIPAL_DOMAIN : principalDomain; User user = new User() .withId(UUID.randomUUID()) .withName(botUser) - .withEmail(botUser + "@" + principalDomain) + .withEmail(botUser + "@" + domain) .withIsBot(true) .withUpdatedBy(botUser) .withUpdatedAt(System.currentTimeMillis()); diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/JwtFilter.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/JwtFilter.java index 92816745f2d..61dfde20e21 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/JwtFilter.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/JwtFilter.java @@ -18,6 +18,7 @@ import com.auth0.jwk.JwkProvider; import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.exceptions.JWTDecodeException; +import com.auth0.jwt.interfaces.Claim; import com.auth0.jwt.interfaces.DecodedJWT; import com.fasterxml.jackson.databind.node.TextNode; import com.google.common.annotations.VisibleForTesting; @@ -28,7 +29,9 @@ import java.net.URL; import java.security.interfaces.RSAPublicKey; import java.util.Calendar; import java.util.List; +import java.util.Map; import java.util.TimeZone; +import java.util.TreeMap; import javax.ws.rs.container.ContainerRequestContext; import javax.ws.rs.container.ContainerRequestFilter; import javax.ws.rs.core.MultivaluedMap; @@ -37,6 +40,7 @@ import javax.ws.rs.core.UriInfo; import javax.ws.rs.ext.Provider; import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang.StringUtils; import org.openmetadata.catalog.Entity; import org.openmetadata.catalog.entity.teams.AuthenticationMechanism; import org.openmetadata.catalog.entity.teams.User; @@ -55,12 +59,15 @@ public class JwtFilter implements ContainerRequestFilter { private List jwtPrincipalClaims; private JwkProvider jwkProvider; + private String principalDomain; + private boolean enforcePrincipalDomain; @SuppressWarnings("unused") private JwtFilter() {} @SneakyThrows - public JwtFilter(AuthenticationConfiguration authenticationConfiguration) { + public JwtFilter( + AuthenticationConfiguration authenticationConfiguration, AuthorizerConfiguration authorizerConfiguration) { this.jwtPrincipalClaims = authenticationConfiguration.getJwtPrincipalClaims(); ImmutableList.Builder publicKeyUrlsBuilder = ImmutableList.builder(); @@ -68,12 +75,20 @@ public class JwtFilter implements ContainerRequestFilter { publicKeyUrlsBuilder.add(new URL(publicKeyUrlStr)); } this.jwkProvider = new MultiUrlJwkProvider(publicKeyUrlsBuilder.build()); + this.principalDomain = authorizerConfiguration.getPrincipalDomain(); + this.enforcePrincipalDomain = authorizerConfiguration.getEnforcePrincipalDomain(); } @VisibleForTesting - JwtFilter(JwkProvider jwkProvider, List jwtPrincipalClaims) { + JwtFilter( + JwkProvider jwkProvider, + List jwtPrincipalClaims, + String principalDomain, + boolean enforcePrincipalDomain) { this.jwkProvider = jwkProvider; this.jwtPrincipalClaims = jwtPrincipalClaims; + this.principalDomain = principalDomain; + this.enforcePrincipalDomain = enforcePrincipalDomain; } @SneakyThrows @@ -114,26 +129,38 @@ public class JwtFilter implements ContainerRequestFilter { } // Get username from JWT token - String userName = + Map claims = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + claims.putAll(jwt.getClaims()); + String jwtClaim = jwtPrincipalClaims.stream() - .filter(jwt.getClaims()::containsKey) + .filter(claims::containsKey) .findFirst() - .map(jwt::getClaim) + .map(claims::get) .map(claim -> claim.as(TextNode.class).asText()) - .map( - authorizedClaim -> { - if (authorizedClaim.contains("@")) { - return authorizedClaim.split("@")[0]; - } else { - return authorizedClaim; - } - }) .orElseThrow( () -> new AuthenticationException( "Invalid JWT token, none of the following claims are present " + jwtPrincipalClaims)); + String userName; + String domain; + if (jwtClaim.contains("@")) { + userName = jwtClaim.split("@")[0]; + domain = jwtClaim.split("@")[1]; + } else { + userName = jwtClaim; + domain = StringUtils.EMPTY; + } + + // validate principal domain + if (enforcePrincipalDomain) { + if (!domain.equals(principalDomain)) { + throw new AuthenticationException( + String.format("Not Authorized! Email does not match the principal domain %s", principalDomain)); + } + } + // validate bot token - if (jwt.getClaims().containsKey(BOT_CLAIM) && jwt.getClaims().get(BOT_CLAIM).asBoolean()) { + if (claims.containsKey(BOT_CLAIM) && claims.get(BOT_CLAIM).asBoolean()) { validateBotToken(tokenFromHeader, userName); } // Setting Security Context diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/NoopFilter.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/NoopFilter.java index c31ead24f58..bfe649a4da1 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/NoopFilter.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/NoopFilter.java @@ -25,7 +25,8 @@ import org.openmetadata.catalog.security.auth.CatalogSecurityContext; public class NoopFilter implements ContainerRequestFilter { @Context private UriInfo uriInfo; - public NoopFilter(AuthenticationConfiguration authenticationConfiguration) {} + public NoopFilter( + AuthenticationConfiguration authenticationConfiguration, AuthorizerConfiguration authorizerConfiguration) {} public void filter(ContainerRequestContext containerRequestContext) { CatalogPrincipal catalogPrincipal = new CatalogPrincipal("anonymous"); diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/SecurityUtil.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/SecurityUtil.java index d3a2e04a0f5..7bf384bf792 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/SecurityUtil.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/security/SecurityUtil.java @@ -34,6 +34,7 @@ public final class SecurityUtil { public static final int BOT = 2; public static final int OWNER = 4; public static final int PERMISSIONS = 8; + public static final String DEFAULT_PRINCIPAL_DOMAIN = "openmetadata.org"; private SecurityUtil() {} diff --git a/catalog-rest-service/src/test/java/org/openmetadata/catalog/security/JwtFilterTest.java b/catalog-rest-service/src/test/java/org/openmetadata/catalog/security/JwtFilterTest.java index 8665d6915e7..da2e9b3c2d2 100644 --- a/catalog-rest-service/src/test/java/org/openmetadata/catalog/security/JwtFilterTest.java +++ b/catalog-rest-service/src/test/java/org/openmetadata/catalog/security/JwtFilterTest.java @@ -49,6 +49,7 @@ import org.mockito.ArgumentCaptor; class JwtFilterTest { private static JwtFilter jwtFilter; + private static JwkProvider jwkProvider; private static Algorithm algorithm; private static UriInfo mockRequestURIInfo; @@ -65,7 +66,7 @@ class JwtFilterTest { // This is used to verify the JWT Jwk mockJwk = mock(Jwk.class); when(mockJwk.getPublicKey()).thenReturn(keyPair.getPublic()); - JwkProvider jwkProvider = mock(JwkProvider.class); + jwkProvider = mock(JwkProvider.class); when(jwkProvider.get(algorithm.getSigningKeyId())).thenReturn(mockJwk); // This is needed by JwtFilter for some metadata, not very important @@ -75,7 +76,44 @@ class JwtFilterTest { when(mockRequestURIInfo.getRequestUri()).thenReturn(uri); List principalClaims = List.of("sub", "email"); - jwtFilter = new JwtFilter(jwkProvider, principalClaims); + String domain = "openmetadata.org"; + boolean enforcePrincipalDomain = false; + jwtFilter = new JwtFilter(jwkProvider, principalClaims, domain, enforcePrincipalDomain); + } + + @Test + void testPrincipalDomainEnforcement() { + List principalClaims = List.of("EMAIL", "sub"); + String domain = "openmetadata.org"; + boolean enforcePrincipalDomain = true; + jwtFilter = new JwtFilter(jwkProvider, principalClaims, domain, enforcePrincipalDomain); + + // success case + String jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withClaim("email", "sam@openmetadata.org") + .sign(algorithm); + + ContainerRequestContext context = createRequestContextWithJwt(jwt); + + jwtFilter.filter(context); + + ArgumentCaptor securityContextArgument = ArgumentCaptor.forClass(SecurityContext.class); + verify(context, times(1)).setSecurityContext(securityContextArgument.capture()); + + assertEquals("sam", securityContextArgument.getValue().getUserPrincipal().getName()); + + // error case + jwt = + JWT.create() + .withExpiresAt(Date.from(Instant.now().plus(1, ChronoUnit.DAYS))) + .withClaim("email", "sam@gmail.com") + .sign(algorithm); + ContainerRequestContext newContext = createRequestContextWithJwt(jwt); + + Exception exception = assertThrows(AuthenticationException.class, () -> jwtFilter.filter(newContext)); + assertTrue(exception.getMessage().toLowerCase(Locale.ROOT).contains("email does not match the principal domain")); } @Test diff --git a/conf/openmetadata.yaml b/conf/openmetadata.yaml index 1112c13be3c..14a9037760c 100644 --- a/conf/openmetadata.yaml +++ b/conf/openmetadata.yaml @@ -131,7 +131,8 @@ authorizerConfiguration: containerRequestFilter: ${AUTHORIZER_REQUEST_FILTER:-org.openmetadata.catalog.security.NoopFilter} adminPrincipals: ${AUTHORIZER_ADMIN_PRINCIPALS:-[admin]} botPrincipals: ${AUTHORIZER_INGESTION_PRINCIPALS:-[ingestion-bot]} - principalDomain: ${AUTHORIZER_PRINCIPAL_DOMAIN:-""} + principalDomain: ${AUTHORIZER_PRINCIPAL_DOMAIN:-"openmetadata.org"} + enforcePrincipalDomain: ${AUTHORIZER_ENFORCE_PRINCIPAL_DOMAIN:-false} authenticationConfiguration: provider: ${AUTHENTICATION_PROVIDER:-no-auth} @@ -141,6 +142,7 @@ authenticationConfiguration: authority: ${AUTHENTICATION_AUTHORITY:-https://accounts.google.com} clientId: ${AUTHENTICATION_CLIENT_ID:-""} callbackUrl: ${AUTHENTICATION_CALLBACK_URL:-""} + jwtPrincipalClaims: ${AUTHENTICATION_JWT_PRINCIPAL_CLAIMS:-[email,preferred_username,sub]} jwtTokenConfiguration: rsapublicKeyFilePath: ${RSA_PUBLIC_KEY_FILE_PATH:-""} diff --git a/docker/local-metadata/docker-compose.yml b/docker/local-metadata/docker-compose.yml index af0a0a2ee4b..93539736364 100644 --- a/docker/local-metadata/docker-compose.yml +++ b/docker/local-metadata/docker-compose.yml @@ -64,12 +64,14 @@ services: AUTHORIZER_ADMIN_PRINCIPALS: ${AUTHORIZER_ADMIN_PRINCIPALS:-[admin]} AUTHORIZER_INGESTION_PRINCIPALS: ${AUTHORIZER_INGESTION_PRINCIPALS:-[ingestion-bot]} AUTHORIZER_PRINCIPAL_DOMAIN: ${AUTHORIZER_PRINCIPAL_DOMAIN:-""} + AUTHORIZER_ENFORCE_PRINCIPAL_DOMAIN: ${AUTHORIZER_ENFORCE_PRINCIPAL_DOMAIN:-false} AUTHENTICATION_PROVIDER: ${AUTHENTICATION_PROVIDER:-no-auth} CUSTOM_OIDC_AUTHENTICATION_PROVIDER_NAME: ${CUSTOM_OIDC_AUTHENTICATION_PROVIDER_NAME:-""} AUTHENTICATION_PUBLIC_KEYS: ${AUTHENTICATION_PUBLIC_KEY:-[https://www.googleapis.com/oauth2/v3/certs]} AUTHENTICATION_AUTHORITY: ${AUTHENTICATION_AUTHORITY:-https://accounts.google.com} AUTHENTICATION_CLIENT_ID: ${AUTHENTICATION_CLIENT_ID:-""} AUTHENTICATION_CALLBACK_URL: ${AUTHENTICATION_CALLBACK_URL:-""} + AUTHENTICATION_JWT_PRINCIPAL_CLAIMS: ${AUTHENTICATION_JWT_PRINCIPAL_CLAIMS:-[email,preferred_username,sub]} # OpenMetadata Server Airflow Configuration AIRFLOW_HOST: ${AIRFLOW_HOST:-http://ingestion:8080} SERVER_HOST_API_URL: ${SERVER_HOST_API_URL:-http://localhost:8585/api} diff --git a/docker/metadata/docker-compose.yml b/docker/metadata/docker-compose.yml index 82fbd166c76..3734af8ac1c 100644 --- a/docker/metadata/docker-compose.yml +++ b/docker/metadata/docker-compose.yml @@ -53,12 +53,14 @@ services: AUTHORIZER_ADMIN_PRINCIPALS: ${AUTHORIZER_ADMIN_PRINCIPALS:-[admin]} AUTHORIZER_INGESTION_PRINCIPALS: ${AUTHORIZER_INGESTION_PRINCIPAL:-[ingestion-bot]} AUTHORIZER_PRINCIPAL_DOMAIN: ${AUTHORIZER_PRINCIPAL_DOMAIN:-""} + AUTHORIZER_ENFORCE_PRINCIPAL_DOMAIN: ${AUTHORIZER_ENFORCE_PRINCIPAL_DOMAIN:-false} AUTHENTICATION_PROVIDER: ${AUTHENTICATION_PROVIDER:-no-auth} CUSTOM_OIDC_AUTHENTICATION_PROVIDER_NAME: ${CUSTOM_OIDC_AUTHENTICATION_PROVIDER_NAME:-""} AUTHENTICATION_PUBLIC_KEYS: ${AUTHENTICATION_PUBLIC_KEY:-[https://www.googleapis.com/oauth2/v3/certs]} AUTHENTICATION_AUTHORITY: ${AUTHENTICATION_AUTHORITY:-https://accounts.google.com} AUTHENTICATION_CLIENT_ID: ${AUTHENTICATION_CLIENT_ID:-""} AUTHENTICATION_CALLBACK_URL: ${AUTHENTICATION_CALLBACK_URL:-""} + AUTHENTICATION_JWT_PRINCIPAL_CLAIMS: ${AUTHENTICATION_JWT_PRINCIPAL_CLAIMS:-[email,preferred_username,sub]} # OpenMetadata Server Airflow Configuration AIRFLOW_HOST: ${AIRFLOW_HOST:-http://ingestion:8080} SERVER_HOST_API_URL: ${SERVER_HOST_API_URL:-http://localhost:8585/api} diff --git a/openmetadata-ui/src/main/resources/ui/src/authentication/auth-provider/AuthProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/authentication/auth-provider/AuthProvider.tsx index edfe2474fbb..94223193ea5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/authentication/auth-provider/AuthProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/authentication/auth-provider/AuthProvider.tsx @@ -316,6 +316,7 @@ export const AuthProvider = ({ if (error.response) { const { status } = error.response; if (status === ClientErrors.UNAUTHORIZED) { + showErrorToast(error); resetUserDetails(true); } else if (status === ClientErrors.FORBIDDEN) { showErrorToast(jsonData['api-error-messages']['forbidden-error']); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ToastUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ToastUtils.ts index b9704da64a9..93cf311c0b6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/ToastUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/ToastUtils.ts @@ -12,11 +12,25 @@ */ import { AxiosError } from 'axios'; -import { isString } from 'lodash'; +import { isEmpty, isString } from 'lodash'; import { toast } from 'react-toastify'; import jsonData from '../jsons/en'; import { getErrorText } from './StringsUtils'; +export const hashCode = (str: string) => { + let hash = 0, + i, + chr; + if (isEmpty(str)) return hash; + for (i = 0; i < str.length; i++) { + chr = str.charCodeAt(i); + hash = (hash << 5) - hash + chr; + hash |= 0; // Convert to 32bit integer + } + + return hash; +}; + /** * Display an error toast message. * @param error error text or AxiosError object @@ -39,11 +53,17 @@ export const showErrorToast = ( errorMessage = getErrorText(error, fallback); // do not show error toasts for 401 // since they will be intercepted and the user will be redirected to the signin page - if (error && error.response?.status === 401) { + // except for principal domain mismatch errors + if ( + error && + error.response?.status === 401 && + !errorMessage.includes('principal domain') + ) { return; } } toast.error(errorMessage, { + toastId: hashCode(errorMessage), autoClose: autoCloseTimer, }); };