diff --git a/docker-compose-acl-config.yml b/docker-compose-acl-config.yml index 7fcd4a8..106e813 100644 --- a/docker-compose-acl-config.yml +++ b/docker-compose-acl-config.yml @@ -1,5 +1,3 @@ - - jndi: datasources: acl: @@ -14,11 +12,33 @@ jndi: connection-timeout: 3000 idle-timeout: 60000 -acl: - db: - jndiName: java:comp/env/jdbc/acl - hbm2ddl.auto: update - schema: acl +acl.db.jndiName: java:comp/env/jdbc/acl +acl.db.schema: acl +acl.db.hbm2ddl.auto: update + +geoserver: + acl: + security: + headers: + enabled: true + user-header: sec-username + roles-header: sec-roles + admin-roles: ["ROLE_ADMINISTRATOR"] + internal: + enabled: true + users: + admin: + admin: true + enabled: ${acl.admin.enabled:true} + # password is a bcrypt encoded value for s3cr3t + password: "${acl.admin.password:{bcrypt}$2a$10$FE62N3ejbKm56EX5VrtSQeDDka8YjwgjwF9sSEKbatGZuZ8e7S9v.}" + #for a plain-text password (e.g. coming from a docker or kubernetes secret, + # use the {noop} prefix, as in: password: "{noop}plaintextpwd}", or password: "{noop}${ACL_ADMIN_PASSWORD}" + user: + admin: false + enabled: true + # password is a bcrypt encoded value for s3cr3t + password: "{bcrypt}$2a$10$eMyaZRLZBAZdor8nOX.qwuwOyWazXjR2hddGLCT6f6c382WiwdQGG" logging: level: @@ -37,3 +57,7 @@ management: exposure: include: - '*' +--- +# local profile, used for development only. Other settings like config and eureka urls in gs_cloud_bootstrap_profiles.yml +spring.config.activate.on-profile: local +server.port: 9091 diff --git a/docker-compose.yml b/docker-compose.yml index b1002c0..5068147 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: cpus: '4.0' memory: 2G ports: - - 5432:5432 + - 6432:5432 acl: image: geoservercloud/geoserver-acl:1.0-SNAPSHOT diff --git a/docs/index.md b/docs/index.md index 19299e5..0c2c760 100644 --- a/docs/index.md +++ b/docs/index.md @@ -35,42 +35,3 @@ access rules. So if you're familiar with GeoFence, it'll be easy to reason about GeoServer ACL. -## Dependency graph - -```mermaid -flowchart LR - subgraph domain - adminrule-management --> object-model & rule-management - authorization --> adminrule-management & rule-management - rule-management --> object-model - end - subgraph openapi-codegen - openapi-server --> openapi-model - openapi-client --> openapi-model - end - subgraph integration - subgraph persistence-jpa - jpa-integration --> jpa-persistence - end - subgraph spring-integration - domain-spring-integration -. optional> .-> rule-management & adminrule-management & authorization - end - subgraph openapi-integration - api-model-mapper --> object-model & openapi-model - api-impl --> api-model-mapper & openapi-server & domain-spring-integration & rule-management & adminrule-management - api-client --> api-model-mapper & openapi-client - end - subgraph spring-boot-integration - spring-boot-autoconfiguration --> domain-spring-integration & api-impl & jpa-integration - end - end - subgraph geoserver-plugin - plugin-webui --> plugin-accessmanager - plugin-rest --> plugin-accessmanager & api-impl - plugin --> plugin-accessmanager & plugin-rest & plugin-webui - end - subgraph application - rest-app --> spring-boot-autoconfiguration - end -``` - diff --git a/src/artifacts/api/Dockerfile b/src/artifacts/api/Dockerfile index 296afc6..9191359 100644 --- a/src/artifacts/api/Dockerfile +++ b/src/artifacts/api/Dockerfile @@ -11,7 +11,7 @@ FROM eclipse-temurin:17-jre LABEL maintainer="GeoServer PSC " WORKDIR /opt/app/bin -ENV JAVA_TOOL_OPTS="\ +ENV DEF_JAVA_TOOL_OPTS="\ --add-exports=java.desktop/sun.awt.image=ALL-UNNAMED \ --add-opens=java.base/java.lang=ALL-UNNAMED \ --add-opens=java.base/java.util=ALL-UNNAMED \ @@ -22,6 +22,9 @@ ENV JAVA_TOOL_OPTS="\ --add-opens=java.naming/com.sun.jndi.ldap=ALL-UNNAMED \ -Djava.awt.headless=true" ENV JAVA_OPTS="-XX:MaxRAMPercentage=80 -XshowSettings:system" + +ENV JAVA_TOOL_OPTS="$DEF_JAVA_TOOL_OPTS $JAVA_OPTS" + EXPOSE 8080 COPY --from=builder dependencies/ ./ @@ -36,4 +39,8 @@ HEALTHCHECK \ --retries=5 \ CMD curl -f -s -o /dev/null localhost:8080/actuator/health || exit 1 -CMD exec java $JAVA_OPTS $JAVA_TOOL_OPTS org.springframework.boot.loader.JarLauncher +ARG APP_ARGS="" + +ENTRYPOINT [ "/bin/bash", "-c", "exec java $JAVA_TOOL_OPTS org.springframework.boot.loader.JarLauncher \"${@}\"", "--" ] + +CMD ["${APP_ARGS}"] diff --git a/src/artifacts/api/README.md b/src/artifacts/api/README.md index c8e4746..a71f9e9 100644 --- a/src/artifacts/api/README.md +++ b/src/artifacts/api/README.md @@ -16,7 +16,7 @@ With the application running at [http://localhost:8080/api](http://localhost:808 mvn clean install ``` -will create a single-jar executable at `target/gs-cloud-acl-service--bin.jar`. +will create a single-jar executable at `target/gs-acl-service--bin.jar`. ## Run @@ -27,7 +27,7 @@ Run in development mode with an in-memory H2 database, either with or - java -jar target/gs-cloud-acl-service-1.0-SNAPSHOT-bin.jar --spring.profiles.active=dev + java -jar target/gs-acl-service-1.0-SNAPSHOT-bin.jar --spring.profiles.active=dev ## Dependency graph diff --git a/src/artifacts/api/pom.xml b/src/artifacts/api/pom.xml index 7d7b055..061d1d3 100644 --- a/src/artifacts/api/pom.xml +++ b/src/artifacts/api/pom.xml @@ -48,6 +48,10 @@ org.springframework.boot spring-boot-starter-actuator + + org.springframework.boot + spring-boot-starter-security + org.springdoc springdoc-openapi-ui @@ -57,6 +61,10 @@ com.h2database h2 + + org.projectlombok + lombok + org.springframework.boot spring-boot-starter-test diff --git a/src/artifacts/api/src/main/java/org/geoserver/acl/app/AccesControlListApplication.java b/src/artifacts/api/src/main/java/org/geoserver/acl/app/AccesControlListApplication.java index 0cd613a..26e6b6d 100644 --- a/src/artifacts/api/src/main/java/org/geoserver/acl/app/AccesControlListApplication.java +++ b/src/artifacts/api/src/main/java/org/geoserver/acl/app/AccesControlListApplication.java @@ -6,15 +6,41 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Arrays; +import java.util.List; @SpringBootApplication public class AccesControlListApplication { + private static final String ENCODEPASSWORD = "encodepassword"; + public static void main(String... args) { + List arglist = Arrays.asList(args); + + if (arglist.contains(ENCODEPASSWORD)) { + System.exit(encodePassword(arglist)); + } + try { SpringApplication.run(AccesControlListApplication.class, args); } catch (RuntimeException e) { System.exit(-1); } } + + private static int encodePassword(List arglist) { + int pwdIndex = 1 + arglist.indexOf(ENCODEPASSWORD); + if (arglist.size() < pwdIndex) { + System.err.println("Usage: encodepassword "); + return -1; + } + String pwd = arglist.get(pwdIndex); + PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); + String encoded = encoder.encode(pwd); + System.out.println(encoded); + return 0; + } } diff --git a/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/AclServiceSecurityAutoConfiguration.java b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/AclServiceSecurityAutoConfiguration.java new file mode 100644 index 0000000..98c4f99 --- /dev/null +++ b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/AclServiceSecurityAutoConfiguration.java @@ -0,0 +1,68 @@ +/* (c) 2023 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.autoconfigure.security; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.preauth.RequestHeaderAuthenticationFilter; + +@AutoConfiguration +@EnableWebSecurity +@EnableConfigurationProperties(SecurityConfigProperties.class) +@EnableGlobalMethodSecurity(prePostEnabled = true) +@Slf4j(topic = "org.geoserver.acl.autoconfigure.security") +public class AclServiceSecurityAutoConfiguration { + + private @Autowired(required = false) RequestHeaderAuthenticationFilter preAuthFilter; + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + AuthenticationManager authenticationManager, + SecurityConfigProperties config) + throws Exception { + + http.csrf().disable(); + + if (!config.enabled()) { + log.warn("No security authentication method is defined!"); + return http.build(); + } + + http.authenticationManager(authenticationManager); + + if (null == preAuthFilter) { + log.info("Pre-authentication headers disabled"); + } else { + log.info( + "Pre-authentication headers enabled for {}/{}. Admin roles: {}", + config.getHeaders().getUserHeader(), + config.getHeaders().getRolesHeader(), + config.getHeaders().getAdminRoles()); + http.addFilterAfter(preAuthFilter, RequestHeaderAuthenticationFilter.class); + } + + http.authorizeRequests() + .antMatchers("/", "/api/api-docs/**", "/api/swagger-ui.html", "/api/swagger-ui/**") + .permitAll() + .anyRequest() + .authenticated() + .and(); + + if (config.getInternal().isEnabled()) { + http.httpBasic(); + } + return http.build(); + } +} diff --git a/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/AuthenticationManagerAutoConfiguration.java b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/AuthenticationManagerAutoConfiguration.java new file mode 100644 index 0000000..b168022 --- /dev/null +++ b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/AuthenticationManagerAutoConfiguration.java @@ -0,0 +1,23 @@ +/* (c) 2023 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.autoconfigure.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.ProviderManager; + +import java.util.List; + +@Configuration +public class AuthenticationManagerAutoConfiguration { + + @Bean + AuthenticationManager authenticationManager(List providers) + throws Exception { + return new ProviderManager(providers); + } +} diff --git a/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/ConditionalOnInternalAuthenticationEnabled.java b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/ConditionalOnInternalAuthenticationEnabled.java new file mode 100644 index 0000000..7a15bad --- /dev/null +++ b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/ConditionalOnInternalAuthenticationEnabled.java @@ -0,0 +1,26 @@ +/* (c) 2023 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.autoconfigure.security; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@ConditionalOnProperty( + prefix = ConditionalOnInternalAuthenticationEnabled.PREFIX, + name = "enabled", + havingValue = "true", + matchIfMissing = false) +public @interface ConditionalOnInternalAuthenticationEnabled { + + public static final String PREFIX = SecurityConfigProperties.PREFIX + ".internal"; +} diff --git a/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/ConditionalOnPreAuthenticationEnabled.java b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/ConditionalOnPreAuthenticationEnabled.java new file mode 100644 index 0000000..7190424 --- /dev/null +++ b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/ConditionalOnPreAuthenticationEnabled.java @@ -0,0 +1,26 @@ +/* (c) 2023 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.autoconfigure.security; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@ConditionalOnProperty( + prefix = ConditionalOnPreAuthenticationEnabled.PREFIX, + name = "enabled", + havingValue = "true", + matchIfMissing = false) +public @interface ConditionalOnPreAuthenticationEnabled { + + public static final String PREFIX = SecurityConfigProperties.PREFIX + ".headers"; +} diff --git a/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/InternalSecurityConfiguration.java b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/InternalSecurityConfiguration.java new file mode 100644 index 0000000..0d8beab --- /dev/null +++ b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/InternalSecurityConfiguration.java @@ -0,0 +1,98 @@ +/* (c) 2023 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.autoconfigure.security; + +import static org.springframework.util.StringUtils.hasText; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.DelegatingPasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +@AutoConfiguration +@ConditionalOnInternalAuthenticationEnabled +@EnableConfigurationProperties(SecurityConfigProperties.class) +@Slf4j(topic = "org.geoserver.acl.autoconfigure.security") +public class InternalSecurityConfiguration { + + @Bean + AuthenticationProvider internalAuthenticationProvider( + @Qualifier("internalUserDetailsService") + UserDetailsService internalUserDetailsService) { + + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setAuthoritiesMapper(new NullAuthoritiesMapper()); + provider.setUserDetailsService(internalUserDetailsService); + + DelegatingPasswordEncoder encoder = + (DelegatingPasswordEncoder) + PasswordEncoderFactories.createDelegatingPasswordEncoder(); + + provider.setPasswordEncoder(encoder); + + return provider; + } + + @Bean("internalUserDetailsService") + public UserDetailsService internalUserDetailsService(SecurityConfigProperties config) { + + Map users = + config.getInternal().getUsers(); + Collection authUsers = new ArrayList<>(); + users.forEach( + (username, userinfo) -> { + validate(username, userinfo); + log.info( + "Loading internal user {}, admin: {}, enabled: {}", + username, + userinfo.isAdmin(), + userinfo.isEnabled()); + UserDetails user = toUserDetails(username, userinfo); + authUsers.add(user); + }); + + long enabledUsers = authUsers.stream().filter(UserDetails::isEnabled).count(); + if (0L == enabledUsers) { + log.warn( + "No API users are enabled for HTTP Basic Auth. Loaded user names: {}", + users.keySet()); + } + + return new InMemoryUserDetailsManager(authUsers); + } + + private UserDetails toUserDetails( + String username, SecurityConfigProperties.Internal.UserInfo u) { + return User.builder() + .username(username) + .password(u.getPassword()) + .authorities(u.authorities()) + .disabled(!u.isEnabled()) + .build(); + } + + private void validate(final String name, SecurityConfigProperties.Internal.UserInfo info) { + if (info.isEnabled()) { + if (!hasText(name)) throw new IllegalArgumentException("User has no name: " + info); + if (!hasText(info.getPassword())) + throw new IllegalArgumentException("User has no password " + name + ": " + info); + } + } +} diff --git a/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/PreAuthenticationSecurityAutoConfiguration.java b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/PreAuthenticationSecurityAutoConfiguration.java new file mode 100644 index 0000000..0660d31 --- /dev/null +++ b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/PreAuthenticationSecurityAutoConfiguration.java @@ -0,0 +1,122 @@ +/* (c) 2023 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.autoconfigure.security; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.AuthenticationUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; +import org.springframework.security.web.authentication.preauth.RequestHeaderAuthenticationFilter; +import org.springframework.util.StringUtils; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +@Configuration +@ConditionalOnPreAuthenticationEnabled +@EnableConfigurationProperties(SecurityConfigProperties.class) +public class PreAuthenticationSecurityAutoConfiguration { + + @Bean + public RequestHeaderAuthenticationFilter requestHeaderAuthenticationFilter( + AuthenticationManager authenticationManager, SecurityConfigProperties config) + throws Exception { + RequestHeaderAuthenticationFilter filter = new RequestHeaderAuthenticationFilter(); + + String userHeader = config.getHeaders().getUserHeader(); + String rolesHeader = config.getHeaders().getRolesHeader(); + if (!StringUtils.hasText(userHeader) || !StringUtils.hasText(rolesHeader)) { + throw new IllegalStateException( + "Both user and roles header names must be provided, got geoserver.acl.security.headers.userHeader: " + + userHeader + + ", geoserver.acl.security.headers.rolesHeader: " + + rolesHeader); + } + + filter.setPrincipalRequestHeader(userHeader); + filter.setCredentialsRequestHeader(rolesHeader); + + filter.setAuthenticationManager(authenticationManager); + // do not throw exception when header is not present. + // one use case is for actuator endpoints and static assets where security + // headers are not required. + filter.setExceptionIfHeaderMissing(false); + return filter; + } + + @Bean + PreAuthenticatedAuthenticationProvider preauthAuthProvider(SecurityConfigProperties config) + throws Exception { + PreAuthenticatedAuthenticationProvider provider = + new PreAuthenticatedAuthenticationProvider(); + Supplier> adminRoles = config.getHeaders()::getAdminRoles; + AuthorizationUserDetailsService detailsService = + new AuthorizationUserDetailsService(adminRoles); + provider.setPreAuthenticatedUserDetailsService(detailsService); + return provider; + } + + @RequiredArgsConstructor + static class AuthorizationUserDetailsService + implements AuthenticationUserDetailsService { + + private final @NonNull Supplier> adminRoles; + + /** + * Loads user from data store and creates UserDetails object based on principal and/or + * credential. + * + *

Role name needs to have "ROLE_" prefix. + * + * @param token instance of PreAuthenticatedAuthenticationToken + * @return UserDetails object which contains role information for the given user. + * @throws UsernameNotFoundException + */ + @Override + public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token) + throws UsernameNotFoundException { + final String principal = (String) token.getPrincipal(); + final Set credentials = givenCredentials((String) token.getCredentials()); + Collection rolesConsideredAdmin = adminRoles.get(); + boolean isAdmin = rolesConsideredAdmin.stream().anyMatch(credentials::contains); + if (isAdmin) { + credentials.add("ROLE_ADMIN"); + } else { + credentials.add("ROLE_USER"); + } + + List authorities = + credentials.stream() + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + return new User(principal, "", authorities); + } + + private Set givenCredentials(String rolesHeader) { + if (StringUtils.hasText(rolesHeader)) { + return Arrays.stream(rolesHeader.split(";")) + .filter(StringUtils::hasText) + .map(String::trim) + .collect(Collectors.toSet()); + } + return Set.of(); + } + } +} diff --git a/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/SecurityConfigProperties.java b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/SecurityConfigProperties.java new file mode 100644 index 0000000..37f77a5 --- /dev/null +++ b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/SecurityConfigProperties.java @@ -0,0 +1,55 @@ +/* (c) 2023 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.autoconfigure.security; + +import lombok.Data; +import lombok.ToString; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Data +@ConfigurationProperties(value = SecurityConfigProperties.PREFIX) +public class SecurityConfigProperties { + + public static final String PREFIX = "geoserver.acl.security"; + + private Internal internal = new Internal(); + private PreauthHeaders headers = new PreauthHeaders(); + + public boolean enabled() { + return internal.isEnabled() || headers.isEnabled(); + } + + public static @Data class Internal { + private boolean enabled; + private Map users = Map.of(); + + @ToString(exclude = "password") + public static @Data class UserInfo { + private String password; + private boolean admin; + private boolean enabled = true; + + public List authorities() { + return Stream.of(admin ? "ROLE_ADMIN" : "ROLE_USER") + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } + } + } + + public static @Data class PreauthHeaders { + private boolean enabled; + private String userHeader = "sec-username"; + private String rolesHeader = "sec-roles"; + private List adminRoles = List.of("ADMIN"); + } +} diff --git a/src/artifacts/api/src/main/java/org/geoserver/acl/config/springdoc/SpringDocHomeRedirectConfiguration.java b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/springdoc/SpringDocAutoConfiguration.java similarity index 66% rename from src/artifacts/api/src/main/java/org/geoserver/acl/config/springdoc/SpringDocHomeRedirectConfiguration.java rename to src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/springdoc/SpringDocAutoConfiguration.java index 934283e..959cbed 100644 --- a/src/artifacts/api/src/main/java/org/geoserver/acl/config/springdoc/SpringDocHomeRedirectConfiguration.java +++ b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/springdoc/SpringDocAutoConfiguration.java @@ -2,14 +2,15 @@ * This code is licensed under the GPL 2.0 license, available at the root * application directory. */ -package org.geoserver.acl.config.springdoc; +package org.geoserver.acl.autoconfigure.springdoc; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -@Configuration -public class SpringDocHomeRedirectConfiguration { +/** {@link AutoConfiguration} redirect the home page to the swagger-ui */ +@AutoConfiguration +public class SpringDocAutoConfiguration { @Bean SpringDocHomeRedirectController homeController( diff --git a/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/springdoc/SpringDocHomeRedirectAutoConfiguration.java b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/springdoc/SpringDocHomeRedirectAutoConfiguration.java deleted file mode 100644 index 8746018..0000000 --- a/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/springdoc/SpringDocHomeRedirectAutoConfiguration.java +++ /dev/null @@ -1,18 +0,0 @@ -/* (c) 2023 Open Source Geospatial Foundation - all rights reserved - * This code is licensed under the GPL 2.0 license, available at the root - * application directory. - */ -package org.geoserver.acl.autoconfigure.springdoc; - -import org.geoserver.acl.config.springdoc.SpringDocHomeRedirectConfiguration; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Import; - -@AutoConfiguration -@ConditionalOnProperty( - name = "springdoc.swagger-ui.enabled", - havingValue = "true", - matchIfMissing = true) -@Import(SpringDocHomeRedirectConfiguration.class) -public class SpringDocHomeRedirectAutoConfiguration {} diff --git a/src/artifacts/api/src/main/java/org/geoserver/acl/config/springdoc/SpringDocHomeRedirectController.java b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/springdoc/SpringDocHomeRedirectController.java similarity index 77% rename from src/artifacts/api/src/main/java/org/geoserver/acl/config/springdoc/SpringDocHomeRedirectController.java rename to src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/springdoc/SpringDocHomeRedirectController.java index 5fbc066..8858e2e 100644 --- a/src/artifacts/api/src/main/java/org/geoserver/acl/config/springdoc/SpringDocHomeRedirectController.java +++ b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/springdoc/SpringDocHomeRedirectController.java @@ -2,18 +2,18 @@ * This code is licensed under the GPL 2.0 license, available at the root * application directory. */ -package org.geoserver.acl.config.springdoc; +package org.geoserver.acl.autoconfigure.springdoc; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; @Controller -class SpringDocHomeRedirectController { +public class SpringDocHomeRedirectController { private String basePath; /** - * @param basePath e.g. {@literal /api/v2/swagger-ui/index.html"} + * @param basePath e.g. {@literal /api/swagger-ui/index.html"} */ public SpringDocHomeRedirectController(String basePath) { this.basePath = basePath; diff --git a/src/artifacts/api/src/main/resources/META-INF/spring.factories b/src/artifacts/api/src/main/resources/META-INF/spring.factories index 54af9b1..b7f54ff 100644 --- a/src/artifacts/api/src/main/resources/META-INF/spring.factories +++ b/src/artifacts/api/src/main/resources/META-INF/spring.factories @@ -6,4 +6,9 @@ org.geoserver.acl.autoconfigure.geotools.GeoToolsStaticContextInitializer org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ org.geoserver.acl.autoconfigure.persistence.JPAIntegrationAutoConfiguration,\ org.geoserver.acl.autoconfigure.api.RulesApiAutoConfiguration,\ -org.geoserver.acl.autoconfigure.springdoc.SpringDocHomeRedirectAutoConfiguration \ No newline at end of file +org.geoserver.acl.autoconfigure.security.AclServiceSecurityAutoConfiguration,\ +org.geoserver.acl.autoconfigure.security.InternalSecurityConfiguration,\ +org.geoserver.acl.autoconfigure.security.PreAuthenticationSecurityAutoConfiguration,\ +org.geoserver.acl.autoconfigure.security.AuthenticationManagerAutoConfiguration,\ +org.geoserver.acl.autoconfigure.springdoc.SpringDocAutoConfiguration + diff --git a/src/artifacts/api/src/main/resources/acl-service.yml b/src/artifacts/api/src/main/resources/acl-service.yml index bcaa810..784e59c 100644 --- a/src/artifacts/api/src/main/resources/acl-service.yml +++ b/src/artifacts/api/src/main/resources/acl-service.yml @@ -16,22 +16,69 @@ geoserver.acl: format_sql: true default_schema: ${acl.db.schema:public} hbm2ddl.auto: ${acl.db.hbm2ddl.auto:validate} + security: + headers: + enabled: false + user-header: sec-username + roles-header: sec-roles + admin-roles: ["ROLE_ADMINISTRATOR"] + internal: + enabled: true + users: + admin: + admin: true + enabled: false + # password is the bcrypt encoded value, for example, for pwd s3cr3t: + # password: "{bcrypt}$2a$10$eMyaZRLZBAZdor8nOX.qwuwOyWazXjR2hddGLCT6f6c382WiwdQGG" +# user: +# admin: false +# enabled: true +# # password is the bcrypt encoded value for s3cr3t +# password: "{bcrypt}$2a$10$eMyaZRLZBAZdor8nOX.qwuwOyWazXjR2hddGLCT6f6c382WiwdQGG" +--- +spring.config.activate.on-profile: local +server.port: 9000 + +jndi: + datasources: + acl: + enabled: true + wait-for-it: true + wait-timeout: 15 + url: jdbc:postgresql://localhost:6432/acl + username: acl + password: acls3cr3t + maximum-pool-size: 50 + minimum-idle: 2 + connection-timeout: 3000 + idle-timeout: 60000 + +acl.db.jndiName: java:comp/env/jdbc/acl +acl.db.schema: acl +acl.db.hbm2ddl.auto: create --- spring.config.activate.on-profile: dev -geoserver.acl: - datasource: - url: jdbc:h2:mem:geoserver-acl;DB_CLOSE_DELAY=-1 - hikari: - minimum-idle: 1 - maximum-pool-size: 20 - jpa: - show-sql: false - properties: - hibernate: - hbm2ddl.auto: update +geoserver: + acl: + security: + headers.enabled: true + internal: + enabled: true + users: + admin.enabled: true + user.enabled: true + datasource: + url: jdbc:h2:mem:geoserver-acl;DB_CLOSE_DELAY=-1 + hikari: + minimum-idle: 1 + maximum-pool-size: 20 + jpa: + show-sql: true + properties: + hibernate.hbm2ddl.auto: create management: endpoints: diff --git a/src/artifacts/plugin/src/main/java/org/geoserver/acl/plugin/autoconfigure/accessmanager/ConditionalOnAclEnabled.java b/src/artifacts/plugin/src/main/java/org/geoserver/acl/plugin/autoconfigure/accessmanager/ConditionalOnAclEnabled.java index bc55e3c..ad128d2 100644 --- a/src/artifacts/plugin/src/main/java/org/geoserver/acl/plugin/autoconfigure/accessmanager/ConditionalOnAclEnabled.java +++ b/src/artifacts/plugin/src/main/java/org/geoserver/acl/plugin/autoconfigure/accessmanager/ConditionalOnAclEnabled.java @@ -1,3 +1,7 @@ +/* (c) 2023 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ package org.geoserver.acl.plugin.autoconfigure.accessmanager; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; diff --git a/src/integration/openapi/spring-server/pom.xml b/src/integration/openapi/spring-server/pom.xml index f37dfbc..4b6a094 100644 --- a/src/integration/openapi/spring-server/pom.xml +++ b/src/integration/openapi/spring-server/pom.xml @@ -57,6 +57,11 @@ spring-web provided + + org.springframework.security + spring-security-core + provided + javax.servlet javax.servlet-api @@ -71,5 +76,15 @@ spring-boot-starter-test test + + org.springframework.boot + spring-boot-starter-security + test + + + org.springframework.security + spring-security-test + test + diff --git a/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/authorization/AuthorizationApiImpl.java b/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/authorization/AuthorizationApiImpl.java index adecaba..982ee00 100644 --- a/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/authorization/AuthorizationApiImpl.java +++ b/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/authorization/AuthorizationApiImpl.java @@ -14,6 +14,7 @@ import org.geoserver.acl.api.model.Rule; import org.geoserver.acl.api.server.AuthorizationApiDelegate; import org.geoserver.acl.api.server.support.AuthorizationApiSupport; +import org.geoserver.acl.api.server.support.IsAuthenticated; import org.geoserver.acl.authorization.AuthorizationService; import org.springframework.http.ResponseEntity; @@ -21,6 +22,7 @@ import java.util.stream.Collectors; @RequiredArgsConstructor +@IsAuthenticated public class AuthorizationApiImpl implements AuthorizationApiDelegate { private final @NonNull AuthorizationService service; diff --git a/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/rules/AdminRulesApiImpl.java b/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/rules/AdminRulesApiImpl.java index 34e7193..34690f7 100644 --- a/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/rules/AdminRulesApiImpl.java +++ b/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/rules/AdminRulesApiImpl.java @@ -17,17 +17,19 @@ import org.geoserver.acl.api.model.InsertPosition; import org.geoserver.acl.api.server.AdminRulesApiDelegate; import org.geoserver.acl.api.server.support.AdminRulesApiSupport; +import org.geoserver.acl.api.server.support.IsAdmin; +import org.geoserver.acl.api.server.support.IsAuthenticated; import org.geoserver.acl.domain.adminrules.AdminRuleAdminService; import org.geoserver.acl.domain.adminrules.AdminRuleIdentifierConflictException; import org.geoserver.acl.domain.filter.RuleQuery; import org.springframework.http.ResponseEntity; -import org.springframework.web.server.ResponseStatusException; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @RequiredArgsConstructor +@IsAuthenticated public class AdminRulesApiImpl implements AdminRulesApiDelegate { private final @NonNull AdminRuleAdminService service; @@ -41,6 +43,7 @@ public class AdminRulesApiImpl implements AdminRulesApiDelegate { return ResponseEntity.ok(service.count(support.map(adminRuleFilter))); } + @IsAdmin public @Override ResponseEntity createAdminRule( AdminRule adminRule, InsertPosition position) { @@ -61,6 +64,7 @@ public class AdminRulesApiImpl implements AdminRulesApiDelegate { } } + @IsAdmin public @Override ResponseEntity deleteAdminRuleById(@NonNull String id) { boolean deleted = service.delete(id); return ResponseEntity.status(deleted ? OK : NOT_FOUND).build(); @@ -134,21 +138,22 @@ private ResponseEntity> query( .body(found.map(support::toApi).orElse(null)); } + @IsAdmin public @Override ResponseEntity shiftAdminRulesByPiority( @NonNull Long priorityStart, @NonNull Long offset) { return ResponseEntity.ok(service.shift(priorityStart, offset)); } + @IsAdmin public @Override ResponseEntity swapAdminRules(@NonNull String id, @NonNull String id2) { service.swap(id, id2); return ResponseEntity.status(OK).build(); } + @IsAdmin public @Override ResponseEntity updateAdminRule( @NonNull String id, AdminRule patchBody) { - org.geoserver.acl.domain.adminrules.AdminRule rule = - service.get(id).orElseThrow(() -> new ResponseStatusException(NOT_FOUND)); - + org.geoserver.acl.domain.adminrules.AdminRule rule = service.get(id).orElse(null); if (null == rule) { return support.error(NOT_FOUND, "AdminRule " + id + " does not exist"); } diff --git a/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/rules/RulesApiImpl.java b/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/rules/RulesApiImpl.java index 85afebc..e1bef8d 100644 --- a/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/rules/RulesApiImpl.java +++ b/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/rules/RulesApiImpl.java @@ -19,6 +19,8 @@ import org.geoserver.acl.api.model.RuleFilter; import org.geoserver.acl.api.model.RuleLimits; import org.geoserver.acl.api.server.RulesApiDelegate; +import org.geoserver.acl.api.server.support.IsAdmin; +import org.geoserver.acl.api.server.support.IsAuthenticated; import org.geoserver.acl.api.server.support.RulesApiSupport; import org.geoserver.acl.domain.filter.RuleQuery; import org.geoserver.acl.domain.rules.RuleAdminService; @@ -33,12 +35,14 @@ import java.util.stream.Collectors; @RequiredArgsConstructor +@IsAuthenticated public class RulesApiImpl implements RulesApiDelegate { private final @NonNull RuleAdminService service; private final @NonNull RulesApiSupport support; @Override + @IsAdmin public ResponseEntity createRule(@NonNull Rule rule, InsertPosition position) { org.geoserver.acl.domain.rules.Rule model = support.toModel(rule); org.geoserver.acl.domain.rules.Rule created; @@ -58,6 +62,7 @@ public ResponseEntity createRule(@NonNull Rule rule, InsertPosition positi } @Override + @IsAdmin public ResponseEntity deleteRuleById(@NonNull String id) { boolean deleted = service.delete(id); @@ -146,6 +151,7 @@ public ResponseEntity ruleExistsById(@NonNull String id) { } @Override + @IsAdmin public ResponseEntity setRuleAllowedStyles(@NonNull String id, Set requestBody) { try { service.setAllowedStyles(id, requestBody); @@ -167,6 +173,7 @@ public ResponseEntity getLayerDetailsByRuleId(@NonNull String id) } @Override + @IsAdmin public ResponseEntity setRuleLayerDetails(@NonNull String id, LayerDetails layerDetails) { try { org.geoserver.acl.domain.rules.LayerDetails ld = support.toModel(layerDetails); @@ -178,6 +185,7 @@ public ResponseEntity setRuleLayerDetails(@NonNull String id, LayerDetails } @Override + @IsAdmin public ResponseEntity setRuleLimits(@NonNull String id, RuleLimits ruleLimits) { try { service.setLimits(id, support.toModel(ruleLimits)); @@ -188,6 +196,7 @@ public ResponseEntity setRuleLimits(@NonNull String id, RuleLimits ruleLim } @Override + @IsAdmin public ResponseEntity shiftRulesByPriority(Long priorityStart, Long offset) { try { int affectedCount = service.shift(priorityStart, offset); @@ -198,6 +207,7 @@ public ResponseEntity shiftRulesByPriority(Long priorityStart, Long off } @Override + @IsAdmin public ResponseEntity swapRules(@NonNull String id, @NonNull String id2) { try { service.swapPriority(id, id2); @@ -208,6 +218,7 @@ public ResponseEntity swapRules(@NonNull String id, @NonNull String id2) { } @Override + @IsAdmin public ResponseEntity updateRuleById(@NonNull String id, Rule patchBody) { final org.geoserver.acl.domain.rules.Rule rule = service.get(id).orElse(null); if (null == rule) { diff --git a/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/support/IsAdmin.java b/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/support/IsAdmin.java new file mode 100644 index 0000000..5a5ffe2 --- /dev/null +++ b/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/support/IsAdmin.java @@ -0,0 +1,19 @@ +/* (c) 2023 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.api.server.support; + +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@PreAuthorize("hasRole('ADMIN')") +public @interface IsAdmin {} diff --git a/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/support/IsAuthenticated.java b/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/support/IsAuthenticated.java new file mode 100644 index 0000000..a360494 --- /dev/null +++ b/src/integration/openapi/spring-server/src/main/java/org/geoserver/acl/api/server/support/IsAuthenticated.java @@ -0,0 +1,19 @@ +/* (c) 2023 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.api.server.support; + +import org.springframework.security.access.prepost.PreAuthorize; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@PreAuthorize("isAuthenticated()") +public @interface IsAuthenticated {} diff --git a/src/integration/openapi/spring-server/src/test/java/org/geoserver/acl/api/it/security/RulesApiSecurityIT.java b/src/integration/openapi/spring-server/src/test/java/org/geoserver/acl/api/it/security/RulesApiSecurityIT.java new file mode 100644 index 0000000..c71a9a9 --- /dev/null +++ b/src/integration/openapi/spring-server/src/test/java/org/geoserver/acl/api/it/security/RulesApiSecurityIT.java @@ -0,0 +1,247 @@ +/* (c) 2023 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.api.it.security; + +import static org.assertj.core.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import org.geoserver.acl.api.model.AccessRequest; +import org.geoserver.acl.api.model.AdminAccessRequest; +import org.geoserver.acl.api.model.AdminRule; +import org.geoserver.acl.api.model.AdminRuleFilter; +import org.geoserver.acl.api.model.InsertPosition; +import org.geoserver.acl.api.model.LayerDetails; +import org.geoserver.acl.api.model.Rule; +import org.geoserver.acl.api.model.RuleFilter; +import org.geoserver.acl.api.model.RuleLimits; +import org.geoserver.acl.api.server.AdminRulesApi; +import org.geoserver.acl.api.server.AuthorizationApi; +import org.geoserver.acl.api.server.RulesApi; +import org.geoserver.acl.api.server.support.RulesApiSupport; +import org.geoserver.acl.authorization.AuthorizationService; +import org.geoserver.acl.domain.adminrules.AdminRuleAdminService; +import org.geoserver.acl.domain.rules.RuleAdminService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.test.context.support.WithMockUser; + +import java.util.Set; + +@SpringBootTest( + classes = SecurityTestConfiguration.class, + properties = "spring.main.banner-mode=off") +class RulesApiSecurityIT { + + private @MockBean RuleAdminService rulesService; + private @MockBean AdminRuleAdminService adminRulesService; + private @MockBean AuthorizationService authService; + + private @Autowired RulesApi rulesApi; + private @Autowired AdminRulesApi adminRulesApi; + private @Autowired AuthorizationApi authApi; + + private @Autowired RulesApiSupport support; + + @Test + void rulesApiNoSecuritySetUp() { + assertAuthCredentialsNotFound(() -> rulesApi.getLayerDetailsByRuleId("id")); + assertAuthCredentialsNotFound(() -> rulesApi.getRuleById("id1")); + assertAuthCredentialsNotFound(() -> rulesApi.getRules(null, null)); + assertAuthCredentialsNotFound(rulesApi::countAllRules); + assertAuthCredentialsNotFound(() -> rulesApi.countRules(new RuleFilter())); + assertAuthCredentialsNotFound(() -> rulesApi.createRule(new Rule(), InsertPosition.FIXED)); + assertAuthCredentialsNotFound(() -> rulesApi.deleteRuleById("id")); + assertAuthCredentialsNotFound(() -> rulesApi.findOneRuleByPriority(1L)); + assertAuthCredentialsNotFound(() -> rulesApi.queryRules(1, "2", new RuleFilter())); + assertAuthCredentialsNotFound(() -> rulesApi.ruleExistsById("1")); + assertAuthCredentialsNotFound(() -> rulesApi.setRuleAllowedStyles("1", Set.of())); + assertAuthCredentialsNotFound(() -> rulesApi.setRuleLayerDetails("1", new LayerDetails())); + assertAuthCredentialsNotFound(() -> rulesApi.setRuleLimits("1", new RuleLimits())); + assertAuthCredentialsNotFound(() -> rulesApi.shiftRulesByPriority(1L, 2L)); + assertAuthCredentialsNotFound(() -> rulesApi.swapRules("1", "2")); + assertAuthCredentialsNotFound(() -> rulesApi.updateRuleById("1", new Rule())); + } + + @Test + void adminRulesApiNoSecuritySetUp() { + assertAuthCredentialsNotFound(() -> adminRulesApi.adminRuleExistsById("1")); + assertAuthCredentialsNotFound(() -> adminRulesApi.countAdminRules(new AdminRuleFilter())); + assertAuthCredentialsNotFound(() -> adminRulesApi.countAllAdminRules()); + assertAuthCredentialsNotFound( + () -> adminRulesApi.createAdminRule(new AdminRule(), InsertPosition.FIXED)); + assertAuthCredentialsNotFound(() -> adminRulesApi.deleteAdminRuleById("1")); + assertAuthCredentialsNotFound( + () -> adminRulesApi.findAdminRules(1, "", new AdminRuleFilter())); + assertAuthCredentialsNotFound(() -> adminRulesApi.findAllAdminRules(null, null)); + assertAuthCredentialsNotFound( + () -> adminRulesApi.findFirstAdminRule(new AdminRuleFilter())); + assertAuthCredentialsNotFound(() -> adminRulesApi.findOneAdminRuleByPriority(1L)); + assertAuthCredentialsNotFound(() -> adminRulesApi.getAdminRuleById("1")); + assertAuthCredentialsNotFound(() -> adminRulesApi.shiftAdminRulesByPiority(1L, 2L)); + assertAuthCredentialsNotFound(() -> adminRulesApi.swapAdminRules("1", "2")); + assertAuthCredentialsNotFound(() -> adminRulesApi.updateAdminRule("1", new AdminRule())); + } + + @Test + void authorizationApiNoSecuritySetUp() { + assertAuthCredentialsNotFound(() -> authApi.getAccessInfo(new AccessRequest())); + assertAuthCredentialsNotFound( + () -> authApi.getAdminAuthorization(new AdminAccessRequest())); + assertAuthCredentialsNotFound(() -> authApi.getMatchingRules(new AccessRequest())); + } + + private void assertAuthCredentialsNotFound(Executable callee) { + assertThrows(AuthenticationCredentialsNotFoundException.class, callee); + } + + @Test + @WithMockUser( + username = "someUser", + authorities = {"ANY_ROLE"}) + void authorizationApi() { + authApi.getAccessInfo(new AccessRequest()); + verify(authService, times(1)).getAccessInfo(any()); + + authApi.getAdminAuthorization(new AdminAccessRequest()); + verify(authService, times(1)).getAdminAuthorization(any()); + + authApi.getMatchingRules(new AccessRequest()); + verify(authService, times(1)).getMatchingRules(any()); + } + + @Test + @WithMockUser( + username = "someUser", + authorities = {"ANY_ROLE"}) + void rulesApiNonAdminUser_readOnlyMethods() { + assertRulesApiReadOnlyMethodsAllowed(); + } + + @Test + @WithMockUser( + username = "someUser", + authorities = {"ANY_ROLE"}) + void rulesApiNonAdminUser_mutatingMethods() { + assertAccessDenied(() -> rulesApi.createRule(new Rule(), InsertPosition.FIXED)); + assertAccessDenied(() -> rulesApi.setRuleAllowedStyles("1", Set.of())); + assertAccessDenied(() -> rulesApi.setRuleLayerDetails("1", new LayerDetails())); + assertAccessDenied(() -> rulesApi.setRuleLimits("1", new RuleLimits())); + assertAccessDenied(() -> rulesApi.shiftRulesByPriority(1L, 2L)); + assertAccessDenied(() -> rulesApi.swapRules("1", "2")); + assertAccessDenied(() -> rulesApi.updateRuleById("1", new Rule())); + assertAccessDenied(() -> rulesApi.deleteRuleById("id")); + } + + void assertRulesApiReadOnlyMethodsAllowed() { + rulesApi.getLayerDetailsByRuleId("id"); + rulesApi.getRuleById("id1"); + rulesApi.getRules(null, null); + rulesApi.countAllRules(); + rulesApi.countRules(new RuleFilter()); + rulesApi.findOneRuleByPriority(1L); + rulesApi.queryRules(1, "2", new RuleFilter()); + rulesApi.ruleExistsById("1"); + } + + @Test + @WithMockUser( + username = "adminUser", + authorities = {"ROLE_ADMIN"}) + void rulesApiAdminUser() { + assertRulesApiReadOnlyMethodsAllowed(); + + rulesApi.createRule( + support.toApi(org.geoserver.acl.domain.rules.Rule.allow()), InsertPosition.FIXED); + verify(rulesService, times(1)).insert(any(), any()); + + rulesApi.setRuleAllowedStyles("1", Set.of()); + verify(rulesService, times(1)).setAllowedStyles(eq("1"), anySet()); + + rulesApi.setRuleLayerDetails("1", new LayerDetails()); + verify(rulesService, times(1)).setLayerDetails(eq("1"), any()); + + rulesApi.setRuleLimits("1", new RuleLimits()); + verify(rulesService, times(1)).setLimits(eq("1"), any()); + + rulesApi.shiftRulesByPriority(1L, 2L); + verify(rulesService, times(1)).shift(eq(1L), eq(2L)); + + rulesApi.swapRules("1", "2"); + verify(rulesService, times(1)).swapPriority("1", "2"); + + rulesApi.deleteRuleById("id"); + verify(rulesService, times(1)).delete("id"); + + rulesApi.updateRuleById("1", new Rule()); + } + + @Test + @WithMockUser( + username = "someUser", + authorities = {"ANY_ROLE"}) + void adminRulesApiNonAdminUser_readOnlyMethods() { + assertAdminRulesApiReadOnlyAllowed(); + } + + private void assertAdminRulesApiReadOnlyAllowed() { + try { + adminRulesApi.adminRuleExistsById("1"); + adminRulesApi.countAdminRules(new AdminRuleFilter()); + adminRulesApi.countAllAdminRules(); + adminRulesApi.findAdminRules(1, "", new AdminRuleFilter()); + adminRulesApi.findAllAdminRules(null, null); + adminRulesApi.findFirstAdminRule(new AdminRuleFilter()); + adminRulesApi.findOneAdminRuleByPriority(1L); + adminRulesApi.getAdminRuleById("1"); + } catch (Exception e) { + fail("No exception expected: " + e.getMessage()); + } + } + + @Test + @WithMockUser( + username = "someUser", + authorities = {"ANY_ROLE"}) + void adminRulesApiNonAdminUser_mutatingMethods() { + assertAccessDenied( + () -> adminRulesApi.createAdminRule(new AdminRule(), InsertPosition.FIXED)); + assertAccessDenied(() -> adminRulesApi.deleteAdminRuleById("1")); + assertAccessDenied(() -> adminRulesApi.shiftAdminRulesByPiority(1L, 2L)); + assertAccessDenied(() -> adminRulesApi.swapAdminRules("1", "2")); + assertAccessDenied(() -> adminRulesApi.updateAdminRule("1", new AdminRule())); + } + + @Test + @WithMockUser( + username = "adminUser", + authorities = {"ROLE_ADMIN"}) + void adminRulesApiAdminUser() { + assertAdminRulesApiReadOnlyAllowed(); + + try { + adminRulesApi.createAdminRule(new AdminRule(), InsertPosition.FIXED); + adminRulesApi.deleteAdminRuleById("1"); + adminRulesApi.shiftAdminRulesByPiority(1L, 2L); + adminRulesApi.swapAdminRules("1", "2"); + adminRulesApi.updateAdminRule("1", new AdminRule()); + } catch (Exception e) { + fail("No exception expected: " + e.getMessage()); + } + } + + private void assertAccessDenied(Executable callee) { + assertThrows(AccessDeniedException.class, callee); + } +} diff --git a/src/integration/openapi/spring-server/src/test/java/org/geoserver/acl/api/it/security/SecurityTestConfiguration.java b/src/integration/openapi/spring-server/src/test/java/org/geoserver/acl/api/it/security/SecurityTestConfiguration.java new file mode 100644 index 0000000..98bc990 --- /dev/null +++ b/src/integration/openapi/spring-server/src/test/java/org/geoserver/acl/api/it/security/SecurityTestConfiguration.java @@ -0,0 +1,16 @@ +/* (c) 2023 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.api.it.security; + +import org.geoserver.acl.api.server.config.AuthorizationApiConfiguration; +import org.geoserver.acl.api.server.config.RulesApiConfiguration; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; + +@Configuration +@Import({RulesApiConfiguration.class, AuthorizationApiConfiguration.class}) +@EnableGlobalMethodSecurity(prePostEnabled = true) +public class SecurityTestConfiguration {} diff --git a/src/integration/openapi/spring-server/src/test/java/org/geoserver/acl/api/server/config/AuthorizationApiConfigurationTest.java b/src/integration/openapi/spring-server/src/test/java/org/geoserver/acl/api/server/config/AuthorizationApiConfigurationTest.java new file mode 100644 index 0000000..11636f2 --- /dev/null +++ b/src/integration/openapi/spring-server/src/test/java/org/geoserver/acl/api/server/config/AuthorizationApiConfigurationTest.java @@ -0,0 +1,57 @@ +/* (c) 2023 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.api.server.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import org.geoserver.acl.api.server.AuthorizationApiController; +import org.geoserver.acl.api.server.AuthorizationApiDelegate; +import org.geoserver.acl.authorization.AuthorizationService; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.annotation.UserConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.web.context.request.NativeWebRequest; + +class AuthorizationApiConfigurationTest { + + private ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration(UserConfigurations.of(AuthorizationApiConfiguration.class)); + + private ApplicationContextRunner withMockRepositories() { + runner = withMock(NativeWebRequest.class); + runner = withMock(AuthorizationService.class); + return runner; + } + + private ApplicationContextRunner withMock(Class beanType) { + return runner.withBean(beanType, () -> mock(beanType)); + } + + @Test + void testWithAvailableAuthorizationService() { + withMockRepositories() + .run( + context -> { + assertThat(context) + .hasNotFailed() + .hasSingleBean(AuthorizationApiController.class) + .hasSingleBean(AuthorizationApiDelegate.class); + }); + } + + @Test + void testMissingAuthorizationService() { + runner.run( + context -> { + assertThat(context) + .hasFailed() + .getFailure() + .hasMessageContaining("Unsatisfied dependency") + .hasMessageContaining("AuthorizationService"); + }); + } +} diff --git a/src/integration/openapi/spring-server/src/test/java/org/geoserver/acl/api/server/rules/RulesApiImpTest.java b/src/integration/openapi/spring-server/src/test/java/org/geoserver/acl/api/server/rules/RulesApiImpTest.java index 70c592f..c7b233a 100644 --- a/src/integration/openapi/spring-server/src/test/java/org/geoserver/acl/api/server/rules/RulesApiImpTest.java +++ b/src/integration/openapi/spring-server/src/test/java/org/geoserver/acl/api/server/rules/RulesApiImpTest.java @@ -43,7 +43,7 @@ import java.util.function.Supplier; import java.util.stream.Collectors; -@SpringBootTest(classes = RulesApiConfiguration.class) +@SpringBootTest(classes = RulesApiConfiguration.class, properties = "spring.main.banner-mode=off") class RulesApiImpTest { private @MockBean RuleAdminService rules;