Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Use snake case method names in test classes, e.g., `should_return_true_when_inpu
- **Test-Driven Development**: Promote writing tests before implementing features to ensure requirements are met.

### Running tests
- Use `mvn test` to run all tests.
- Use `mvn test` to run all unit tests.
- Use `mvn -B -fae -e -DskipQA=true -Pqa-skip -Dspotless.apply.skip -Ddocker.skip=true -Ppostgresql verify -Dspring-boot.run.profiles=dev,populate-testdata,postgresql -Dspring-boot.run.arguments=--spatial.dbs.connect=true` to run all integration tests`

## Performance Considerations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,38 +6,129 @@

package org.tailormap.api.configuration;

import java.io.InputStream;
import java.io.ObjectInputStream;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.core.StreamReadFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import java.io.IOException;
import java.lang.invoke.MethodHandles;
import java.nio.charset.StandardCharsets;
import org.jspecify.annotations.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.BeanClassLoaderAware;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.ConversionService;
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.security.jackson2.SecurityJackson2Modules;
import org.springframework.session.config.SessionRepositoryCustomizer;
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;

@Configuration(proxyBeanMethods = false)
public class JdbcSessionConfiguration implements BeanClassLoaderAware {
private static final Logger logger =
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());

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
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

The SQL query syntax is incorrect. The UPDATE query uses encode() function with 'escape' encoding to convert bytes to text, then casts to jsonb. However, the parameter is already UTF-8 JSON bytes and should use convert_from() like the INSERT query. The encode() function with 'escape' encoding is meant for binary data, not JSON. This will likely cause invalid JSON to be stored in the database.

Suggested change
SET ATTRIBUTE_BYTES = encode(?, 'escape')::jsonb
SET ATTRIBUTE_BYTES = convert_from(?, 'UTF8')::jsonb

Copilot uses AI. Check for mistakes.
WHERE SESSION_PRIMARY_ID = ?
AND ATTRIBUTE_NAME = ?
""";
Comment on lines +34 to +46
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

The custom SQL queries use PostgreSQL-specific functions (convert_from and encode) but the table name placeholder pattern (%TABLE_NAME%) suggests this might be intended to work with Spring Session's table name customization. If these queries are meant to be customizable, they should be documented. Additionally, the encode() usage in the UPDATE query appears incorrect - it should use convert_from() like the INSERT query to properly handle UTF-8 JSON bytes.

Copilot uses AI. Check for mistakes.

private ClassLoader classLoader;

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

/**
* Ignore deserialization exceptions, see <a
* href="https://github.com/spring-projects/spring-session/issues/529#issuecomment-2761671945">here</a>.
*/
@Configuration
public class JdbcSessionConfiguration {
@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()));
public ConversionService springSessionConversionService(ObjectMapper objectMapper) {

BasicPolymorphicTypeValidator ptv = 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)
.build();

ObjectMapper copy = objectMapper
.copy()
.configure(
StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION.mappedFeature(),
(logger.isDebugEnabled() || logger.isTraceEnabled()))
.configure(SerializationFeature.INDENT_OUTPUT, (logger.isDebugEnabled() || logger.isTraceEnabled()));

// register mixins early so Jackson picks up the @JsonCreator constructor for TailormapUserDetails
// implementations
copy.addMixIn(
org.tailormap.api.security.TailormapUserDetailsImpl.class,
org.tailormap.api.security.TailormapUserDetailsImplMixin.class);

copy.addMixIn(
org.tailormap.api.security.TailormapOidcUser.class,
org.tailormap.api.security.TailormapOidcUserMixin.class);

copy.registerModules(SecurityJackson2Modules.getModules(this.classLoader))
.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);

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 copy.writerFor(Object.class).writeValueAsBytes(source);
} catch (IOException e) {
logger.error("Error serializing Spring Session object: {}", source, e);
throw new RuntimeException("Unable to serialize Spring Session.", e);

Check warning on line 98 in src/main/java/org/tailormap/api/configuration/JdbcSessionConfiguration.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/configuration/JdbcSessionConfiguration.java#L96-L98

Added lines #L96 - L98 were not covered by tests
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

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

When serialization fails, the exception is caught and wrapped in a RuntimeException with a generic message "Unable to serialize Spring Session." However, the original exception details might contain important information for debugging. Consider providing more context in the error message, such as the type of object that failed to serialize, to help with debugging serialization issues in production.

Suggested change
throw new RuntimeException("Unable to serialize Spring Session.", e);
String message =
"Unable to serialize Spring Session attribute of type "
+ (source != null ? source.getClass().getName() : "null")
+ ".";
throw new RuntimeException(message, e);

Copilot uses AI. Check for mistakes.
}
});
// 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 copy.readValue(source, Object.class);
} catch (IOException e) {

Check warning on line 109 in src/main/java/org/tailormap/api/configuration/JdbcSessionConfiguration.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/configuration/JdbcSessionConfiguration.java#L109

Added line #L109 was not covered by tests
String preview;
try {
String content = new String(source, StandardCharsets.UTF_8);
int maxLength = 256;

Check warning on line 113 in src/main/java/org/tailormap/api/configuration/JdbcSessionConfiguration.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/configuration/JdbcSessionConfiguration.java#L112-L113

Added lines #L112 - L113 were not covered by tests
preview = content.length() > maxLength ? content.substring(0, maxLength) + "..." : content;
} catch (Exception ex) {
preview = "<unavailable>";
}
logger.error(

Check warning on line 118 in src/main/java/org/tailormap/api/configuration/JdbcSessionConfiguration.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/configuration/JdbcSessionConfiguration.java#L115-L118

Added lines #L115 - L118 were not covered by tests
"Error deserializing Spring Session from bytes, length: {}, preview: {}",
source.length,

Check warning on line 120 in src/main/java/org/tailormap/api/configuration/JdbcSessionConfiguration.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/configuration/JdbcSessionConfiguration.java#L120

Added line #L120 was not covered by tests
preview,
e);
throw new RuntimeException("Unable to deserialize Spring Session.", e);

Check warning on line 123 in src/main/java/org/tailormap/api/configuration/JdbcSessionConfiguration.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/configuration/JdbcSessionConfiguration.java#L123

Added line #L123 was not covered by tests
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

The error handling for deserialization failures wraps exceptions in RuntimeException without providing specific context. When session deserialization fails, the generic message "Unable to deserialize Spring Session." does not provide enough information for debugging. Consider providing more detailed error messages that include information about the data being deserialized, such as the length or a truncated preview of the JSON content.

Suggested change
throw new RuntimeException("Unable to deserialize Spring Session.", e);
String preview;
try {
String content = new String(source, StandardCharsets.UTF_8);
int maxLength = 256;
preview =
content.length() > maxLength
? content.substring(0, maxLength) + "..."
: content;
} catch (Exception ex) {
// Fallback in case the bytes cannot be converted to a UTF-8 string
preview = "<unavailable>";
}
throw new RuntimeException(
"Unable to deserialize Spring Session. Payload length=" + source.length
+ ", preview='" + preview + "'",
e);

Copilot uses AI. Check for mistakes.
}
});

return converter;
}

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;
}
}
@Override
public void setBeanClassLoader(@NonNull ClassLoader classLoader) {
this.classLoader = classLoader;
}
}
15 changes: 6 additions & 9 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,9 @@
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.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
Expand All @@ -19,7 +19,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 +32,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 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) {

Check warning on line 33 in src/main/java/org/tailormap/api/security/TailormapOidcUserMixin.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/security/TailormapOidcUserMixin.java#L33

Added line #L33 was not covered by tests
Comment on lines +32 to +33
// mixin constructor only for Jackson 2; no implementation
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

The comment refers to "Jackson 2" but should just say "Jackson" since it's no longer necessary to specify the version in comments. Jackson 2.x has been the standard for many years, and version-specific comments can become outdated.

Suggested change
// mixin constructor only for Jackson 2; no implementation
// mixin constructor only for Jackson; no implementation

Copilot uses AI. Check for mistakes.
}

Check warning on line 35 in src/main/java/org/tailormap/api/security/TailormapOidcUserMixin.java

View check run for this annotation

Codecov / codecov/patch

src/main/java/org/tailormap/api/security/TailormapOidcUserMixin.java#L35

Added line #L35 was not covered by tests
Comment on lines +25 to +35
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

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

The TailormapOidcUserMixin specifies a @JsonCreator constructor with parameters claims, authorities, attributes, nameAttributeKey, oidcRegistrationName, and additionalGroupProperties. However, TailormapOidcUser does not have a constructor with this signature. The actual constructor has parameters: authorities, idToken, userInfo, nameAttributeKey, oidcRegistrationName, additionalGroupProperties. This mismatch will cause Jackson deserialization to fail because Jackson cannot find a matching constructor. You need to add a constructor that matches the mixin signature or adjust the mixin to match the actual constructor.

Copilot uses AI. Check for mistakes.
}
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
*/
package org.tailormap.api.security;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serial;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.GrantedAuthority;
Expand All @@ -19,8 +21,14 @@
import org.tailormap.api.persistence.json.AdminAdditionalProperty;
import org.tailormap.api.repository.GroupRepository;

Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

Changing class visibility from package-private to public weakens encapsulation. This class was intentionally made package-private with a comment "Do not make public, use the interface". Making it public exposes implementation details and may allow external code to depend directly on the implementation rather than the TailormapUserDetails interface. If public visibility is required for Jackson deserialization, document why this design decision was made.

Suggested change
/**
* Internal {@link TailormapUserDetails} implementation.
*
* <p>Do not use this class directly; always depend on the {@link TailormapUserDetails}
* interface instead. This class is {@code public} only to support JSON/session
* deserialization (e.g. by Jackson / Spring Session). Changing its visibility back to
* package-private would break that deserialization.</p>
*/

Copilot uses AI. Check for mistakes.
/* Do not make public, use the interface */
class TailormapUserDetailsImpl implements TailormapUserDetails {
/**
* Internal {@link TailormapUserDetails} implementation.
*
* <p>Do not use this class directly; always depend on the {@link TailormapUserDetails} interface instead. This class is
* {@code public} only to support JSON/session deserialization (e.g. by Jackson / Spring Session). Changing its
* visibility back to package-private would break that deserialization.
*/
public class TailormapUserDetailsImpl implements TailormapUserDetails {

@Serial
private static final long serialVersionUID = 1L;
Expand All @@ -35,6 +43,43 @@ class TailormapUserDetailsImpl implements TailormapUserDetails {
private final Collection<TailormapAdditionalProperty> additionalProperties = new ArrayList<>();
private final Collection<TailormapAdditionalProperty> additionalGroupProperties = new ArrayList<>();

/**
* Constructor for Jackson deserialization.
*
* @param authorities the authorities
* @param username the username
* @param password the password
* @param validUntil the valid until date
* @param enabled whether the user is enabled
* @param organisation the organisation
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

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

The JavaDoc is incomplete. It lists 6 parameters but is missing documentation for the additionalProperties and additionalGroupProperties parameters that are specified in the mixin constructor. These parameters should be documented to match the mixin definition.

Suggested change
* @param organisation the organisation
* @param organisation the organisation
* @param additionalProperties additional user-specific properties
* @param additionalGroupProperties additional properties derived from the user's groups

Copilot uses AI. Check for mistakes.
* @param additionalProperties the additional properties
* @param additionalGroupProperties the additional group properties
*/
@SuppressWarnings("unused")
TailormapUserDetailsImpl(
@JsonProperty("authorities") Collection<GrantedAuthority> authorities,
@JsonProperty("username") String username,
@JsonProperty("password") String password,
@JsonProperty("validUntil") ZonedDateTime validUntil,
@JsonProperty("enabled") boolean enabled,
@JsonProperty("organisation") String organisation,
@JsonProperty("additionalProperties") Collection<TailormapAdditionalProperty> additionalProperties,
@JsonProperty("additionalGroupProperties")
Collection<TailormapAdditionalProperty> additionalGroupProperties) {
this.authorities = authorities;
this.username = username;
this.password = password;
this.validUntil = validUntil;
this.enabled = enabled;
this.organisation = organisation;
if (additionalProperties != null) {
this.additionalProperties.addAll(additionalProperties);
}
if (additionalGroupProperties != null) {
this.additionalGroupProperties.addAll(additionalGroupProperties);
}
}

public TailormapUserDetailsImpl(User user, GroupRepository groupRepository) {
authorities = new HashSet<>();
user.getGroups().stream()
Expand All @@ -61,10 +106,12 @@ public TailormapUserDetailsImpl(User user, GroupRepository groupRepository) {
}
}

// For group properties, look in the database with a list of authorities instead of user.getGroups(), so
// aliasForGroup is taken into account
this.additionalGroupProperties.addAll(groupRepository.findAdditionalPropertiesByGroups(
authorities.stream().map(GrantedAuthority::getAuthority).toList()));
if (groupRepository != null) {
// For group properties, look in the database with a list of authorities instead of user.getGroups(), so
// aliasForGroup is taken into account
this.additionalGroupProperties.addAll(groupRepository.findAdditionalPropertiesByGroups(
authorities.stream().map(GrantedAuthority::getAuthority).toList()));
}
}

@Override
Expand Down Expand Up @@ -99,11 +146,11 @@ public String getOrganisation() {

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

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