Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions build/ci/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@
name: 'planmonitor-wonen-ci'

volumes:
postgis-db:
planmonitor-wonen-data:


services:
db:
image: postgis/postgis:18-3.6-alpine
environment:
TZ: Europe/Amsterdam
POSTGRES_USER: planmonitor-wonen
POSTGRES_PASSWORD: planmonitor-wonen
POSTGRES_DB: planmonitor-wonen
volumes:
- postgis-db:/var/lib/postgresql/data
- planmonitor-wonen-data:/var/lib/postgresql/data
- ./initdb:/docker-entrypoint-initdb.d
ports:
- "127.0.0.1:5432:5432"
Expand All @@ -30,6 +31,7 @@ services:
tailormap:
image: ghcr.io/tailormap/tailormap:snapshot
environment:
- TZ=Europe/Amsterdam
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/tailormap
- SPRING_PROFILES_ACTIVE=populate-testdata
- ADMIN_HASHED_PASSWORD
Expand Down
8 changes: 4 additions & 4 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ SPDX-License-Identifier: MIT
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aspectj</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
Expand Down Expand Up @@ -245,10 +249,6 @@ SPDX-License-Identifier: MIT
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-json</artifactId>
</dependency>
<dependency>
<groupId>org.geotools</groupId>
<artifactId>gt-main</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,46 @@

package nl.b3p.planmonitorwonen.api.configuration;

import java.io.InputStream;
import java.io.ObjectInputStream;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import java.lang.invoke.MethodHandles;
import java.nio.charset.StandardCharsets;
import javax.sql.DataSource;
import org.jspecify.annotations.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.BeanClassLoaderAware;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.convert.ConversionFailedException;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.core.convert.support.GenericConversionService;
import org.springframework.core.serializer.Deserializer;
import org.springframework.core.serializer.support.DeserializingConverter;
import org.springframework.core.serializer.support.SerializingConverter;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.security.jackson.SecurityJacksonModules;
import org.springframework.session.config.SessionRepositoryCustomizer;
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
import org.springframework.session.jdbc.config.annotation.SpringSessionDataSource;
import org.springframework.session.jdbc.config.annotation.web.http.EnableJdbcHttpSession;
import org.springframework.transaction.PlatformTransactionManager;
import tools.jackson.core.JacksonException;
import tools.jackson.core.StreamReadFeature;
import tools.jackson.databind.DefaultTyping;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;

@Configuration
@EnableJdbcHttpSession
@Profile("!test")
public class JdbcSessionConfiguration {
public class JdbcSessionConfiguration implements BeanClassLoaderAware {
private static final Logger logger =
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());

@Value("${spring.datasource.url}")
private String dataSourceUrl;

Expand All @@ -48,6 +64,27 @@ public class JdbcSessionConfiguration {
@Value("${tailormap.datasource.password}")
private String sessionDataSourcePassword;

private static final String CREATE_SESSION_ATTRIBUTE_QUERY =
"""
INSERT INTO %TABLE_NAME%_ATTRIBUTES (SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES)
VALUES (?, ?, convert_from(?, 'UTF8')::jsonb)
""";

private static final String UPDATE_SESSION_ATTRIBUTE_QUERY =
"""
UPDATE %TABLE_NAME%_ATTRIBUTES
SET ATTRIBUTE_BYTES = encode(?, 'escape')::jsonb
WHERE SESSION_PRIMARY_ID = ?
AND ATTRIBUTE_NAME = ?
""";

private ClassLoader classLoader;

@Override
public void setBeanClassLoader(@NonNull ClassLoader classLoader) {
this.classLoader = classLoader;
}

@Bean
public DataSource dataSource() {
DataSourceBuilder<?> dataSourceBuilder = DataSourceBuilder.create();
Expand All @@ -67,6 +104,14 @@ public JdbcClient tailormapJdbcClient(@Qualifier("tailormapDataSource") DataSour
return JdbcClient.create(data);
}

@Bean
SessionRepositoryCustomizer<JdbcIndexedSessionRepository> customizer() {
return (sessionRepository) -> {
sessionRepository.setCreateSessionAttributeQuery(CREATE_SESSION_ATTRIBUTE_QUERY);
sessionRepository.setUpdateSessionAttributeQuery(UPDATE_SESSION_ATTRIBUTE_QUERY);
};
}

@Bean(name = {"springSessionDataSource", "tailormapDataSource"})
@SpringSessionDataSource
public DataSource sessionDataSource() {
Expand All @@ -85,20 +130,78 @@ public PlatformTransactionManager springSessionTransactionOperations(

@Bean("springSessionConversionService")
public ConversionService springSessionConversionService() {
GenericConversionService converter = new GenericConversionService();
converter.addConverter(Object.class, byte[].class, new SerializingConverter());
converter.addConverter(byte[].class, Object.class, new DeserializingConverter(new CustomDeserializer()));
return converter;
}
BasicPolymorphicTypeValidator.Builder builder = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("org.tailormap.api.security.")
.allowIfSubType("org.springframework.security.")
.allowIfSubType("java.util.")
.allowIfSubType(java.lang.Number.class)
.allowIfSubType("java.time.")
.allowIfBaseType(Object.class);

JsonMapper mapper = JsonMapper.builder()
.configure(
StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION,
(logger.isDebugEnabled() || logger.isTraceEnabled()))
.configure(SerializationFeature.INDENT_OUTPUT, (logger.isDebugEnabled() || logger.isTraceEnabled()))
.addMixIn(
org.tailormap.api.security.TailormapUserDetailsImpl.class,
org.tailormap.api.security.TailormapUserDetailsImplMixin.class)
.addMixIn(
org.tailormap.api.security.TailormapOidcUser.class,
org.tailormap.api.security.TailormapOidcUserMixin.class)
.addModules(SecurityJacksonModules.getModules(this.classLoader, builder))
.activateDefaultTyping(builder.build(), DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY)
.build();

static class CustomDeserializer implements Deserializer<Object> {
@Override
public Object deserialize(InputStream inputStream) {
try (ObjectInputStream ois = new ObjectInputStream(inputStream)) {
return ois.readObject();
} catch (Exception ignored) {
return null;
final GenericConversionService converter = new GenericConversionService();
// Object -> byte[] (serialize to JSON bytes)
converter.addConverter(Object.class, byte[].class, source -> {
try {
logger.debug("Serializing Spring Session: {}", source);
return mapper.writerFor(Object.class).writeValueAsBytes(source);
} catch (JacksonException e) {
logger.error("Error serializing Spring Session object: {}", source, e);
throw new ConversionFailedException(
TypeDescriptor.forObject(source),
TypeDescriptor.valueOf(byte[].class),
source,
e);
}
}
});
// byte[] -> Object (deserialize from JSON bytes)
converter.addConverter(byte[].class, Object.class, source -> {
try {
logger.debug(
"Deserializing Spring Session from bytes, length: {} ({})",
source.length,
new String(source, StandardCharsets.UTF_8));
return mapper.readValue(source, Object.class);
} catch (JacksonException e) {
String preview;
try {
String content = new String(source, StandardCharsets.UTF_8);
int maxLength = 256;
if (logger.isDebugEnabled() || logger.isTraceEnabled()) {
preview = content;
} else {
preview = content.length() > maxLength ? content.substring(0, maxLength) + "..." : content;
}
} catch (Exception ex) {
preview = "<unavailable>";
}
logger.error(
"Error deserializing Spring Session from bytes, length: {}, preview: {}",
source.length,
preview,
e);
throw new ConversionFailedException(
TypeDescriptor.valueOf(byte[].class),
TypeDescriptor.valueOf(Object.class),
source,
e);
}
});

return converter;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ public void init() throws ParseException {

private void populateGemeentes() throws IOException {
String sql = ImportGemeentesApplication.getGemeentesSql(bestuurlijkeGebiedenWfs, gemeentesTypename);
// Intentionally clear the gemeente table before (re)importing test/demo data.
// This configuration is only active when the `planmonitor-wonen-api.populate-testdata`
// property is set to `true` and is not meant to be enabled in production environments.
// Ensure that this remains disabled in production and that schema constraints allow
// truncating and repopulating the gemeente table in this way.
this.jdbcClient.sql("delete from gemeente").update();
this.jdbcClient.sql(sql).update();
}
}
18 changes: 8 additions & 10 deletions src/main/java/org/tailormap/api/security/TailormapOidcUser.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
package org.tailormap.api.security;

import java.io.Serial;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import org.jspecify.annotations.NonNull;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
Expand All @@ -19,7 +20,7 @@ public class TailormapOidcUser extends DefaultOidcUser implements TailormapUserD
@Serial
private static final long serialVersionUID = 1L;

private final Collection<TailormapAdditionalProperty> additionalGroupProperties;
private final Collection<TailormapAdditionalProperty> additionalGroupProperties = new ArrayList<>();

private final String oidcRegistrationName;

Expand All @@ -32,17 +33,14 @@ public TailormapOidcUser(
Collection<TailormapAdditionalProperty> additionalGroupProperties) {
super(authorities, idToken, userInfo, nameAttributeKey);
this.oidcRegistrationName = oidcRegistrationName;
this.additionalGroupProperties = Collections.unmodifiableCollection(additionalGroupProperties);
}

@Override
public Collection<TailormapAdditionalProperty> getAdditionalProperties() {
return List.of();
if (additionalGroupProperties != null) {
this.additionalGroupProperties.addAll(additionalGroupProperties);
}
}

@Override
public Collection<TailormapAdditionalProperty> getAdditionalGroupProperties() {
return additionalGroupProperties;
return Collections.unmodifiableCollection(additionalGroupProperties);
}

@Override
Expand All @@ -51,7 +49,7 @@ public String getPassword() {
}

@Override
public String getUsername() {
@NonNull public String getUsername() {
return super.getName();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright (C) 2025 B3Partners B.V.
*
* SPDX-License-Identifier: MIT
*/
package org.tailormap.api.security;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import java.util.Collection;
import java.util.Map;
import org.springframework.security.core.GrantedAuthority;

@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
@JsonAutoDetect(
fieldVisibility = JsonAutoDetect.Visibility.ANY,
getterVisibility = JsonAutoDetect.Visibility.NONE,
isGetterVisibility = JsonAutoDetect.Visibility.NONE)
@JsonIgnoreProperties(ignoreUnknown = true)
public abstract class TailormapOidcUserMixin {

@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
public TailormapOidcUserMixin(
@SuppressWarnings("unused") @JsonProperty("claims") Map<String, Object> claims,
@SuppressWarnings("unused") @JsonProperty("authorities") Collection<? extends GrantedAuthority> authorities,
@SuppressWarnings("unused") @JsonProperty("attributes") Map<String, Object> attributes,
@SuppressWarnings("unused") @JsonProperty("nameAttributeKey") String nameAttributeKey,
@SuppressWarnings("unused") @JsonProperty("oidcRegistrationName") String oidcRegistrationName,
@SuppressWarnings("unused") @JsonProperty("additionalGroupProperties")
Collection<TailormapAdditionalProperty> additionalGroupProperties) {
// mixin constructor only for Jackson; no implementation
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@

import java.io.Serializable;
import java.util.Collection;
import java.util.Collections;
import java.util.stream.Stream;
import org.springframework.security.core.userdetails.UserDetails;

public interface TailormapUserDetails extends Serializable, UserDetails {

Collection<TailormapAdditionalProperty> getAdditionalProperties();
default Collection<TailormapAdditionalProperty> getAdditionalProperties() {
return Collections.emptyList();
}

Collection<TailormapAdditionalProperty> getAdditionalGroupProperties();

Expand Down
Loading
Loading