Merge remote-tracking branch 'origin/master' into issues/2844

This commit is contained in:
Roman Zabaluev 2023-05-10 14:06:43 +08:00
commit 5c12f1b53f
15 changed files with 72 additions and 164 deletions

View file

@ -14,13 +14,11 @@ public class LdapProperties {
private String adminPassword;
private String userFilterSearchBase;
private String userFilterSearchFilter;
private String groupFilterSearchBase;
@Value("${oauth2.ldap.activeDirectory:false}")
private boolean isActiveDirectory;
@Value("${oauth2.ldap.aсtiveDirectory.domain:@null}")
private String activeDirectoryDomain;
@Value("${oauth2.ldap.groupRoleAttribute:cn}")
private String groupRoleAttribute;
}

View file

@ -3,7 +3,6 @@ package com.provectus.kafka.ui.config.auth;
import static com.provectus.kafka.ui.config.auth.AbstractAuthSecurityConfig.AUTH_WHITELIST;
import com.provectus.kafka.ui.service.rbac.AccessControlService;
import com.provectus.kafka.ui.service.rbac.extractor.RbacLdapAuthoritiesExtractor;
import java.util.Collection;
import java.util.List;
import javax.annotation.Nullable;
@ -12,7 +11,6 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.ldap.LdapAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@ -34,6 +32,8 @@ import org.springframework.security.ldap.authentication.LdapAuthenticationProvid
import org.springframework.security.ldap.authentication.ad.ActiveDirectoryLdapAuthenticationProvider;
import org.springframework.security.ldap.search.FilterBasedLdapUserSearch;
import org.springframework.security.ldap.search.LdapUserSearch;
import org.springframework.security.ldap.userdetails.DefaultLdapAuthoritiesPopulator;
import org.springframework.security.ldap.userdetails.LdapAuthoritiesPopulator;
import org.springframework.security.ldap.userdetails.LdapUserDetailsMapper;
import org.springframework.security.web.server.SecurityWebFilterChain;
@ -50,7 +50,7 @@ public class LdapSecurityConfig {
@Bean
public ReactiveAuthenticationManager authenticationManager(BaseLdapPathContextSource contextSource,
ApplicationContext context,
LdapAuthoritiesPopulator ldapAuthoritiesPopulator,
@Nullable AccessControlService acs) {
var rbacEnabled = acs != null && acs.isRbacEnabled();
BindAuthenticator ba = new BindAuthenticator(contextSource);
@ -67,7 +67,7 @@ public class LdapSecurityConfig {
AbstractLdapAuthenticationProvider authenticationProvider;
if (!props.isActiveDirectory()) {
authenticationProvider = rbacEnabled
? new LdapAuthenticationProvider(ba, new RbacLdapAuthoritiesExtractor(context))
? new LdapAuthenticationProvider(ba, ldapAuthoritiesPopulator)
: new LdapAuthenticationProvider(ba);
} else {
authenticationProvider = new ActiveDirectoryLdapAuthenticationProvider(props.getActiveDirectoryDomain(),
@ -95,6 +95,15 @@ public class LdapSecurityConfig {
return ctx;
}
@Bean
@Primary
public LdapAuthoritiesPopulator ldapAuthoritiesPopulator(BaseLdapPathContextSource contextSource) {
var authoritiesPopulator = new DefaultLdapAuthoritiesPopulator(contextSource, props.getGroupFilterSearchBase());
authoritiesPopulator.setRolePrefix("");
authoritiesPopulator.setConvertToUpperCase(false);
return authoritiesPopulator;
}
@Bean
public SecurityWebFilterChain configureLdap(ServerHttpSecurity http) {
log.info("Configuring LDAP authentication.");

View file

@ -39,41 +39,42 @@ public class MessageFilters {
}
static Predicate<TopicMessageDTO> groovyScriptFilter(String script) {
var compiledScript = compileScript(script);
var engine = getGroovyEngine();
var compiledScript = compileScript(engine, script);
var jsonSlurper = new JsonSlurper();
return new Predicate<TopicMessageDTO>() {
@SneakyThrows
@Override
public boolean test(TopicMessageDTO msg) {
var bindings = getGroovyEngine().createBindings();
var bindings = engine.createBindings();
bindings.put("partition", msg.getPartition());
bindings.put("offset", msg.getOffset());
bindings.put("timestampMs", msg.getTimestamp().toInstant().toEpochMilli());
bindings.put("keyAsText", msg.getKey());
bindings.put("valueAsText", msg.getContent());
bindings.put("headers", msg.getHeaders());
bindings.put("key", parseToJsonOrReturnNull(jsonSlurper, msg.getKey()));
bindings.put("value", parseToJsonOrReturnNull(jsonSlurper, msg.getContent()));
bindings.put("key", parseToJsonOrReturnAsIs(jsonSlurper, msg.getKey()));
bindings.put("value", parseToJsonOrReturnAsIs(jsonSlurper, msg.getContent()));
var result = compiledScript.eval(bindings);
if (result instanceof Boolean) {
return (Boolean) result;
} else {
throw new ValidationException(
String.format("Unexpected script result: %s, Boolean should be returned instead", result));
"Unexpected script result: %s, Boolean should be returned instead".formatted(result));
}
}
};
}
@Nullable
private static Object parseToJsonOrReturnNull(JsonSlurper parser, @Nullable String str) {
private static Object parseToJsonOrReturnAsIs(JsonSlurper parser, @Nullable String str) {
if (str == null) {
return null;
}
try {
return parser.parseText(str);
} catch (Exception e) {
return null;
return str;
}
}
@ -86,9 +87,9 @@ public class MessageFilters {
return GROOVY_ENGINE;
}
private static CompiledScript compileScript(String script) {
private static CompiledScript compileScript(GroovyScriptEngineImpl engine, String script) {
try {
return getGroovyEngine().compile(script);
return engine.compile(script);
} catch (ScriptException e) {
throw new ValidationException("Script syntax error: " + e.getMessage());
}

View file

@ -1,70 +0,0 @@
package com.provectus.kafka.ui.service.rbac.extractor;
import com.provectus.kafka.ui.config.auth.LdapProperties;
import com.provectus.kafka.ui.model.rbac.Role;
import com.provectus.kafka.ui.model.rbac.provider.Provider;
import com.provectus.kafka.ui.service.rbac.AccessControlService;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationContext;
import org.springframework.ldap.core.DirContextOperations;
import org.springframework.ldap.core.support.BaseLdapPathContextSource;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.ldap.userdetails.DefaultLdapAuthoritiesPopulator;
import org.springframework.util.Assert;
@Slf4j
public class RbacLdapAuthoritiesExtractor extends DefaultLdapAuthoritiesPopulator {
private final AccessControlService acs;
private final LdapProperties props;
private final Function<Map<String, List<String>>, GrantedAuthority> authorityMapper = (record) -> {
String role = record.get(getGroupRoleAttribute()).get(0);
return new SimpleGrantedAuthority(role);
};
public RbacLdapAuthoritiesExtractor(ApplicationContext context) {
super(context.getBean(BaseLdapPathContextSource.class), null);
this.acs = context.getBean(AccessControlService.class);
this.props = context.getBean(LdapProperties.class);
}
@Override
public Set<GrantedAuthority> getAdditionalRoles(DirContextOperations user, String username) {
return acs.getRoles()
.stream()
.map(Role::getSubjects)
.flatMap(List::stream)
.filter(s -> s.getProvider().equals(Provider.LDAP))
.filter(s -> s.getType().equals("group"))
.flatMap(subject -> getRoles(subject.getValue(), user.getNameInNamespace(), username).stream())
.collect(Collectors.toSet());
}
private Set<GrantedAuthority> getRoles(String groupSearchBase, String userDn, String username) {
Assert.notNull(groupSearchBase, "groupSearchBase is empty");
log.trace(
"Searching for roles for user [{}] with DN [{}], groupRoleAttribute [{}] and filter [{}] in search base [{}]",
username, userDn, props.getGroupRoleAttribute(), getGroupSearchFilter(), groupSearchBase);
var ldapTemplate = getLdapTemplate();
ldapTemplate.setIgnoreNameNotFoundException(true);
Set<Map<String, List<String>>> userRoles = ldapTemplate.searchForMultipleAttributeValues(
groupSearchBase, getGroupSearchFilter(), new String[] {userDn, username},
new String[] {props.getGroupRoleAttribute()});
return userRoles.stream()
.map(authorityMapper)
.peek(a -> log.debug("Mapped role [{}] for user [{}]", a, username))
.collect(Collectors.toSet());
}
}

View file

@ -118,10 +118,18 @@ class MessageFiltersTest {
}
@Test
void keySetToNullIfKeyCantBeParsedToJson() {
var f = groovyScriptFilter("key == null");
void keySetToKeyStringIfCantBeParsedToJson() {
var f = groovyScriptFilter("key == \"not json\"");
assertTrue(f.test(msg().key("not json")));
assertFalse(f.test(msg().key("{ \"k\" : \"v\" }")));
}
@Test
void keyAndKeyAsTextSetToNullIfRecordsKeyIsNull() {
var f = groovyScriptFilter("key == null");
assertTrue(f.test(msg().key(null)));
f = groovyScriptFilter("keyAsText == null");
assertTrue(f.test(msg().key(null)));
}
@Test
@ -132,10 +140,18 @@ class MessageFiltersTest {
}
@Test
void valueSetToNullIfKeyCantBeParsedToJson() {
var f = groovyScriptFilter("value == null");
void valueSetToContentStringIfCantBeParsedToJson() {
var f = groovyScriptFilter("value == \"not json\"");
assertTrue(f.test(msg().content("not json")));
assertFalse(f.test(msg().content("{ \"k\" : \"v\" }")));
}
@Test
void valueAndValueAsTextSetToNullIfRecordsContentIsNull() {
var f = groovyScriptFilter("value == null");
assertTrue(f.test(msg().content(null)));
f = groovyScriptFilter("valueAsText == null");
assertTrue(f.test(msg().content(null)));
}
@Test
@ -185,4 +201,4 @@ class MessageFiltersTest {
.partition(1);
}
}
}

View file

@ -3,6 +3,7 @@ package com.provectus.kafka.ui.pages.ksqldb;
import static com.codeborne.selenide.Condition.visible;
import static com.codeborne.selenide.Selenide.$$x;
import static com.codeborne.selenide.Selenide.$x;
import static com.codeborne.selenide.Selenide.sleep;
import com.codeborne.selenide.CollectionCondition;
import com.codeborne.selenide.Condition;
@ -37,6 +38,7 @@ public class KsqlQueryForm extends BasePage {
@Step
public KsqlQueryForm clickClearBtn() {
clickByJavaScript(clearBtn);
sleep(500);
return this;
}

View file

@ -34,7 +34,11 @@ const Configs: React.FC = () => {
const getData = () => {
return data
.filter((item) => item.name.toLocaleLowerCase().indexOf(keyword) > -1)
.filter(
(item) =>
item.name.toLocaleLowerCase().indexOf(keyword.toLocaleLowerCase()) >
-1
)
.sort((a, b) => {
if (a.source === b.source) return 0;

View file

@ -1,4 +1,4 @@
import React, { useEffect, useMemo } from 'react';
import React, { useMemo } from 'react';
import PageHeading from 'components/common/PageHeading/PageHeading';
import * as Metrics from 'components/common/Metrics';
import { Tag } from 'components/common/Tag/Tag.styled';
@ -10,7 +10,6 @@ import Table, { SizeCell } from 'components/common/NewTable';
import useBoolean from 'lib/hooks/useBoolean';
import { clusterNewConfigPath } from 'lib/paths';
import { GlobalSettingsContext } from 'components/contexts/GlobalSettingsContext';
import { useNavigate } from 'react-router-dom';
import { ActionCanButton } from 'components/common/ActionComponent';
import { useGetUserInfo } from 'lib/hooks/api/roles';
@ -23,7 +22,7 @@ const Dashboard: React.FC = () => {
const clusters = useClusters();
const { value: showOfflineOnly, toggle } = useBoolean(false);
const appInfo = React.useContext(GlobalSettingsContext);
const navigate = useNavigate();
const config = React.useMemo(() => {
const clusterList = clusters.data || [];
const offlineClusters = clusterList.filter(
@ -58,12 +57,6 @@ const Dashboard: React.FC = () => {
return initialColumns;
}, []);
useEffect(() => {
if (appInfo.hasDynamicConfig && !clusters.data) {
navigate(clusterNewConfigPath);
}
}, [clusters, appInfo.hasDynamicConfig]);
const isApplicationConfig = useMemo(() => {
return !!data?.userInfo?.permissions.some(
(permission) => permission.resource === ResourceType.APPLICATIONCONFIG

View file

@ -1,45 +0,0 @@
import React from 'react';
import { useClusters } from 'lib/hooks/api/clusters';
import Dashboard from 'components/Dashboard/Dashboard';
import { Cluster, ServerStatus } from 'generated-sources';
import { render } from 'lib/testHelpers';
interface DataType {
data: Cluster[] | undefined;
}
jest.mock('lib/hooks/api/clusters');
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
describe('Dashboard component', () => {
const renderComponent = (hasDynamicConfig: boolean, data: DataType) => {
const useClustersMock = useClusters as jest.Mock;
useClustersMock.mockReturnValue(data);
render(<Dashboard />, {
globalSettings: { hasDynamicConfig },
});
};
it('redirects to new cluster configuration page if there are no clusters and dynamic config is enabled', async () => {
await renderComponent(true, { data: undefined });
expect(mockedNavigate).toHaveBeenCalled();
});
it('should not navigate to new cluster config page when there are clusters', async () => {
await renderComponent(true, {
data: [{ name: 'Cluster 1', status: ServerStatus.ONLINE }],
});
expect(mockedNavigate).not.toHaveBeenCalled();
});
it('should not navigate to new cluster config page when there are no clusters and hasDynamicConfig is false', async () => {
await renderComponent(false, {
data: [],
});
expect(mockedNavigate).not.toHaveBeenCalled();
});
});

View file

@ -33,6 +33,7 @@ export const Fieldset = styled.fieldset`
flex: 1;
flex-direction: column;
gap: 8px;
color: ${({ theme }) => theme.default.color.normal};
`;
export const ButtonsContainer = styled.div`

View file

@ -14,9 +14,6 @@ export const DiffWrapper = styled.div`
background-color: ${({ theme }) => theme.default.backgroundColor};
color: ${({ theme }) => theme.default.color.normal};
}
.ace_line {
background-color: ${({ theme }) => theme.default.backgroundColor};
}
.ace_gutter-cell {
background-color: ${({ theme }) =>
theme.ksqlDb.query.editor.cell.backgroundColor};
@ -39,10 +36,10 @@ export const DiffWrapper = styled.div`
.ace_string {
color: ${({ theme }) => theme.ksqlDb.query.editor.aceString};
}
> .codeMarker {
background: ${({ theme }) => theme.icons.warningIcon};
.codeMarker {
background-color: ${({ theme }) => theme.ksqlDb.query.editor.codeMarker};
position: absolute;
z-index: 20;
z-index: 2000;
}
`;

View file

@ -39,6 +39,10 @@ export const StyledSlider = styled.span<Props>`
transition: 0.4s;
border-radius: 20px;
:hover {
background-color: ${({ theme }) => theme.switch.hover};
}
&::before {
position: absolute;
content: '';

View file

@ -2,7 +2,7 @@ import { useAppInfo } from 'lib/hooks/api/appConfig';
import React from 'react';
import { ApplicationInfoEnabledFeaturesEnum } from 'generated-sources';
export interface GlobalSettingsContextProps {
interface GlobalSettingsContextProps {
hasDynamicConfig: boolean;
}

View file

@ -26,10 +26,7 @@ import {
} from '@tanstack/react-query';
import { ConfirmContextProvider } from 'components/contexts/ConfirmContext';
import ConfirmationModal from 'components/common/ConfirmationModal/ConfirmationModal';
import {
GlobalSettingsContext,
GlobalSettingsContextProps,
} from 'components/contexts/GlobalSettingsContext';
import { GlobalSettingsContext } from 'components/contexts/GlobalSettingsContext';
import { UserInfoRolesAccessContext } from 'components/contexts/UserInfoRolesAccessContext';
import { RolesType, modifyRolesData } from './permissions';
@ -38,7 +35,6 @@ interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
preloadedState?: Partial<RootState>;
store?: Store<Partial<RootState>, AnyAction>;
initialEntries?: MemoryRouterProps['initialEntries'];
globalSettings?: GlobalSettingsContextProps;
userInfo?: {
roles?: RolesType;
rbacFlag: boolean;
@ -114,7 +110,6 @@ const customRender = (
preloadedState,
}),
initialEntries,
globalSettings = { hasDynamicConfig: false },
userInfo,
...renderOptions
}: CustomRenderOptions = {}
@ -124,7 +119,7 @@ const customRender = (
children,
}) => (
<TestQueryClientProvider>
<GlobalSettingsContext.Provider value={globalSettings}>
<GlobalSettingsContext.Provider value={{ hasDynamicConfig: false }}>
<ThemeProvider theme={theme}>
<TestUserInfoProvider data={userInfo}>
<ConfirmContextProvider>

View file

@ -235,12 +235,13 @@ const baseTheme = {
color: Colors.neutral[90],
},
switch: {
unchecked: Colors.brand[30],
unchecked: Colors.neutral[20],
hover: Colors.neutral[40],
checked: Colors.brand[50],
circle: Colors.neutral[0],
disabled: Colors.neutral[10],
checkedIcon: {
backgroundColor: Colors.neutral[70],
backgroundColor: Colors.neutral[10],
},
},
pageLoader: {
@ -366,6 +367,7 @@ export const theme = {
cursor: Colors.neutral[90],
variable: Colors.red[50],
aceString: Colors.green[60],
codeMarker: Colors.yellow[20],
},
},
},
@ -767,6 +769,7 @@ export const darkTheme: ThemeType = {
cursor: Colors.neutral[0],
variable: Colors.red[50],
aceString: Colors.green[60],
codeMarker: Colors.yellow[20],
},
},
},