Skip to content

Commit d9dabb7

Browse files
committed
feat: pluggable HTTP session store — JDBC and Redis backends for multi-pod deployments
Adds server.session-store config (none | jdbc | redis). In-memory sessions (none) remain the default so existing single-instance deployments are unaffected. Set session-store: jdbc to persist sessions to the existing JDBC database with zero new infrastructure; set session-store: redis to point at an in-cluster Redis/Valkey pod. Session store is wired via Spring Session 4.0.2. The SessionRepositoryFilter is registered in the Jetty filter chain ahead of Spring Security so that distributed sessions are in place before authentication state is read. JDBC backend creates SPRING_SESSION / SPRING_SESSION_ATTRIBUTES tables via DatabaseMigrator V4. V3 (email unique constraint, previously on disk but unregistered) is also added to the migration registry. Redis connection configured via server.redis.{host,port,password,ssl}. closes #136
1 parent c380c6d commit d9dabb7

9 files changed

Lines changed: 237 additions & 4 deletions

File tree

build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ ext {
3535
// Spring
3636
springVersion = '7.0.6'
3737
springSecurityVersion = '7.0.4'
38+
springSessionVersion = '4.0.2'
3839

3940
// YAML
4041
snakeyamlVersion = '2.2'

git-proxy-java-core/src/main/java/org/finos/gitproxy/db/jdbc/DatabaseMigrator.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ private record Migration(String version, String description, String resource, bo
3838
new Migration("1", "initial schema", "db/migration/V1__initial_schema.sql", false),
3939
new Migration("2", "provider id format", "db/migration/V2__provider_id_format.sql", false),
4040
new Migration(
41-
"2.1", "widen provider columns", "db/migration-postgresql/V2_1__widen_provider_columns.sql", true));
41+
"2.1", "widen provider columns", "db/migration-postgresql/V2_1__widen_provider_columns.sql", true),
42+
new Migration("3", "email unique constraint", "db/migration/V3__email_unique.sql", false),
43+
new Migration("4", "spring session tables", "db/migration/V4__spring_session.sql", false));
4244

4345
// ---------------------------------------------------------------------------
4446

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-- Spring Session JDBC store tables.
2+
-- Created unconditionally on all JDBC backends so the schema is consistent regardless of whether
3+
-- server.session-store=jdbc is currently configured. Tables are unused when another store is selected.
4+
-- BYTEA is compatible with both PostgreSQL and H2 2.x (which accepts it as a BINARY VARYING alias).
5+
6+
CREATE TABLE IF NOT EXISTS SPRING_SESSION (
7+
PRIMARY_ID CHAR(36) NOT NULL,
8+
SESSION_ID CHAR(36) NOT NULL,
9+
CREATION_TIME BIGINT NOT NULL,
10+
LAST_ACCESS_TIME BIGINT NOT NULL,
11+
MAX_INACTIVE_INTERVAL INT NOT NULL,
12+
EXPIRY_TIME BIGINT NOT NULL,
13+
PRINCIPAL_NAME VARCHAR(100) DEFAULT NULL,
14+
CONSTRAINT SPRING_SESSION_PK PRIMARY KEY (PRIMARY_ID)
15+
);
16+
17+
CREATE UNIQUE INDEX IF NOT EXISTS SPRING_SESSION_IX1 ON SPRING_SESSION (SESSION_ID);
18+
CREATE INDEX IF NOT EXISTS SPRING_SESSION_IX2 ON SPRING_SESSION (EXPIRY_TIME);
19+
CREATE INDEX IF NOT EXISTS SPRING_SESSION_IX3 ON SPRING_SESSION (PRINCIPAL_NAME);
20+
21+
CREATE TABLE IF NOT EXISTS SPRING_SESSION_ATTRIBUTES (
22+
SESSION_PRIMARY_ID CHAR(36) NOT NULL,
23+
ATTRIBUTE_NAME VARCHAR(200) NOT NULL,
24+
ATTRIBUTE_BYTES BYTEA NOT NULL,
25+
CONSTRAINT SPRING_SESSION_ATTRIBUTES_PK PRIMARY KEY (SESSION_PRIMARY_ID, ATTRIBUTE_NAME),
26+
CONSTRAINT SPRING_SESSION_ATTRIBUTES_FK FOREIGN KEY (SESSION_PRIMARY_ID)
27+
REFERENCES SPRING_SESSION (PRIMARY_ID) ON DELETE CASCADE
28+
);

git-proxy-java-dashboard/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ dependencies {
148148
implementation "org.springframework.security:spring-security-oauth2-client:${springSecurityVersion}"
149149
implementation "org.springframework.security:spring-security-oauth2-jose:${springSecurityVersion}"
150150

151+
// Spring Session — JDBC and Redis backends for distributed session management
152+
implementation "org.springframework.session:spring-session-jdbc:${springSessionVersion}"
153+
implementation "org.springframework.session:spring-session-data-redis:${springSessionVersion}"
154+
151155
// Jackson JSON processing
152156
implementation "tools.jackson.core:jackson-databind"
153157

git-proxy-java-dashboard/src/main/java/org/finos/gitproxy/dashboard/GitProxyWithDashboardApplication.java

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,16 @@ public void lifeCycleStopping(LifeCycle event) {
9797
});
9898

9999
// Spring MVC DispatcherServlet at /* - git-specific paths take precedence per servlet spec
100+
var jdbcDataSource = configBuilder.getJdbcDataSourceOrNull();
100101
registerSpringServlet(
101-
context, ctx, providerConfig, gitProxyConfig, configHolder, liveConfigLoader, repoRegistry);
102+
context,
103+
ctx,
104+
providerConfig,
105+
gitProxyConfig,
106+
configHolder,
107+
liveConfigLoader,
108+
repoRegistry,
109+
jdbcDataSource);
102110

103111
server.setHandler(context);
104112
server.start();
@@ -118,9 +126,10 @@ private static void registerSpringServlet(
118126
GitProxyConfig gitProxyConfig,
119127
ConfigHolder configHolder,
120128
LiveConfigLoader liveConfigLoader,
121-
RepoRegistry repoRegistry) {
129+
RepoRegistry repoRegistry,
130+
javax.sql.DataSource jdbcDataSource) {
122131
var appContext = new AnnotationConfigWebApplicationContext();
123-
appContext.register(SpringWebConfig.class, SecurityConfig.class);
132+
appContext.register(SpringWebConfig.class, SecurityConfig.class, SessionStoreConfig.class);
124133
appContext.addBeanFactoryPostProcessor(bf -> {
125134
bf.registerSingleton("pushStore", ctx.pushStore());
126135
bf.registerSingleton("providers", providers);
@@ -133,6 +142,11 @@ private static void registerSpringServlet(
133142
if (ctx.repoPermissionService() != null) {
134143
bf.registerSingleton("repoPermissionService", ctx.repoPermissionService());
135144
}
145+
// Expose the JDBC DataSource as a Spring bean so SessionStoreConfig can inject it
146+
// when session-store=jdbc. Null for MongoDB deployments (no JDBC DataSource available).
147+
if (jdbcDataSource != null) {
148+
bf.registerSingleton("dataSource", jdbcDataSource);
149+
}
136150
});
137151

138152
// Refresh the Spring context inside a ServletContextListener so the ServletContext is set
@@ -157,6 +171,17 @@ public void contextDestroyed(ServletContextEvent sce) {
157171
holder.setInitOrder(1);
158172
context.addServlet(holder, "/*");
159173

174+
// Spring Session filter — must be registered before Spring Security so that the distributed
175+
// session store is in place before Security reads authentication state from the session.
176+
// The filter is always wired (even for session-store=none, where it uses an in-memory store
177+
// equivalent to the default Jetty session behaviour).
178+
var sessionFilter = new FilterHolder(new DelegatingFilterProxy("springSessionRepositoryFilter", appContext));
179+
sessionFilter.setName("springSessionRepositoryFilter");
180+
sessionFilter.setAsyncSupported(true);
181+
for (String path : new String[] {"/api/*", "/login", "/logout", "/", "/oauth2/*", "/login/oauth2/*"}) {
182+
context.addFilter(sessionFilter, path, EnumSet.of(DispatcherType.REQUEST, DispatcherType.ERROR));
183+
}
184+
160185
// Wire Spring Security filter chain into Jetty. Register only on the paths Spring Security
161186
// actually protects — never on /push/* or /proxy/* to avoid interfering with async git streaming.
162187
// /oauth2/* and /login/oauth2/* are needed for the OIDC authorization code flow; they are
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package org.finos.gitproxy.dashboard;
2+
3+
import jakarta.servlet.Filter;
4+
import java.time.Duration;
5+
import java.util.concurrent.ConcurrentHashMap;
6+
import javax.sql.DataSource;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.finos.gitproxy.jetty.config.GitProxyConfig;
9+
import org.finos.gitproxy.jetty.config.ServerConfig.RedisConfig;
10+
import org.springframework.beans.factory.annotation.Autowired;
11+
import org.springframework.context.annotation.Bean;
12+
import org.springframework.context.annotation.Configuration;
13+
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
14+
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
15+
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
16+
import org.springframework.data.redis.core.RedisTemplate;
17+
import org.springframework.data.redis.serializer.RedisSerializer;
18+
import org.springframework.jdbc.core.JdbcTemplate;
19+
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
20+
import org.springframework.session.MapSessionRepository;
21+
import org.springframework.session.SessionRepository;
22+
import org.springframework.session.data.redis.RedisIndexedSessionRepository;
23+
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
24+
import org.springframework.session.web.http.SessionRepositoryFilter;
25+
import org.springframework.transaction.support.TransactionTemplate;
26+
27+
/**
28+
* Wires the HTTP session store based on {@code server.session-store} in git-proxy.yml.
29+
*
30+
* <ul>
31+
* <li>{@code none} (default) — in-memory {@link MapSessionRepository}; sessions are lost on restart
32+
* <li>{@code jdbc} — {@link JdbcIndexedSessionRepository}; persisted to the configured JDBC database
33+
* <li>{@code redis} — {@link RedisIndexedSessionRepository}; persisted to Redis/Valkey
34+
* </ul>
35+
*
36+
* <p>The {@code springSessionRepositoryFilter} bean is always registered so that
37+
* {@link GitProxyWithDashboardApplication} can unconditionally wire it into the Jetty filter chain ahead of Spring
38+
* Security.
39+
*/
40+
@Slf4j
41+
@Configuration
42+
public class SessionStoreConfig {
43+
44+
@Autowired
45+
private GitProxyConfig gitProxyConfig;
46+
47+
/** Injected only for JDBC backends — null for MongoDB deployments. */
48+
@Autowired(required = false)
49+
private DataSource dataSource;
50+
51+
@Bean
52+
@SuppressWarnings("unchecked")
53+
public SessionRepository<?> sessionRepository() {
54+
String store = gitProxyConfig.getServer().getSessionStore();
55+
Duration timeout = Duration.ofSeconds(gitProxyConfig.getAuth().getSessionTimeoutSeconds());
56+
return switch (store) {
57+
case "jdbc" -> buildJdbc(timeout);
58+
case "redis" -> buildRedis(timeout);
59+
default -> {
60+
log.info("Session store: in-memory (server.session-store=none). Sessions will not survive restarts.");
61+
var repo = new MapSessionRepository(new ConcurrentHashMap<>());
62+
repo.setDefaultMaxInactiveInterval(timeout);
63+
yield repo;
64+
}
65+
};
66+
}
67+
68+
@Bean(name = "springSessionRepositoryFilter")
69+
@SuppressWarnings({"rawtypes", "unchecked"})
70+
public Filter springSessionRepositoryFilter(SessionRepository sessionRepository) {
71+
return new SessionRepositoryFilter<>(sessionRepository);
72+
}
73+
74+
// ── JDBC ─────────────────────────────────────────────────────────────────
75+
76+
private JdbcIndexedSessionRepository buildJdbc(Duration timeout) {
77+
if (dataSource == null) {
78+
throw new IllegalStateException(
79+
"server.session-store=jdbc requires a JDBC database (h2-file, h2-mem, or postgres)."
80+
+ " Current database.type is mongo — use session-store: none or provision a JDBC database.");
81+
}
82+
log.info("Session store: JDBC (server.session-store=jdbc)");
83+
var jdbcOps = new JdbcTemplate(dataSource);
84+
var txOps = new TransactionTemplate(new DataSourceTransactionManager(dataSource));
85+
var repo = new JdbcIndexedSessionRepository(jdbcOps, txOps);
86+
repo.setDefaultMaxInactiveInterval(timeout);
87+
return repo;
88+
}
89+
90+
// ── Redis ─────────────────────────────────────────────────────────────────
91+
92+
private RedisIndexedSessionRepository buildRedis(Duration timeout) {
93+
RedisConfig redisCfg = gitProxyConfig.getServer().getRedis();
94+
log.info(
95+
"Session store: Redis (server.session-store=redis, host={}:{})",
96+
redisCfg.getHost(),
97+
redisCfg.getPort());
98+
99+
var standaloneConfig = new RedisStandaloneConfiguration(redisCfg.getHost(), redisCfg.getPort());
100+
if (!redisCfg.getPassword().isBlank()) {
101+
standaloneConfig.setPassword(redisCfg.getPassword());
102+
}
103+
104+
LettuceClientConfiguration clientConfig = redisCfg.isSsl()
105+
? LettuceClientConfiguration.builder().useSsl().build()
106+
: LettuceClientConfiguration.defaultConfiguration();
107+
108+
var factory = new LettuceConnectionFactory(standaloneConfig, clientConfig);
109+
factory.afterPropertiesSet();
110+
111+
var template = new RedisTemplate<String, Object>();
112+
template.setConnectionFactory(factory);
113+
template.setKeySerializer(RedisSerializer.string());
114+
template.setHashKeySerializer(RedisSerializer.string());
115+
template.afterPropertiesSet();
116+
117+
var repo = new RedisIndexedSessionRepository(template);
118+
repo.setDefaultMaxInactiveInterval(timeout);
119+
return repo;
120+
}
121+
}

git-proxy-java-server/src/main/java/org/finos/gitproxy/jetty/config/JettyConfigurationBuilder.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,16 @@ private MongoStoreFactory requireMongoStoreFactory() {
665665
return cachedMongoStoreFactory;
666666
}
667667

668+
/**
669+
* Returns the JDBC {@link DataSource} for JDBC-backed database types ({@code h2-mem}, {@code h2-file},
670+
* {@code postgres}). Returns {@code null} for {@code mongo} — callers that need a DataSource for features like
671+
* session persistence should check for null and either skip or fail gracefully.
672+
*/
673+
public DataSource getJdbcDataSourceOrNull() {
674+
if ("mongo".equals(config.getDatabase().getType())) return null;
675+
return requireJdbcDataSource();
676+
}
677+
668678
private DataSource requireJdbcDataSource() {
669679
if (cachedDataSource == null) {
670680
DatabaseConfig db = config.getDatabase();

git-proxy-java-server/src/main/java/org/finos/gitproxy/jetty/config/ServerConfig.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,31 @@ public class ServerConfig {
6868

6969
/** TLS configuration for the server listener and upstream trust. Omit entirely to use plain HTTP. */
7070
private TlsConfig tls = new TlsConfig();
71+
72+
/**
73+
* HTTP session persistence backend. Values:
74+
*
75+
* <ul>
76+
* <li>{@code none} (default) — in-memory sessions; sessions are lost on restart and not shared across instances
77+
* <li>{@code jdbc} — persisted to the configured JDBC database ({@code h2-file} or {@code postgres}); zero new
78+
* infrastructure required, uses the same DataSource as the push store
79+
* <li>{@code redis} — persisted to a Redis or Valkey instance; configure connection via {@code server.redis.*}
80+
* </ul>
81+
*
82+
* <p>Use {@code jdbc} or {@code redis} when running multiple instances sharing a database so that authenticated
83+
* sessions survive pod restarts and are visible across all instances.
84+
*/
85+
private String sessionStore = "none";
86+
87+
/** Redis connection settings — used when {@code server.session-store: redis}. */
88+
private RedisConfig redis = new RedisConfig();
89+
90+
/** Binds the {@code server.redis:} block. */
91+
@Data
92+
public static class RedisConfig {
93+
private String host = "localhost";
94+
private int port = 6379;
95+
private String password = "";
96+
private boolean ssl = false;
97+
}
7198
}

git-proxy-java-server/src/main/resources/git-proxy.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@ server:
1010
# Sends a "." progress packet periodically to prevent idle-timeout disconnects during long validation steps
1111
# (e.g. secret scanning, approval polling). Set to 0 to disable.
1212
heartbeat-interval-seconds: 10
13+
# HTTP session persistence backend. Options:
14+
# none — in-memory sessions (default); sessions are lost on restart and not shared across instances
15+
# jdbc — persisted to the configured JDBC database (h2-file or postgres); no new infrastructure needed
16+
# redis — persisted to a Redis or Valkey instance; configure connection via server.redis.*
17+
# Use jdbc or redis when running multiple instances sharing a database so that authenticated sessions
18+
# survive pod restarts and remain valid across all pods.
19+
# session-store: none
20+
21+
# Redis connection — required when session-store: redis
22+
# redis:
23+
# host: redis.cluster.local
24+
# port: 6379
25+
# password: ""
26+
# ssl: false
27+
1328
# Origins allowed to make cross-origin requests to the dashboard REST API.
1429
# Required when the frontend is served from a different hostname than the backend (e.g. behind a load balancer).
1530
# Default (empty): same-origin only. Override per-deployment in git-proxy-local.yml or via

0 commit comments

Comments
 (0)