Skip to content
Merged
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
10 changes: 5 additions & 5 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ SPDX-License-Identifier: MIT
</parent>
<groupId>nl.b3p.pmw</groupId>
<artifactId>planmonitor-wonen-api</artifactId>
<version>1.2.6-SNAPSHOT</version>
<version>1.3.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Planmonitor Wonen API</name>
<description>Planmonitor Wonen API provides the backend for Planmonitor Wonen</description>
Expand Down 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);
};
}
Comment on lines +107 to +113
Copy link

Copilot AI Jan 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This customizer bean lacks JavaDoc documentation. It should explain why custom SQL queries are needed for PostgreSQL JSONB storage, how they differ from the default queries, and any database-specific requirements or constraints.

Copilot uses AI. Check for mistakes.

@Bean(name = {"springSessionDataSource", "tailormapDataSource"})
@SpringSessionDataSource
public DataSource sessionDataSource() {
Expand All @@ -85,20 +130,74 @@ 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);

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;
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();

final GenericConversionService converter = new GenericConversionService();
// Object -> byte[] (serialize to JSON bytes)
// this is not actually done in the application because sessions are
// read-only, so this is commented out but left here for possible future use
// 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