From 54e780e9f967ddccd6dba4eafa4d3eef98f05192 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 6 Nov 2026 18:06:30 -0600 Subject: [PATCH 1/2] feat: hash session ids into storage keys and publish the PostgreSQL schema (KOF22 session-store integration) Brings the KOF22-validated session-store behaviour onto develop: - SessionCodec derives the storage key from a SHA-256 of a domain-separated session id, so a raw session id is never stored or logged. - StoredSessionSchema publishes the PostgreSQL DDL for the stored-session table. - The in-memory, Redis, shared-Redis and table-based providers and the stored-session table metadata use them. - Tests: SessionCodecTest, a PostgreSQL integration test for the table provider, and the fixture endpoint; the PostgreSQL test needs qqq-backend-module-postgres (test scope) and a UTC JVM. Source and test changes only; the POM keeps develop's Core RC.22 and qbit-redis 0.1.0-RC.1 pins. Co-Authored-By: Claude Sonnet 5.5 --- pom.xml | 7 + .../qbits/sessionstore/SessionCodec.java | 151 ++++++ .../sessionstore/StoredSessionSchema.java | 175 +++++++ .../StoredSessionTableMetaDataProducer.java | 16 +- .../InMemorySessionStoreProvider.java | 37 +- .../providers/RedisSessionStoreProvider.java | 28 +- .../SharedRedisSessionStoreProvider.java | 17 +- .../TableBasedSessionStoreProvider.java | 345 ++++++------ .../qbits/sessionstore/SessionCodecTest.java | 110 ++++ .../providers/FixtureEndpoint.java | 238 +++++++++ .../providers/FixtureEndpointTest.java | 92 ++++ .../SharedRedisSessionStoreProviderIT.java | 119 ++++- .../TableBasedSessionStorePostgresIT.java | 491 ++++++++++++++++++ .../TableBasedSessionStoreProviderIT.java | 2 +- 14 files changed, 1637 insertions(+), 191 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/sessionstore/SessionCodec.java create mode 100644 src/main/java/com/kingsrook/qbits/sessionstore/StoredSessionSchema.java create mode 100644 src/test/java/com/kingsrook/qbits/sessionstore/SessionCodecTest.java create mode 100644 src/test/java/com/kingsrook/qbits/sessionstore/providers/FixtureEndpoint.java create mode 100644 src/test/java/com/kingsrook/qbits/sessionstore/providers/FixtureEndpointTest.java create mode 100644 src/test/java/com/kingsrook/qbits/sessionstore/providers/TableBasedSessionStorePostgresIT.java diff --git a/pom.xml b/pom.xml index 2fd6769..dd2059b 100644 --- a/pom.xml +++ b/pom.xml @@ -125,6 +125,11 @@ qqq-backend-module-rdbms test + + com.kingsrook.qqq + qqq-backend-module-postgres + test + com.h2database @@ -180,6 +185,8 @@ **/*IntegrationTest.java **/*IT.java + + -Duser.timezone=UTC diff --git a/src/main/java/com/kingsrook/qbits/sessionstore/SessionCodec.java b/src/main/java/com/kingsrook/qbits/sessionstore/SessionCodec.java new file mode 100644 index 0000000..685368b --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sessionstore/SessionCodec.java @@ -0,0 +1,151 @@ +/* + * Copyright 2024 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.kingsrook.qbits.sessionstore; + + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import org.json.JSONObject; + + +/******************************************************************************* + ** Shared at-rest rules for every persistent provider. + ** + ** A session id is the credential that resumes the session (the cookie value of a + ** native login), so it is never written to a table, Redis key, log line or the + ** stored JSON. Providers address a session by {@link #key(String)} (a one-way + ** digest) and store {@link #encode(String, QSession)}, an envelope whose session + ** has its own uuid and id reference removed; {@link #decode(String, String)} + ** restores them from the id the caller presented. A reader that does not hold + ** the id therefore cannot resume anything from a copy of the store, and an + ** envelope read under another id is refused. + *******************************************************************************/ +public final class SessionCodec +{ + private static final String KEY_DOMAIN = "qbit-session-store:v1:"; + private static final int VERSION = 1; + private static final int LOG_LENGTH = 12; + + + + /*************************************************************************** + ** Utility class. + ***************************************************************************/ + private SessionCodec() + { + } + + + + /*************************************************************************** + ** The storage key for a session id: lower-case hex SHA-256 of a domain + ** separated id. + ***************************************************************************/ + public static String key(String sessionId) + { + Objects.requireNonNull(sessionId, "Session id is required"); + if(sessionId.isBlank()) + { + throw new IllegalArgumentException("Session id is required"); + } + try + { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((KEY_DOMAIN + sessionId).getBytes(StandardCharsets.UTF_8))); + } + catch(NoSuchAlgorithmException e) + { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + + + + /*************************************************************************** + ** A short non-reversible tag for log lines. + ***************************************************************************/ + public static String logTag(String sessionId) + { + return sessionId == null || sessionId.isBlank() ? "none" : key(sessionId).substring(0, LOG_LENGTH); + } + + + + /*************************************************************************** + ** Serialize a session for storage under the given id, without the id. + ***************************************************************************/ + public static String encode(String sessionId, QSession session) + { + Objects.requireNonNull(session, "Session is required"); + JSONObject stored = new JSONObject(JsonUtils.toJson(session)); + boolean uuidStripped = sessionId.equals(stored.optString("uuid", null)); + boolean refStripped = sessionId.equals(stored.optString("idReference", null)); + if(uuidStripped) + { + stored.remove("uuid"); + } + if(refStripped) + { + stored.remove("idReference"); + } + return new JSONObject() + .put("v", VERSION) + .put("keyDigest", key(sessionId)) + .put("uuidIsId", uuidStripped) + .put("idReferenceIsId", refStripped) + .put("session", stored) + .toString(); + } + + + + /*************************************************************************** + ** Restore a stored envelope for the id the caller presented; anything that + ** is not an envelope for exactly this id is refused. + ***************************************************************************/ + public static QSession decode(String sessionId, String json) throws IOException + { + JSONObject envelope; + try + { + envelope = new JSONObject(json); + } + catch(RuntimeException e) + { + throw new IOException("Stored session is not readable", e); + } + if(envelope.optInt("v", -1) != VERSION || !envelope.has("session") || !key(sessionId).equals(envelope.optString("keyDigest", null))) + { + throw new IOException("Stored session does not belong to the presented id"); + } + JSONObject stored = envelope.getJSONObject("session"); + if(envelope.optBoolean("uuidIsId")) + { + stored.put("uuid", sessionId); + } + if(envelope.optBoolean("idReferenceIsId")) + { + stored.put("idReference", sessionId); + } + return JsonUtils.toObject(stored.toString(), QSession.class); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/sessionstore/StoredSessionSchema.java b/src/main/java/com/kingsrook/qbits/sessionstore/StoredSessionSchema.java new file mode 100644 index 0000000..14fdea2 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/sessionstore/StoredSessionSchema.java @@ -0,0 +1,175 @@ +/* + * Copyright 2024 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.kingsrook.qbits.sessionstore; + + +import java.util.ArrayList; +import java.util.List; +import java.util.function.UnaryOperator; +import com.kingsrook.qbits.sessionstore.metadata.StoredSessionTableMetaDataProducer; +import com.kingsrook.qbits.sessionstore.model.StoredSession; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; + + +/******************************************************************************* + ** The PostgreSQL schema of the TABLE_BASED provider, derived from the very table + ** metadata the producer installs, so the two cannot drift apart. A migration + ** identity (the schema owner) applies it once; the provider itself only ever does + ** DML and needs no DDL, history or ownership rights. + ** + ** QQQ quotes every identifier, so by default the physical names are exactly the + ** metadata names (quoted). An application that maps the table onto another naming + ** rule (for example snake_case through RDBMSTableBackendDetails and field backend + ** names) passes that rule as the naming function, and gets the matching DDL. + *******************************************************************************/ +public final class StoredSessionSchema +{ + private static final UnaryOperator EXACT = UnaryOperator.identity(); + + + + /*************************************************************************** + ** Utility class. + ***************************************************************************/ + private StoredSessionSchema() + { + } + + + + /*************************************************************************** + ** The physical table name (exact metadata name). + ***************************************************************************/ + public static String physicalTableName(QSessionStoreQBitConfig config) + { + return physicalTableName(config, EXACT); + } + + + + /*************************************************************************** + ** The physical table name under a naming rule. + ***************************************************************************/ + public static String physicalTableName(QSessionStoreQBitConfig config, UnaryOperator naming) + { + return naming.apply(config.applyPrefix(config.getTableName())); + } + + + + /*************************************************************************** + ** The physical column names (exact metadata names). + ***************************************************************************/ + public static List physicalColumnNames(QSessionStoreQBitConfig config) + { + return physicalColumnNames(config, EXACT); + } + + + + /*************************************************************************** + ** The physical column names under a naming rule. + ***************************************************************************/ + public static List physicalColumnNames(QSessionStoreQBitConfig config, UnaryOperator naming) + { + List names = new ArrayList<>(); + for(QFieldMetaData field : table(config).getFields().values()) + { + names.add(naming.apply(field.getName())); + } + return names; + } + + + + /*************************************************************************** + ** Idempotent DDL with exact names. + ***************************************************************************/ + public static String postgresDdl(QSessionStoreQBitConfig config) + { + return postgresDdl(config, EXACT); + } + + + + /*************************************************************************** + ** Idempotent DDL under a naming rule: the table, its unique key on the + ** session-key digest and an index serving expiry cleanup. Identifiers are + ** always quoted, as QQQ quotes them in its own statements. + ***************************************************************************/ + public static String postgresDdl(QSessionStoreQBitConfig config, UnaryOperator naming) + { + QTableMetaData table = table(config); + String name = physicalTableName(config, naming); + String key = naming.apply(StoredSession.FIELD_SESSION_UUID); + String exp = naming.apply(StoredSession.FIELD_EXPIRES_AT); + List lines = new ArrayList<>(); + for(QFieldMetaData field : table.getFields().values()) + { + lines.add(" " + quote(naming.apply(field.getName())) + " " + type(table, field)); + } + lines.add(" CONSTRAINT " + quote("uq_" + name + "_" + key) + " UNIQUE (" + quote(key) + ")"); + + return "CREATE TABLE IF NOT EXISTS " + quote(name) + " (\n" + String.join(",\n", lines) + "\n);\n" + + "CREATE INDEX IF NOT EXISTS " + quote("ix_" + name + "_" + exp) + " ON " + quote(name) + " (" + quote(exp) + ");\n"; + } + + + + /*************************************************************************** + ** Quote an identifier. + ***************************************************************************/ + public static String quote(String identifier) + { + return "\"" + identifier.replace("\"", "\"\"") + "\""; + } + + + + /*************************************************************************** + ** The producer's table, built in a scratch instance. + ***************************************************************************/ + private static QTableMetaData table(QSessionStoreQBitConfig config) + { + QInstance scratch = new QInstance(); + StoredSessionTableMetaDataProducer.produce(scratch, config); + return scratch.getTable(config.applyPrefix(config.getTableName())); + } + + + + /*************************************************************************** + ** Column type and constraints of one field. + ***************************************************************************/ + private static String type(QTableMetaData table, QFieldMetaData field) + { + if(field.getName().equals(table.getPrimaryKeyField())) + { + return "INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY"; + } + String base = switch(field.getType()) + { + case STRING, PASSWORD -> field.getMaxLength() == null ? "TEXT" : "VARCHAR(" + field.getMaxLength() + ")"; + case TEXT, HTML -> "TEXT"; + case DATE_TIME -> "TIMESTAMP WITH TIME ZONE"; + default -> throw new IllegalStateException("Unsupported stored-session field type " + field.getType()); + }; + return base + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : ""); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/sessionstore/metadata/StoredSessionTableMetaDataProducer.java b/src/main/java/com/kingsrook/qbits/sessionstore/metadata/StoredSessionTableMetaDataProducer.java index ea8908a..9b75eec 100644 --- a/src/main/java/com/kingsrook/qbits/sessionstore/metadata/StoredSessionTableMetaDataProducer.java +++ b/src/main/java/com/kingsrook/qbits/sessionstore/metadata/StoredSessionTableMetaDataProducer.java @@ -22,12 +22,19 @@ import com.kingsrook.qbits.sessionstore.model.StoredSession; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel; +import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules; import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; /******************************************************************************* ** Metadata producer for the StoredSession table. + ** + ** The table is internal: hidden, and protected by its own permission set that no + ** role is given (the provider reads and writes it as the system user). Its rows + ** are session-key digests and id-free envelopes, never session ids. *******************************************************************************/ public class StoredSessionTableMetaDataProducer { @@ -42,6 +49,11 @@ public class StoredSessionTableMetaDataProducer QTableMetaData table = new QTableMetaData() .withName(tableName) .withLabel("Stored Session") + .withIsHidden(true) + .withPermissionRules(new QPermissionRules() + .withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS) + .withPermissionBaseName(tableName) + .withDenyBehavior(DenyBehavior.HIDDEN)) .withBackendName(config.getBackendName()) .withPrimaryKeyField(StoredSession.FIELD_ID) .withRecordLabelFormat("%s") @@ -49,10 +61,10 @@ public class StoredSessionTableMetaDataProducer .withUniqueKey(new UniqueKey(StoredSession.FIELD_SESSION_UUID)) .withField(new QFieldMetaData(StoredSession.FIELD_ID, QFieldType.INTEGER).withIsEditable(false)) .withField(new QFieldMetaData(StoredSession.FIELD_SESSION_UUID, QFieldType.STRING) - .withLabel("Session UUID") + .withLabel("Session Key (digest)") .withIsRequired(true) .withIsEditable(false) - .withMaxLength(36)) + .withMaxLength(64)) .withField(new QFieldMetaData(StoredSession.FIELD_USER_ID, QFieldType.STRING) .withLabel("User ID") .withIsEditable(false)) diff --git a/src/main/java/com/kingsrook/qbits/sessionstore/providers/InMemorySessionStoreProvider.java b/src/main/java/com/kingsrook/qbits/sessionstore/providers/InMemorySessionStoreProvider.java index d7efd0a..87cc700 100644 --- a/src/main/java/com/kingsrook/qbits/sessionstore/providers/InMemorySessionStoreProvider.java +++ b/src/main/java/com/kingsrook/qbits/sessionstore/providers/InMemorySessionStoreProvider.java @@ -25,6 +25,7 @@ import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface; import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig; +import com.kingsrook.qbits.sessionstore.SessionCodec; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.session.QSession; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; @@ -57,6 +58,24 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf + /*************************************************************************** + ** A request that mutates its session must not change what another request + ** will load, so the store holds and returns copies. + ***************************************************************************/ + private static QSession copy(QSession session) + { + try + { + return session.clone(); + } + catch(CloneNotSupportedException e) + { + throw new IllegalStateException("Session cannot be copied", e); + } + } + + + /*************************************************************************** ** Configure the provider. ***************************************************************************/ @@ -99,8 +118,8 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf { Duration effectiveTtl = ttl != null ? ttl : defaultTtl; Instant expiresAt = Instant.now().plus(effectiveTtl); - cache.put(sessionUuid, new CachedSession(session, expiresAt)); - LOG.debug("Stored session", logPair("sessionUuid", sessionUuid), logPair("expiresAt", expiresAt)); + cache.put(sessionUuid, new CachedSession(copy(session), expiresAt)); + LOG.debug("Stored session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("expiresAt", expiresAt)); } @@ -120,11 +139,11 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf if(cached.isExpired()) { cache.remove(sessionUuid); - LOG.debug("Session expired", logPair("sessionUuid", sessionUuid)); + LOG.debug("Session expired", logPair("session", SessionCodec.logTag(sessionUuid))); return Optional.empty(); } - return Optional.of(cached.session()); + return Optional.of(copy(cached.session())); } @@ -136,7 +155,7 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf public void remove(String sessionUuid) { cache.remove(sessionUuid); - LOG.debug("Removed session", logPair("sessionUuid", sessionUuid)); + LOG.debug("Removed session", logPair("session", SessionCodec.logTag(sessionUuid))); } @@ -152,7 +171,7 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf { Instant newExpiresAt = Instant.now().plus(defaultTtl); cache.put(sessionUuid, new CachedSession(cached.session(), newExpiresAt)); - LOG.debug("Touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt)); + LOG.debug("Touched session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newExpiresAt", newExpiresAt)); } } @@ -184,7 +203,7 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf if(cached.isExpired()) { cache.remove(sessionUuid); - LOG.debug("Session expired", logPair("sessionUuid", sessionUuid)); + LOG.debug("Session expired", logPair("session", SessionCodec.logTag(sessionUuid))); return Optional.empty(); } @@ -193,9 +212,9 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf ////////////////////////////////////////////////////////////////////// Instant newExpiresAt = Instant.now().plus(defaultTtl); cache.put(sessionUuid, new CachedSession(cached.session(), newExpiresAt)); - LOG.debug("Loaded and touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt)); + LOG.debug("Loaded and touched session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newExpiresAt", newExpiresAt)); - return Optional.of(cached.session()); + return Optional.of(copy(cached.session())); } diff --git a/src/main/java/com/kingsrook/qbits/sessionstore/providers/RedisSessionStoreProvider.java b/src/main/java/com/kingsrook/qbits/sessionstore/providers/RedisSessionStoreProvider.java index 8689dab..71808fa 100644 --- a/src/main/java/com/kingsrook/qbits/sessionstore/providers/RedisSessionStoreProvider.java +++ b/src/main/java/com/kingsrook/qbits/sessionstore/providers/RedisSessionStoreProvider.java @@ -20,9 +20,9 @@ import java.time.Duration; import java.util.Optional; import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface; import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig; +import com.kingsrook.qbits.sessionstore.SessionCodec; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.session.QSession; -import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; @@ -82,7 +82,7 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface ***************************************************************************/ private String buildKey(String sessionUuid) { - return keyPrefix + sessionUuid; + return keyPrefix + SessionCodec.key(sessionUuid); } @@ -97,14 +97,14 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface { Duration effectiveTtl = ttl != null ? ttl : defaultTtl; String key = buildKey(sessionUuid); - String sessionJson = JsonUtils.toJson(session); + String sessionJson = SessionCodec.encode(sessionUuid, session); jedis.setex(key, effectiveTtl.toSeconds(), sessionJson); - LOG.debug("Stored session in Redis", logPair("sessionUuid", sessionUuid), logPair("ttlSeconds", effectiveTtl.toSeconds())); + LOG.debug("Stored session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("ttlSeconds", effectiveTtl.toSeconds())); } catch(Exception e) { - LOG.warn("Failed to store session in Redis", logPair("sessionUuid", sessionUuid), e); + LOG.warn("Failed to store session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e); } } @@ -126,12 +126,12 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface return Optional.empty(); } - QSession session = JsonUtils.toObject(sessionJson, QSession.class); + QSession session = SessionCodec.decode(sessionUuid, sessionJson); return Optional.of(session); } catch(Exception e) { - LOG.warn("Failed to load session from Redis", logPair("sessionUuid", sessionUuid), e); + LOG.warn("Failed to load session from Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e); return Optional.empty(); } } @@ -148,11 +148,11 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface { String key = buildKey(sessionUuid); jedis.del(key); - LOG.debug("Removed session from Redis", logPair("sessionUuid", sessionUuid)); + LOG.debug("Removed session from Redis", logPair("session", SessionCodec.logTag(sessionUuid))); } catch(Exception e) { - LOG.warn("Failed to remove session from Redis", logPair("sessionUuid", sessionUuid), e); + LOG.warn("Failed to remove session from Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e); } } @@ -168,11 +168,11 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface { String key = buildKey(sessionUuid); jedis.expire(key, defaultTtl.toSeconds()); - LOG.debug("Touched session in Redis", logPair("sessionUuid", sessionUuid), logPair("newTtlSeconds", defaultTtl.toSeconds())); + LOG.debug("Touched session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newTtlSeconds", defaultTtl.toSeconds())); } catch(Exception e) { - LOG.warn("Failed to touch session in Redis", logPair("sessionUuid", sessionUuid), e); + LOG.warn("Failed to touch session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e); } } @@ -229,13 +229,13 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface return Optional.empty(); } - QSession session = JsonUtils.toObject(sessionJson, QSession.class); - LOG.debug("Loaded and touched session in Redis", logPair("sessionUuid", sessionUuid), logPair("newTtlSeconds", defaultTtl.toSeconds())); + QSession session = SessionCodec.decode(sessionUuid, sessionJson); + LOG.debug("Loaded and touched session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newTtlSeconds", defaultTtl.toSeconds())); return Optional.of(session); } catch(Exception e) { - LOG.warn("Failed to load and touch session in Redis", logPair("sessionUuid", sessionUuid), e); + LOG.warn("Failed to load and touch session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e); return Optional.empty(); } } diff --git a/src/main/java/com/kingsrook/qbits/sessionstore/providers/SharedRedisSessionStoreProvider.java b/src/main/java/com/kingsrook/qbits/sessionstore/providers/SharedRedisSessionStoreProvider.java index a841c49..9b25227 100644 --- a/src/main/java/com/kingsrook/qbits/sessionstore/providers/SharedRedisSessionStoreProvider.java +++ b/src/main/java/com/kingsrook/qbits/sessionstore/providers/SharedRedisSessionStoreProvider.java @@ -27,10 +27,10 @@ import com.kingsrook.qbits.redis.api.RedisCache; import com.kingsrook.qbits.redis.api.RedisNamespace; import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface; import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig; +import com.kingsrook.qbits.sessionstore.SessionCodec; import com.kingsrook.qbits.sessionstore.SharedRedisSessionStoreSettings; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; import com.kingsrook.qqq.backend.core.model.session.QSession; -import com.kingsrook.qqq.backend.core.utils.JsonUtils; /******************************************************************************* @@ -115,7 +115,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi @Override public void store(String id, QSession session, Duration ttl) { - cache().put(id, JsonUtils.toJson(Objects.requireNonNull(session)).getBytes(StandardCharsets.UTF_8), validTtl(ttl == null ? defaultTtl : ttl)); + cache().put(SessionCodec.key(id), SessionCodec.encode(id, Objects.requireNonNull(session)).getBytes(StandardCharsets.UTF_8), validTtl(ttl == null ? defaultTtl : ttl)); } @@ -126,7 +126,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi @Override public Optional load(String id) { - return decode(cache().get(id)); + return decode(id, cache().get(SessionCodec.key(id))); } @@ -137,7 +137,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi @Override public void remove(String id) { - cache().remove(id); + cache().remove(SessionCodec.key(id)); } @@ -148,7 +148,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi @Override public void touch(String id) { - cache().touch(id, defaultTtl); + cache().touch(SessionCodec.key(id), defaultTtl); } @@ -159,7 +159,8 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi @Override public Optional loadAndTouch(String id) { - return decode(sliding ? cache().getAndTouch(id, defaultTtl) : cache().get(id)); + String key = SessionCodec.key(id); + return decode(id, sliding ? cache().getAndTouch(key, defaultTtl) : cache().get(key)); } @@ -167,7 +168,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi /*************************************************************************** ** Bound decoded payloads and never include session contents in error messages. ***************************************************************************/ - private Optional decode(Optional value) + private Optional decode(String id, Optional value) { return value.map(bytes -> { @@ -177,7 +178,7 @@ public final class SharedRedisSessionStoreProvider implements QSessionStoreProvi } try { - return Objects.requireNonNull(JsonUtils.toObject(new String(bytes, StandardCharsets.UTF_8), QSession.class)); + return Objects.requireNonNull(SessionCodec.decode(id, new String(bytes, StandardCharsets.UTF_8))); } catch(IOException | RuntimeException e) { diff --git a/src/main/java/com/kingsrook/qbits/sessionstore/providers/TableBasedSessionStoreProvider.java b/src/main/java/com/kingsrook/qbits/sessionstore/providers/TableBasedSessionStoreProvider.java index d7b1a22..8940613 100644 --- a/src/main/java/com/kingsrook/qbits/sessionstore/providers/TableBasedSessionStoreProvider.java +++ b/src/main/java/com/kingsrook/qbits/sessionstore/providers/TableBasedSessionStoreProvider.java @@ -16,23 +16,26 @@ package com.kingsrook.qbits.sessionstore.providers; -import java.io.Serializable; +import java.io.IOException; import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface; import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig; +import com.kingsrook.qbits.sessionstore.SessionCodec; import com.kingsrook.qbits.sessionstore.model.StoredSession; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; -import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; @@ -40,19 +43,22 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; -import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.session.QSession; import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession; -import com.kingsrook.qqq.backend.core.utils.JsonUtils; import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair; /******************************************************************************* ** Table-based session store provider using QQQ table actions. ** Best for: multi-instance deployments, persistence across restarts. + ** + ** Rows are addressed by {@link SessionCodec#key(String)} and hold an envelope that + ** does not contain the session id, so the table never holds a credential that + ** resumes a session. Storing a session that is already present replaces its data: + ** a stale copy (old permissions, old tenant keys) is never left in place, and when + ** a store cannot complete the previous copy is removed. *******************************************************************************/ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInterface { @@ -61,6 +67,7 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte private QSessionStoreQBitConfig config; private String tableName; private Duration defaultTtl; + private boolean sliding; @@ -73,85 +80,196 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte this.config = config; this.tableName = config.applyPrefix(config.getTableName()); this.defaultTtl = config.getDefaultTtl(); + this.sliding = !Boolean.FALSE.equals(config.getEnableSlidingExpiration()); LOG.info("TableBased session store configured", logPair("tableName", tableName)); } /*************************************************************************** - ** Store a session with the given TTL. + ** Store a session with the given TTL, replacing any existing copy. ***************************************************************************/ @Override public void store(String sessionUuid, QSession session, Duration ttl) { + String key = SessionCodec.key(sessionUuid); try { Duration effectiveTtl = ttl != null ? ttl : defaultTtl; - Instant expiresAt = Instant.now().plus(effectiveTtl); - String sessionJson = JsonUtils.toJson(session); - - QRecord record = new QRecord() - .withValue(StoredSession.FIELD_SESSION_UUID, sessionUuid) - .withValue(StoredSession.FIELD_USER_ID, session.getUser() != null ? session.getUser().getIdReference() : null) - .withValue(StoredSession.FIELD_SESSION_DATA, sessionJson) - .withValue(StoredSession.FIELD_EXPIRES_AT, expiresAt); + Instant expiresAt = Instant.now().plus(effectiveTtl); + String stored = SessionCodec.encode(sessionUuid, session); + String userId = session.getUser() != null ? session.getUser().getIdReference() : null; runWithSystemSession(() -> { - InsertInput insertInput = new InsertInput(); - insertInput.setTableName(tableName); - insertInput.setRecords(List.of(record)); - new InsertAction().execute(insertInput); + try + { + upsert(key, userId, stored, expiresAt); + } + catch(QException e) + { + /////////////////////////////////////////////////////////////////////// + // two instances storing the same new session race on the unique key: // + // the loser retries once, as an update // + /////////////////////////////////////////////////////////////////////// + upsert(key, userId, stored, expiresAt); + } return null; }); - LOG.debug("Stored session", logPair("sessionUuid", sessionUuid), logPair("expiresAt", expiresAt)); + LOG.debug("Stored session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("expiresAt", expiresAt)); } catch(Exception e) { - LOG.warn("Failed to store session", logPair("sessionUuid", sessionUuid), e); + LOG.warn("Failed to store session", logPair("session", SessionCodec.logTag(sessionUuid)), e); + remove(sessionUuid); } } /*************************************************************************** - ** Load a session by UUID. + ** Insert or update the one row for a key. + ***************************************************************************/ + private void upsert(String key, String userId, String stored, Instant expiresAt) throws QException + { + QRecord existing = get(key); + if(existing == null) + { + InsertInput insertInput = new InsertInput(); + insertInput.setTableName(tableName); + insertInput.setRecords(List.of(new QRecord() + .withValue(StoredSession.FIELD_SESSION_UUID, key) + .withValue(StoredSession.FIELD_USER_ID, userId) + .withValue(StoredSession.FIELD_SESSION_DATA, stored) + .withValue(StoredSession.FIELD_EXPIRES_AT, expiresAt))); + requireNoErrors(new InsertAction().execute(insertInput).getRecords()); + } + else + { + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(tableName); + updateInput.setRecords(List.of(new QRecord() + .withValue("id", existing.getValue("id")) + .withValue(StoredSession.FIELD_USER_ID, userId) + .withValue(StoredSession.FIELD_SESSION_DATA, stored) + .withValue(StoredSession.FIELD_EXPIRES_AT, expiresAt))); + requireNoErrors(new UpdateAction().execute(updateInput).getRecords()); + } + } + + + + /*************************************************************************** + ** A record-level error (unique key, constraint) is a failed store. + ***************************************************************************/ + private static void requireNoErrors(List records) throws QException + { + for(QRecord record : records) + { + if(record.getErrors() != null && !record.getErrors().isEmpty()) + { + throw new QException("Session store write was rejected"); + } + } + } + + + + /*************************************************************************** + ** The stored row for a storage key, or null. + ***************************************************************************/ + private QRecord get(String key) throws QException + { + GetInput getInput = new GetInput(); + getInput.setTableName(tableName); + getInput.setUniqueKey(Map.of(StoredSession.FIELD_SESSION_UUID, key)); + GetOutput getOutput = new GetAction().execute(getInput); + return getOutput.getRecord(); + } + + + + /*************************************************************************** + ** Load a session by id. ***************************************************************************/ @Override public Optional load(String sessionUuid) { + return read(sessionUuid, false); + } + + + + /*************************************************************************** + ** Load a session and, when sliding expiration is on, renew it. + ***************************************************************************/ + @Override + public Optional loadAndTouch(String sessionUuid) + { + return read(sessionUuid, sliding); + } + + + + /*************************************************************************** + ** Shared read: expired, unreadable and mismatched rows are removed and + ** reported as a miss. + ***************************************************************************/ + private Optional read(String sessionUuid, boolean touch) + { + String key = SessionCodec.key(sessionUuid); try { - QRecord record = runWithSystemSession(() -> + return runWithSystemSession(() -> { - GetInput getInput = new GetInput(); - getInput.setTableName(tableName); - getInput.setUniqueKey(Map.of(StoredSession.FIELD_SESSION_UUID, sessionUuid)); - GetOutput getOutput = new GetAction().execute(getInput); - return getOutput.getRecord(); + QRecord record = get(key); + if(record == null) + { + return Optional.empty(); + } + + Instant expiresAt = record.getValueInstant(StoredSession.FIELD_EXPIRES_AT); + if(expiresAt != null && Instant.now().isAfter(expiresAt)) + { + deleteByKey(key); + LOG.debug("Session expired", logPair("session", SessionCodec.logTag(sessionUuid))); + return Optional.empty(); + } + + QSession session; + try + { + session = SessionCodec.decode(sessionUuid, record.getValueString(StoredSession.FIELD_SESSION_DATA)); + String storedUser = record.getValueString(StoredSession.FIELD_USER_ID); + String sessionUser = session.getUser() != null ? session.getUser().getIdReference() : null; + if(!Objects.equals(storedUser, sessionUser)) + { + throw new IOException("Stored session identity does not match its row"); + } + } + catch(IOException | RuntimeException e) + { + deleteByKey(key); + LOG.warn("Discarded an unreadable stored session", logPair("session", SessionCodec.logTag(sessionUuid))); + return Optional.empty(); + } + + if(touch) + { + UpdateInput updateInput = new UpdateInput(); + updateInput.setTableName(tableName); + updateInput.setRecords(List.of(new QRecord() + .withValue("id", record.getValue("id")) + .withValue(StoredSession.FIELD_EXPIRES_AT, Instant.now().plus(defaultTtl)))); + new UpdateAction().execute(updateInput); + } + return Optional.of(session); }); - - if(record == null) - { - return Optional.empty(); - } - - Instant expiresAt = record.getValueInstant(StoredSession.FIELD_EXPIRES_AT); - if(expiresAt != null && Instant.now().isAfter(expiresAt)) - { - remove(sessionUuid); - LOG.debug("Session expired", logPair("sessionUuid", sessionUuid)); - return Optional.empty(); - } - - String sessionJson = record.getValueString(StoredSession.FIELD_SESSION_DATA); - QSession session = JsonUtils.toObject(sessionJson, QSession.class); - return Optional.of(session); } catch(Exception e) { - LOG.warn("Failed to load session", logPair("sessionUuid", sessionUuid), e); + LOG.warn("Failed to load session", logPair("session", SessionCodec.logTag(sessionUuid)), e); return Optional.empty(); } } @@ -159,7 +277,7 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte /*************************************************************************** - ** Remove a session by UUID. + ** Remove a session by id. ***************************************************************************/ @Override public void remove(String sessionUuid) @@ -168,26 +286,35 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte { runWithSystemSession(() -> { - DeleteInput deleteInput = new DeleteInput(); - deleteInput.setTableName(tableName); - deleteInput.setQueryFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria(StoredSession.FIELD_SESSION_UUID, QCriteriaOperator.EQUALS, sessionUuid))); - new DeleteAction().execute(deleteInput); + deleteByKey(SessionCodec.key(sessionUuid)); return null; }); - - LOG.debug("Removed session", logPair("sessionUuid", sessionUuid)); + LOG.debug("Removed session", logPair("session", SessionCodec.logTag(sessionUuid))); } catch(Exception e) { - LOG.warn("Failed to remove session", logPair("sessionUuid", sessionUuid), e); + LOG.warn("Failed to remove session", logPair("session", SessionCodec.logTag(sessionUuid)), e); } } /*************************************************************************** - ** Touch a session to reset its TTL. + ** Delete the row for a storage key. + ***************************************************************************/ + private void deleteByKey(String key) throws QException + { + DeleteInput deleteInput = new DeleteInput(); + deleteInput.setTableName(tableName); + deleteInput.setQueryFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria(StoredSession.FIELD_SESSION_UUID, QCriteriaOperator.EQUALS, key))); + new DeleteAction().execute(deleteInput); + } + + + + /*************************************************************************** + ** Touch a session to reset its TTL; never recreates a missing session. ***************************************************************************/ @Override public void touch(String sessionUuid) @@ -198,33 +325,24 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte runWithSystemSession(() -> { - ///////////////////////////////////////////////////////////////// - // First get the record to find its primary key // - ///////////////////////////////////////////////////////////////// - GetInput getInput = new GetInput(); - getInput.setTableName(tableName); - getInput.setUniqueKey(Map.of(StoredSession.FIELD_SESSION_UUID, sessionUuid)); - GetOutput getOutput = new GetAction().execute(getInput); - - if(getOutput.getRecord() != null) + QRecord record = get(SessionCodec.key(sessionUuid)); + if(record != null) { - QRecord updateRecord = new QRecord() - .withValue("id", getOutput.getRecord().getValue("id")) - .withValue(StoredSession.FIELD_EXPIRES_AT, newExpiresAt); - UpdateInput updateInput = new UpdateInput(); updateInput.setTableName(tableName); - updateInput.setRecords(List.of(updateRecord)); + updateInput.setRecords(List.of(new QRecord() + .withValue("id", record.getValue("id")) + .withValue(StoredSession.FIELD_EXPIRES_AT, newExpiresAt))); new UpdateAction().execute(updateInput); } return null; }); - LOG.debug("Touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt)); + LOG.debug("Touched session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newExpiresAt", newExpiresAt)); } catch(Exception e) { - LOG.warn("Failed to touch session", logPair("sessionUuid", sessionUuid), e); + LOG.warn("Failed to touch session", logPair("session", SessionCodec.logTag(sessionUuid)), e); } } @@ -241,83 +359,6 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte - /*************************************************************************** - ** Load a session and touch it in a single operation. - ** - ** Optimized to perform both load and TTL update in one database round-trip - ** by updating the expiresAt during the same get+update operation. - ***************************************************************************/ - @Override - public Optional loadAndTouch(String sessionUuid) - { - try - { - return runWithSystemSession(() -> - { - GetInput getInput = new GetInput(); - getInput.setTableName(tableName); - getInput.setUniqueKey(Map.of(StoredSession.FIELD_SESSION_UUID, sessionUuid)); - GetOutput getOutput = new GetAction().execute(getInput); - - QRecord record = getOutput.getRecord(); - if(record == null) - { - return Optional.empty(); - } - - Instant expiresAt = record.getValueInstant(StoredSession.FIELD_EXPIRES_AT); - if(expiresAt != null && Instant.now().isAfter(expiresAt)) - { - /////////////////////////////// - // Session is expired, clean up // - /////////////////////////////// - DeleteInput deleteInput = new DeleteInput(); - deleteInput.setTableName(tableName); - deleteInput.setQueryFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria(StoredSession.FIELD_SESSION_UUID, QCriteriaOperator.EQUALS, sessionUuid))); - new DeleteAction().execute(deleteInput); - LOG.debug("Session expired", logPair("sessionUuid", sessionUuid)); - return Optional.empty(); - } - - /////////////////////////////// - // Update expiresAt (touch) // - /////////////////////////////// - Instant newExpiresAt = Instant.now().plus(defaultTtl); - QRecord updateRecord = new QRecord() - .withValue("id", record.getValue("id")) - .withValue(StoredSession.FIELD_EXPIRES_AT, newExpiresAt); - - UpdateInput updateInput = new UpdateInput(); - updateInput.setTableName(tableName); - updateInput.setRecords(List.of(updateRecord)); - new UpdateAction().execute(updateInput); - - /////////////////////////////// - // Return the session // - /////////////////////////////// - String sessionJson = record.getValueString(StoredSession.FIELD_SESSION_DATA); - try - { - QSession session = JsonUtils.toObject(sessionJson, QSession.class); - LOG.debug("Loaded and touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt)); - return Optional.of(session); - } - catch(java.io.IOException e) - { - throw new QException("Failed to deserialize session", e); - } - }); - } - catch(Exception e) - { - LOG.warn("Failed to load and touch session", logPair("sessionUuid", sessionUuid), e); - return Optional.empty(); - } - } - - - /*************************************************************************** ** Clean up expired sessions. ***************************************************************************/ @@ -356,11 +397,9 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte { long count = runWithSystemSession(() -> { - QueryInput queryInput = new QueryInput(); - queryInput.setTableName(tableName); - queryInput.setFilter(new QQueryFilter()); - QueryOutput queryOutput = new QueryAction().execute(queryInput); - return (long) queryOutput.getRecords().size(); + CountInput countInput = new CountInput(); + countInput.setTableName(tableName); + return new CountAction().execute(countInput).getCount().longValue(); }); return "TableBased: " + count + " sessions in " + tableName; } diff --git a/src/test/java/com/kingsrook/qbits/sessionstore/SessionCodecTest.java b/src/test/java/com/kingsrook/qbits/sessionstore/SessionCodecTest.java new file mode 100644 index 0000000..95bcc80 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/sessionstore/SessionCodecTest.java @@ -0,0 +1,110 @@ +/* + * Copyright 2024 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.kingsrook.qbits.sessionstore; + + +import java.io.IOException; +import java.util.Set; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** Unit tests for the at-rest rules shared by the persistent providers. + *******************************************************************************/ +class SessionCodecTest +{ + private static final String ID = "3f1b6d3c-6b0e-4f2d-9d52-0a7d9ad1c001"; + + + + /*************************************************************************** + ** The key is a stable one-way digest that does not contain the id. + ***************************************************************************/ + @Test + void testKeyIsStableDigestWithoutId() + { + String key = SessionCodec.key(ID); + assertThat(key).matches("[0-9a-f]{64}").doesNotContain(ID).isEqualTo(SessionCodec.key(ID)).isNotEqualTo(SessionCodec.key(ID + "x")); + assertThat(SessionCodec.logTag(ID)).hasSize(12).doesNotContain(ID); + assertThat(SessionCodec.logTag(null)).isEqualTo("none"); + assertThatThrownBy(() -> SessionCodec.key(" ")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> SessionCodec.key(null)).isInstanceOf(NullPointerException.class); + } + + + + /*************************************************************************** + ** The stored text never contains the id, and decoding restores it. + ***************************************************************************/ + @Test + void testEncodeRemovesIdAndDecodeRestoresIt() throws IOException + { + QSession session = new QSession(); + session.setUuid(ID); + session.setIdReference(ID); + session.setUser(new QUser().withIdReference("alice")); + session.setPermissions(Set.of("p.read")); + session.setValue("tenant", "t-1"); + + String stored = SessionCodec.encode(ID, session); + assertThat(stored).doesNotContain(ID).contains("alice").contains("p.read"); + + QSession decoded = SessionCodec.decode(ID, stored); + assertThat(decoded.getUuid()).isEqualTo(ID); + assertThat(decoded.getIdReference()).isEqualTo(ID); + assertThat(decoded.getUser().getIdReference()).isEqualTo("alice"); + assertThat(decoded.getPermissions()).containsExactly("p.read"); + assertThat(decoded.getValue("tenant")).isEqualTo("t-1"); + } + + + + /*************************************************************************** + ** A session whose id reference differs from its uuid keeps its own value. + ***************************************************************************/ + @Test + void testDifferingIdReferenceIsKept() throws IOException + { + QSession session = new QSession(); + session.setUuid(ID); + session.setIdReference("alice@example.com"); + + QSession decoded = SessionCodec.decode(ID, SessionCodec.encode(ID, session)); + assertThat(decoded.getUuid()).isEqualTo(ID); + assertThat(decoded.getIdReference()).isEqualTo("alice@example.com"); + } + + + + /*************************************************************************** + ** An envelope read under another id, a legacy plain session, and junk are all refused. + ***************************************************************************/ + @Test + void testForeignLegacyAndJunkAreRefused() + { + String stored = SessionCodec.encode(ID, new QSession().withValue("k", "v")); + assertThatThrownBy(() -> SessionCodec.decode(ID + "other", stored)).isInstanceOf(IOException.class); + assertThatThrownBy(() -> SessionCodec.decode(ID, JsonUtils.toJson(new QSession().withValue("k", "v")))).isInstanceOf(IOException.class); + assertThatThrownBy(() -> SessionCodec.decode(ID, "not json")).isInstanceOf(IOException.class); + assertThatThrownBy(() -> SessionCodec.decode(ID, stored.replace("\"v\":1", "\"v\":2"))).isInstanceOf(IOException.class); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/sessionstore/providers/FixtureEndpoint.java b/src/test/java/com/kingsrook/qbits/sessionstore/providers/FixtureEndpoint.java new file mode 100644 index 0000000..2ae02f0 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/sessionstore/providers/FixtureEndpoint.java @@ -0,0 +1,238 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.sessionstore.providers; + + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import com.github.dockerjava.api.command.InspectContainerResponse; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.GenericContainer; + + +/******************************************************************************* + ** Where a disposable test container is reachable FROM THIS JVM. + ** + ** The original behavior is Testcontainers' own: the host it reports and the + ** published port. That is kept everywhere except one measured topology: a JVM + ** inside a build container that shares the daemon's default bridge with the + ** fixture (the full Linux verify, which talks to the host daemon through its + ** socket). On the Docker Desktop runner the published port is reached through + ** the host-gateway hop; measured on an idle machine that path showed round trips + ** of about 1 ms with stalls of 0.3 s and about 2 s, against about 0.13 ms and + ** no stalls when dialing the sibling directly on the bridge. These tests use + ** 250 ms to 1 s socket timeouts, so such stalls can expire them; that is a + ** plausible contributor, not shown to be the only cause of the observed + ** "Read timed out" failures. In that one topology the fixture is dialed on its + ** bridge address and its own port. + ** + ** The choice is made from the daemon's own description of the build container's + ** networks (no connection probing). If the build container cannot be identified + ** or is not on the default bridge (a job network, host networking, a remote + ** daemon), the original published endpoint is used unchanged. Timeouts, + ** scenarios, assertions and server configuration are unchanged. + *******************************************************************************/ +final class FixtureEndpoint +{ + private static final String DEFAULT_BRIDGE = "bridge"; + + private static final Pattern MOUNTINFO_CONTAINER_ID = Pattern.compile("/docker/containers/([0-9a-f]{64})/"); + + private static volatile Boolean buildContainerOnDefaultBridge; + + + + /*************************************************************************** + ** Host and port to dial. + ***************************************************************************/ + record Address(String host, int port) + { + } + + + + /*************************************************************************** + ** Utility class. + ***************************************************************************/ + private FixtureEndpoint() + { + } + + + + /*************************************************************************** + ** Host this JVM must dial for the container. + ***************************************************************************/ + static String host(GenericContainer container) + { + return (choose(onBridgeWithBuildContainer(), bridgeIp(container), 6379, container.getHost(), publishedPort(container)).host()); + } + + + + /*************************************************************************** + ** Port matching {@link #host(GenericContainer)}. + ***************************************************************************/ + static int port(GenericContainer container) + { + return (choose(onBridgeWithBuildContainer(), bridgeIp(container), 6379, container.getHost(), publishedPort(container)).port()); + } + + + + /*************************************************************************** + ** A container that lives only on a custom test network is also attached to the + ** default bridge when (and only when) the build container is on that bridge, so + ** that the build container can reach it directly. The test network, and therefore + ** every partition scenario between the fixture servers, is untouched: attach only + ** the container the test client dials, never one whose reachability the test cuts + ** or whose peers must stay isolated from it. No-op in every other topology. + ***************************************************************************/ + static void attachToDefaultBridgeWhenInsideContainer(GenericContainer container) + { + if(!onBridgeWithBuildContainer()) + { + return; + } + var docker = container.getDockerClient(); + var networks = docker.inspectContainerCmd(container.getContainerId()).exec().getNetworkSettings().getNetworks(); + if(networks == null || !networks.containsKey(DEFAULT_BRIDGE)) + { + docker.connectToNetworkCmd().withContainerId(container.getContainerId()).withNetworkId(DEFAULT_BRIDGE).exec(); + } + } + + + + /*************************************************************************** + ** Whether this JVM runs in a container that the daemon reports on the default + ** bridge. Decided once per JVM from the daemon's description of this container. + ***************************************************************************/ + private static boolean onBridgeWithBuildContainer() + { + Boolean known = buildContainerOnDefaultBridge; + if(known == null) + { + known = decideBuildContainerOnDefaultBridge(); + buildContainerOnDefaultBridge = known; + } + return (known); + } + + + + /*************************************************************************** + ** Inspect the build container; anything that cannot be established means the + ** original published endpoint (false). + ***************************************************************************/ + private static boolean decideBuildContainerOnDefaultBridge() + { + if(!Files.exists(Path.of("/.dockerenv"))) + { + return (false); + } + try + { + String id = ownContainerId(Files.readString(Path.of("/proc/self/mountinfo")), System.getenv("HOSTNAME")); + if(id == null) + { + return (false); + } + var networks = DockerClientFactory.instance().client().inspectContainerCmd(id).exec().getNetworkSettings().getNetworks(); + return (networks != null && networks.containsKey(DEFAULT_BRIDGE)); + } + catch(Exception e) + { + return (false); + } + } + + + + /*************************************************************************** + ** The id of the container this JVM runs in: from the mount table when it names + ** the container directory, otherwise the host name (the container id unless + ** overridden); null when neither exists. + ***************************************************************************/ + static String ownContainerId(String mountInfo, String hostName) + { + Matcher matcher = MOUNTINFO_CONTAINER_ID.matcher(mountInfo == null ? "" : mountInfo); + if(matcher.find()) + { + return (matcher.group(1)); + } + return (hostName == null || hostName.isBlank() ? null : hostName.trim()); + } + + + + /*************************************************************************** + ** The container's address on the default bridge, or null when it has none. + ***************************************************************************/ + private static String bridgeIp(GenericContainer container) + { + if(!onBridgeWithBuildContainer()) + { + return (null); + } + InspectContainerResponse inspected = container.getDockerClient().inspectContainerCmd(container.getContainerId()).exec(); + var networks = inspected.getNetworkSettings().getNetworks(); + return (networks == null || networks.get(DEFAULT_BRIDGE) == null ? null : networks.get(DEFAULT_BRIDGE).getIpAddress()); + } + + + + /*************************************************************************** + ** Testcontainers' published port for the Redis port, or null when the build + ** container dials the bridge (so the published port is not consulted). + ***************************************************************************/ + private static Integer publishedPort(GenericContainer container) + { + return (onBridgeWithBuildContainer() ? null : container.getMappedPort(6379)); + } + + + + /*************************************************************************** + ** Pure decision, separately tested. With the build container on the default + ** bridge: the sibling's bridge address and its own port. Otherwise the published + ** endpoint exactly as Testcontainers reports it. + ***************************************************************************/ + static Address choose(boolean buildContainerOnDefaultBridge, String bridgeIp, int internalPort, String publishedHost, Integer publishedPort) + { + if(buildContainerOnDefaultBridge) + { + if(bridgeIp == null || bridgeIp.isBlank()) + { + throw new IllegalStateException("FIXTURE_ENDPOINT_NO_BRIDGE_ADDRESS: the build container cannot reach a sibling without its bridge address"); + } + return (new Address(bridgeIp, internalPort)); + } + if(publishedHost == null || publishedHost.isBlank() || publishedPort == null) + { + throw new IllegalStateException("FIXTURE_ENDPOINT_NOT_PUBLISHED: no published endpoint to dial"); + } + return (new Address(publishedHost, publishedPort)); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/sessionstore/providers/FixtureEndpointTest.java b/src/test/java/com/kingsrook/qbits/sessionstore/providers/FixtureEndpointTest.java new file mode 100644 index 0000000..e785ce0 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/sessionstore/providers/FixtureEndpointTest.java @@ -0,0 +1,92 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.sessionstore.providers; + + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + + +/******************************************************************************* + ** The topology decision of the sibling-container fixtures. + *******************************************************************************/ +class FixtureEndpointTest +{ + private static final String CONTAINER_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + + + /*************************************************************************** + ** Build container on the default bridge: dial the sibling bridge IP and its own port, never the published port. + ***************************************************************************/ + @Test + void buildContainerOnTheBridgeUsesTheBridgeAddress() + { + assertEquals(new FixtureEndpoint.Address("172.17.0.9", 6379), FixtureEndpoint.choose(true, "172.17.0.9", 6379, "localhost", 41111)); + assertEquals(new FixtureEndpoint.Address("172.17.0.9", 6379), FixtureEndpoint.choose(true, "172.17.0.9", 6379, null, null)); + } + + + + /*************************************************************************** + ** Every other topology (JVM on the Docker host, remote daemon, job network, host networking, unidentified + ** build container) keeps the original Testcontainers host and published port exactly, whatever the host is. + ***************************************************************************/ + @Test + void everyOtherTopologyKeepsTheOriginalPublishedEndpoint() + { + assertEquals(new FixtureEndpoint.Address("localhost", 41111), FixtureEndpoint.choose(false, "172.17.0.9", 6379, "localhost", 41111)); + assertEquals(new FixtureEndpoint.Address("docker.example.test", 32768), FixtureEndpoint.choose(false, null, 6379, "docker.example.test", 32768)); + assertEquals(new FixtureEndpoint.Address("host.docker.internal", 40000), FixtureEndpoint.choose(false, null, 6379, "host.docker.internal", 40000)); + } + + + + /*************************************************************************** + ** An unknown endpoint is an error, never a guessed address. + ***************************************************************************/ + @Test + void missingEndpointFailsClosed() + { + assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(true, null, 6379, "localhost", 41111)); + assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(true, " ", 6379, "localhost", 41111)); + assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(false, "172.17.0.9", 6379, "localhost", null)); + assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(false, "172.17.0.9", 6379, " ", 41111)); + } + + + + /*************************************************************************** + ** The build container id comes from the mount table when present, else the host name. + ***************************************************************************/ + @Test + void ownContainerIdPrefersTheMountTableThenTheHostName() + { + String mountInfo = "700 699 0:50 /docker/containers/" + CONTAINER_ID + "/hostname /etc/hostname rw,relatime - ext4 /dev/sda1 rw"; + assertEquals(CONTAINER_ID, FixtureEndpoint.ownContainerId(mountInfo, "unrelated")); + assertEquals("abc123", FixtureEndpoint.ownContainerId("no container mounts here", " abc123 ")); + assertNull(FixtureEndpoint.ownContainerId("", null)); + assertNull(FixtureEndpoint.ownContainerId(null, " ")); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/sessionstore/providers/SharedRedisSessionStoreProviderIT.java b/src/test/java/com/kingsrook/qbits/sessionstore/providers/SharedRedisSessionStoreProviderIT.java index 09f5cdc..cd042f4 100644 --- a/src/test/java/com/kingsrook/qbits/sessionstore/providers/SharedRedisSessionStoreProviderIT.java +++ b/src/test/java/com/kingsrook/qbits/sessionstore/providers/SharedRedisSessionStoreProviderIT.java @@ -28,6 +28,7 @@ import com.kingsrook.qbits.sessionstore.QSessionStoreProviderType; import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig; import com.kingsrook.qbits.sessionstore.QSessionStoreQBitContext; import com.kingsrook.qbits.sessionstore.QSessionStoreQBitProducer; +import com.kingsrook.qbits.sessionstore.SessionCodec; import com.kingsrook.qbits.sessionstore.SharedRedisSessionStoreRuntimeService; import com.kingsrook.qbits.sessionstore.SharedRedisSessionStoreSettings; import com.kingsrook.qqq.backend.core.context.QContext; @@ -45,6 +46,115 @@ import static org.junit.jupiter.api.Assertions.*; *******************************************************************************/ class SharedRedisSessionStoreProviderIT { + /*************************************************************************** + ** Observed replicated sessions and revocations survive controlled promotion. + ** This does not claim survival of writes lost before asynchronous replication. + ***************************************************************************/ + @Test + void replicatedSessionsAndRevocationsSurviveControlledPromotion() throws Exception + { + QInstance original = QContext.getQInstance(); + try(var network = org.testcontainers.containers.Network.newNetwork(); + var primary = new GenericContainer<>("redis:7.4-alpine").withNetwork(network).withNetworkAliases("primary").withExposedPorts(6379); + var replica = new GenericContainer<>("redis:7.4-alpine").withNetwork(network).withExposedPorts(6379) + .withCommand("redis-server", "--replicaof", "primary", "6379")) + { + primary.start(); + replica.start(); + FixtureEndpoint.attachToDefaultBridgeWhenInsideContainer(primary); + FixtureEndpoint.attachToDefaultBridgeWhenInsideContainer(replica); + QInstance first = host(primary, "promotion", true); + QInstance second = host(replica, "promotion", false); + QInstance third = host(replica, "promotion", true); + var firstRuntime = new RedisRuntimeService(); + var secondRuntime = new RedisRuntimeService(); + var thirdRuntime = new RedisRuntimeService(); + try + { + firstRuntime.start(first); + secondRuntime.start(second); + thirdRuntime.start(third); + var a = QSessionStoreQBitContext.getProvider(first); + var b = QSessionStoreQBitContext.getProvider(second); + var c = QSessionStoreQBitContext.getProvider(third); + a.store("active", new QSession().withValue("owner", "alice"), Duration.ofSeconds(60)); + a.store("revoked", new QSession().withValue("owner", "bob"), Duration.ofSeconds(60)); + assertTimeoutPreemptively(Duration.ofSeconds(15), () -> + { + while(true) + { + try + { + if(b.load("revoked").isPresent()) + { + break; + } + } + catch(redis.clients.jedis.exceptions.JedisConnectionException initialSync) + { + // Initial replica loading can temporarily block reads; the outer deadline stays bounded. + } + Thread.sleep(25); + } + }); + assertThrows(RuntimeException.class, () -> b.store("replica-write", new QSession(), null)); + a.remove("revoked"); + // Seeing a later write proves this test's deletion reached the replica. + a.store("barrier", new QSession(), Duration.ofSeconds(60)); + assertTimeoutPreemptively(Duration.ofSeconds(5), () -> + { + while(b.load("barrier").isEmpty()) + { + Thread.sleep(25); + } + }); + assertTrue(b.load("revoked").isEmpty()); + primary.stop(); + assertThrows(RuntimeException.class, () -> a.load("active")); + var promoted = replica.execInContainer("redis-cli", "REPLICAOF", "NO", "ONE"); + assertEquals(0, promoted.getExitCode()); + assertEquals("OK", promoted.getStdout().trim()); + assertEquals("alice", b.load("active").orElseThrow().getValue("owner")); + assertTrue(c.loadAndTouch("revoked").isEmpty()); + c.touch("revoked"); + assertTrue(b.load("revoked").isEmpty()); + b.store("new", new QSession().withValue("owner", "carol"), Duration.ofSeconds(1)); + assertEquals("carol", c.loadAndTouch("new").orElseThrow().getValue("owner")); + var namespace = new RedisNamespace("test", "promotion", "authentication", "session-store"); + var client = RedisRuntimeService.connection(second, "sessions").client(); + assertTrue(client.pttl(namespace.key(SessionCodec.key("new"))) > 20000); + c.remove("active"); + assertTrue(b.load("active").isEmpty()); + b.store("expires", new QSession(), Duration.ofMillis(200)); + assertTimeoutPreemptively(Duration.ofSeconds(3), () -> + { + while(b.load("expires").isPresent()) + { + Thread.sleep(25); + } + }); + c.touch("expires"); + assertTrue(c.loadAndTouch("expires").isEmpty()); + } + finally + { + for(var runtime : List.of(firstRuntime, secondRuntime, thirdRuntime)) + { + runtime.stop(); + runtime.afterApplicationStop(); + } + for(var instance : List.of(first, second, third)) + { + QSessionStoreQBitContext.clear(instance); + QSessionStoreRegistry.getInstance(instance).clear(); + } + QContext.setQInstance(original); + } + } + } + + + /*************************************************************************** ** Separate nodes share one app's sessions while other apps remain isolated. ***************************************************************************/ @@ -85,10 +195,11 @@ class SharedRedisSessionStoreProviderIT var client = RedisRuntimeService.connection(first, "sessions").client(); a.store("sliding", new QSession(), Duration.ofSeconds(1)); assertTrue(a.loadAndTouch("sliding").isPresent()); - assertTrue(client.pttl(scope.key("sliding")) > 20000); + assertTrue(client.pttl(scope.key(SessionCodec.key("sliding"))) > 20000); + assertEquals(-2L, client.pttl(scope.key("sliding")), "the raw session id must never be a Redis key"); a.store("fixed", new QSession(), Duration.ofMillis(200)); assertTrue(b.loadAndTouch("fixed").isPresent()); - assertTrue(client.pttl(scope.key("fixed")) <= 200); + assertTrue(client.pttl(scope.key(SessionCodec.key("fixed"))) <= 200); long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos(); while(b.load("fixed").isPresent() && System.nanoTime() < deadline) { @@ -100,7 +211,7 @@ class SharedRedisSessionStoreProviderIT assertThrows(IllegalArgumentException.class, () -> a.store("large", new QSession().withValue("large", "x".repeat(70000)), null)); RedisAccess.cache(first, "sessions", scope, 65536) - .put("broken", "test-secret-not-json".getBytes(java.nio.charset.StandardCharsets.UTF_8), Duration.ofSeconds(5)); + .put(SessionCodec.key("broken"), "test-secret-not-json".getBytes(java.nio.charset.StandardCharsets.UTF_8), Duration.ofSeconds(5)); var malformed = assertThrows(IllegalStateException.class, () -> a.load("broken")); assertFalse(malformed.toString().contains("test-secret")); assertNull(malformed.getCause()); @@ -426,7 +537,7 @@ class SharedRedisSessionStoreProviderIT instance.addTable(new com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData().withName("record").withBackendName("memory").withPrimaryKeyField("id") .withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("id", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.INTEGER))); new RedisQBitProducer().withQBitConfig(new RedisQBitConfig().withConnection("sessions", - new RedisConnectionConfig().withHost(redis.getHost()).withPort(redis.getMappedPort(6379)) + new RedisConnectionConfig().withHost(FixtureEndpoint.host(redis)).withPort(FixtureEndpoint.port(redis)) .withTls(false).withSocketTimeoutMillis(300).withConnectionTimeoutMillis(300))) .produce(instance); new QSessionStoreQBitProducer().withConfig(new QSessionStoreQBitConfig() diff --git a/src/test/java/com/kingsrook/qbits/sessionstore/providers/TableBasedSessionStorePostgresIT.java b/src/test/java/com/kingsrook/qbits/sessionstore/providers/TableBasedSessionStorePostgresIT.java new file mode 100644 index 0000000..c49b4b7 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/sessionstore/providers/TableBasedSessionStorePostgresIT.java @@ -0,0 +1,491 @@ +/* + * Copyright 2024 Kingsrook, LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.kingsrook.qbits.sessionstore.providers; + + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import com.kingsrook.qbits.sessionstore.QSessionStoreProviderFactory; +import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface; +import com.kingsrook.qbits.sessionstore.QSessionStoreProviderType; +import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig; +import com.kingsrook.qbits.sessionstore.QSessionStoreQBitProducer; +import com.kingsrook.qbits.sessionstore.SessionCodec; +import com.kingsrook.qbits.sessionstore.StoredSessionSchema; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.instances.QInstanceValidator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +/******************************************************************************* + ** The TABLE_BASED provider on a real PostgreSQL 17, with the production + ** separation of identities: a schema OWNER applies the DDL once, and the + ** provider runs as a DML-only role that owns nothing and cannot create or alter + ** anything. Two application instances (separate QInstances and providers) + ** share the one database, as replicas of the host would. + *******************************************************************************/ +class TableBasedSessionStorePostgresIT +{ + private static final String OWNER = "qbit_owner"; + private static final String RUNTIME = "qbit_runtime"; + private static final String BACKEND = "sessionsPg"; + private static final String TABLE = "qbitSessionStoreEntry"; + + private static GenericContainer postgres; + private static String host; + private static int port; + + private QSessionStoreQBitConfig config; + private QInstance instanceA; + private QInstance instanceB; + private QSessionStoreProviderInterface providerA; + private QSessionStoreProviderInterface providerB; + + + + /*************************************************************************** + ** One PG17, owner-created schema, DML-only runtime role. + ***************************************************************************/ + @BeforeAll + static void startDatabase() throws Exception + { + postgres = new GenericContainer<>("postgres:17-alpine") + .withEnv("POSTGRES_PASSWORD", "super-synthetic") + .withExposedPorts(5432) + .waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*\\n", 2).withStartupTimeout(Duration.ofMinutes(2))); + postgres.start(); + host = postgres.getHost(); + port = postgres.getMappedPort(5432); + + try(Connection admin = connect("postgres", "super-synthetic", "postgres"); Statement statement = admin.createStatement()) + { + statement.execute("CREATE ROLE " + OWNER + " LOGIN PASSWORD 'owner-synthetic'"); + statement.execute("CREATE ROLE " + RUNTIME + " LOGIN PASSWORD 'runtime-synthetic'"); + statement.execute("CREATE DATABASE sessions OWNER " + OWNER); + statement.execute("REVOKE ALL ON DATABASE sessions FROM PUBLIC"); + statement.execute("GRANT CONNECT ON DATABASE sessions TO " + RUNTIME); + } + try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement()) + { + statement.execute("REVOKE ALL ON SCHEMA public FROM PUBLIC"); + statement.execute("GRANT USAGE ON SCHEMA public TO " + RUNTIME); + String ddl = StoredSessionSchema.postgresDdl(configFor()); + statement.execute(ddl); + statement.execute(ddl); // idempotent + String table = StoredSessionSchema.physicalTableName(configFor()); + statement.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON " + StoredSessionSchema.quote(table) + " TO " + RUNTIME); + } + } + + + + /*************************************************************************** + ** Stop the database. + ***************************************************************************/ + @AfterAll + static void stopDatabase() + { + if(postgres != null) + { + postgres.stop(); + } + } + + + + /*************************************************************************** + ** Fresh table content and two runtime instances per test. + ***************************************************************************/ + @BeforeEach + void setUp() throws Exception + { + try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement()) + { + statement.execute("DELETE FROM " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(configFor()))); + } + config = configFor(); + instanceA = instance(); + instanceB = instance(); + QContext.init(instanceA, new QSession()); + providerA = QSessionStoreProviderFactory.createProvider(instanceA, config); + QContext.init(instanceB, new QSession()); + providerB = QSessionStoreProviderFactory.createProvider(instanceB, config); + } + + + + /*************************************************************************** + ** Clear the context. + ***************************************************************************/ + @AfterEach + void tearDown() + { + QContext.clear(); + } + + + + /*************************************************************************** + ** Store and load as the DML-only role; a second instance sees it, and a + ** removal on either instance revokes it on both. + ***************************************************************************/ + @Test + void testSharedAcrossInstancesAndRevocation() + { + String id = UUID.randomUUID().toString(); + run(instanceA, () -> providerA.store(id, session(id, "alice", "tenant-a", "orders.read"), Duration.ofMinutes(10))); + + QSession seen = on(instanceB, () -> providerB.load(id)).orElseThrow(); + assertThat(seen.getUuid()).isEqualTo(id); + assertThat(seen.getUser().getIdReference()).isEqualTo("alice"); + assertThat(seen.getValue("tenant")).isEqualTo("tenant-a"); + assertThat(seen.getPermissions()).containsExactly("orders.read"); + + run(instanceB, () -> providerB.remove(id)); + assertThat(on(instanceA, () -> providerA.load(id))).isEmpty(); + assertThat(on(instanceA, () -> providerA.loadAndTouch(id))).isEmpty(); + assertThat(rows()).isEmpty(); + } + + + + /*************************************************************************** + ** Re-storing a session replaces what was cached: narrower permissions or a + ** different tenant never survive as a stale copy; one row remains. + ***************************************************************************/ + @Test + void testRestoreReplacesStaleCopy() + { + String id = UUID.randomUUID().toString(); + run(instanceA, () -> providerA.store(id, session(id, "alice", "tenant-a", "orders.write", "orders.read"), Duration.ofMinutes(10))); + run(instanceB, () -> providerB.store(id, session(id, "alice", "tenant-b", "orders.read"), Duration.ofMinutes(10))); + + QSession seen = on(instanceA, () -> providerA.load(id)).orElseThrow(); + assertThat(seen.getValue("tenant")).isEqualTo("tenant-b"); + assertThat(seen.getPermissions()).containsExactly("orders.read"); + assertThat(rows()).hasSize(1); + } + + + + /*************************************************************************** + ** Sessions of different actors and tenants stay separate, and a session id + ** that was never stored loads nothing. + ***************************************************************************/ + @Test + void testActorAndTenantSeparation() + { + String idAlice = UUID.randomUUID().toString(); + String idBob = UUID.randomUUID().toString(); + run(instanceA, () -> providerA.store(idAlice, session(idAlice, "alice", "tenant-a", "a.read"), Duration.ofMinutes(10))); + run(instanceA, () -> providerA.store(idBob, session(idBob, "bob", "tenant-b", "b.read"), Duration.ofMinutes(10))); + + QSession alice = on(instanceB, () -> providerB.load(idAlice)).orElseThrow(); + QSession bob = on(instanceB, () -> providerB.load(idBob)).orElseThrow(); + assertThat(alice.getUser().getIdReference()).isEqualTo("alice"); + assertThat(alice.getValue("tenant")).isEqualTo("tenant-a"); + assertThat(bob.getUser().getIdReference()).isEqualTo("bob"); + assertThat(bob.getValue("tenant")).isEqualTo("tenant-b"); + assertThat(on(instanceB, () -> providerB.load(UUID.randomUUID().toString()))).isEmpty(); + assertThat(on(instanceB, () -> providerB.load(idAlice.toUpperCase()))).isEmpty(); + } + + + + /*************************************************************************** + ** The database holds no session id and no id-bearing JSON: only digests. + ***************************************************************************/ + @Test + void testDatabaseHoldsNoSessionId() throws Exception + { + String id = UUID.randomUUID().toString(); + run(instanceA, () -> providerA.store(id, session(id, "alice", "tenant-a", "orders.read"), Duration.ofMinutes(10))); + + List columns = StoredSessionSchema.physicalColumnNames(config); + try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement(); + ResultSet rs = statement.executeQuery("SELECT * FROM " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config)))) + { + assertThat(rs.next()).isTrue(); + for(String column : columns) + { + String value = rs.getString(column); + assertThat(value == null ? "" : value).as(column).doesNotContain(id); + } + assertThat(rs.getString("sessionUuid")).isEqualTo(SessionCodec.key(id)); + } + } + + + + /*************************************************************************** + ** An expired row is a miss and is removed; cleanup deletes only expired rows. + ***************************************************************************/ + @Test + void testExpiryAndCleanup() throws Exception + { + String live = UUID.randomUUID().toString(); + String expired = UUID.randomUUID().toString(); + String lazy = UUID.randomUUID().toString(); + run(instanceA, () -> providerA.store(live, session(live, "alice", "t", "p"), Duration.ofMinutes(10))); + run(instanceA, () -> providerA.store(expired, session(expired, "bob", "t", "p"), Duration.ofMillis(1))); + run(instanceA, () -> providerA.store(lazy, session(lazy, "carol", "t", "p"), Duration.ofMillis(1))); + Thread.sleep(50); + + assertThat(on(instanceB, () -> providerB.load(lazy))).isEmpty(); + assertThat(rows()).hasSize(2); + + run(instanceB, providerB::cleanExpired); + assertThat(rows()).hasSize(1); + assertThat(on(instanceA, () -> providerA.load(live))).isPresent(); + assertThat(on(instanceA, () -> providerA.load(expired))).isEmpty(); + } + + + + /*************************************************************************** + ** A row whose identity column no longer matches its envelope is discarded + ** rather than served. + ***************************************************************************/ + @Test + void testTamperedRowIsDiscarded() throws Exception + { + String id = UUID.randomUUID().toString(); + run(instanceA, () -> providerA.store(id, session(id, "alice", "tenant-a", "orders.read"), Duration.ofMinutes(10))); + try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement()) + { + statement.execute("UPDATE " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config)) + " SET \"userId\" = 'mallory'"); + } + assertThat(on(instanceB, () -> providerB.load(id))).isEmpty(); + assertThat(rows()).isEmpty(); + } + + + + /*************************************************************************** + ** The runtime identity can do DML only: no DDL, no other relation, and the + ** schema it was given is not its own. + ***************************************************************************/ + @Test + void testRuntimeRoleHasNoDdlOrOwnership() throws Exception + { + try(Connection runtime = connect(RUNTIME, "runtime-synthetic", "sessions"); Statement statement = runtime.createStatement()) + { + assertThatThrownBy(() -> statement.execute("CREATE TABLE intruder (id int)")).isInstanceOf(SQLException.class).hasMessageContaining("permission denied"); + assertThatThrownBy(() -> statement.execute("ALTER TABLE " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config)) + " ADD COLUMN x int")).isInstanceOf(SQLException.class); + assertThatThrownBy(() -> statement.execute("DROP TABLE " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config)))).isInstanceOf(SQLException.class); + assertThatThrownBy(() -> statement.execute("TRUNCATE " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config)))).isInstanceOf(SQLException.class); + try(ResultSet rs = statement.executeQuery("SELECT count(*) FROM pg_class c JOIN pg_roles r ON r.oid = c.relowner WHERE r.rolname = '" + RUNTIME + "'")) + { + rs.next(); + assertThat(rs.getInt(1)).isZero(); + } + } + } + + + + /*************************************************************************** + ** The table is internal: hidden, and a signed-in user session, however + ** privileged elsewhere, cannot query it through the core actions. + ***************************************************************************/ + @Test + void testTableIsInternal() throws Exception + { + QSessionStoreQBitConfig produced = configFor(); + QInstance instance = baseInstance(); + new QSessionStoreQBitProducer().withConfig(produced).produce(instance, ""); + new QInstanceValidator().validate(instance); + assertThat(instance.getTable(TABLE).getIsHidden()).isTrue(); + + QSession user = new QSession(); + user.setUser(new QUser().withIdReference("alice")); + user.setPermissions(Set.of("everything.read")); + QContext.init(instance, user); + assertThatThrownBy(() -> + { + QueryInput input = new QueryInput(); + input.setTableName(TABLE); + new QueryAction().execute(input); + }).isInstanceOf(QException.class); + } + + + + /*************************************************************************** + ** DDL is derived from the producer's metadata, idempotent, and names every + ** field. + ***************************************************************************/ + @Test + void testDdlMatchesMetadata() + { + String ddl = StoredSessionSchema.postgresDdl(configFor()); + assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS \"qbitSessionStoreEntry\"").contains("\"sessionUuid\" VARCHAR(64) NOT NULL") + .contains("GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY").contains("CREATE INDEX IF NOT EXISTS"); + assertThat(StoredSessionSchema.physicalColumnNames(configFor())).containsExactlyInAnyOrder("id", "sessionUuid", "userId", "sessionData", "expiresAt", "createDate", "modifyDate"); + + String snake = StoredSessionSchema.postgresDdl(configFor(), com.kingsrook.qqq.backend.core.instances.QInstanceEnricher::inferBackendName); + assertThat(snake).contains("CREATE TABLE IF NOT EXISTS \"qbit_session_store_entry\"").contains("\"session_uuid\" VARCHAR(64) NOT NULL").contains("\"expires_at\" TIMESTAMP WITH TIME ZONE NOT NULL"); + } + + + + /*************************************************************************** + ** Run something with the given instance as the QQQ context, as one + ** application replica would. + ***************************************************************************/ + private static T on(QInstance instance, java.util.function.Supplier action) + { + QContext.init(instance, new QSession()); + return action.get(); + } + + + + /*************************************************************************** + ** Void variant. + ***************************************************************************/ + private static void run(QInstance instance, Runnable action) + { + QContext.init(instance, new QSession()); + action.run(); + } + + + + /*************************************************************************** + ** The row count, read as the owner. + ***************************************************************************/ + private List rows() + { + List keys = new ArrayList<>(); + try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement(); + ResultSet rs = statement.executeQuery("SELECT \"sessionUuid\" FROM " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config)))) + { + while(rs.next()) + { + keys.add(rs.getString(1)); + } + } + catch(SQLException e) + { + throw new IllegalStateException(e); + } + return keys; + } + + + + /*************************************************************************** + ** A synthetic session. + ***************************************************************************/ + private static QSession session(String id, String user, String tenant, String... permissions) + { + QSession session = new QSession(); + session.setUuid(id); + session.setIdReference(id); + session.setUser(new QUser().withIdReference(user)); + session.setPermissions(Set.of(permissions)); + session.setValue("tenant", tenant); + return session; + } + + + + /*************************************************************************** + ** The provider configuration. + ***************************************************************************/ + private static QSessionStoreQBitConfig configFor() + { + return new QSessionStoreQBitConfig() + .withProviderType(QSessionStoreProviderType.TABLE_BASED) + .withBackendName(BACKEND) + .withTableName(TABLE) + .withDefaultTtl(Duration.ofMinutes(30)) + .withEnableCleanupProcess(false); + } + + + + /*************************************************************************** + ** A runtime application instance on the DML-only role, enriched and + ** validated the way a started server is. + ***************************************************************************/ + private static QInstance instance() throws QException + { + QInstance instance = baseInstance(); + com.kingsrook.qbits.sessionstore.metadata.StoredSessionTableMetaDataProducer.produce(instance, configFor()); + new QInstanceValidator().validate(instance); + return instance; + } + + + + /*************************************************************************** + ** The application without the qbit: enforcement on, native PG backend as the + ** DML-only role. + ***************************************************************************/ + private static QInstance baseInstance() + { + QInstance instance = new QInstance(); + instance.setEnforcePermissionsInCoreActions(true); + instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS)); + PostgreSQLBackendMetaData backend = new PostgreSQLBackendMetaData(); + backend.setName(BACKEND); + backend.setHostName(host); + backend.setPort(port); + backend.setDatabaseName("sessions"); + backend.setUsername(RUNTIME); + backend.setPassword("runtime-synthetic"); + instance.addBackend(backend); + return instance; + } + + + + /*************************************************************************** + ** A JDBC connection as a named role. + ***************************************************************************/ + private static Connection connect(String user, String password, String database) throws SQLException + { + return DriverManager.getConnection("jdbc:postgresql://" + host + ":" + port + "/" + database, user, password); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/sessionstore/providers/TableBasedSessionStoreProviderIT.java b/src/test/java/com/kingsrook/qbits/sessionstore/providers/TableBasedSessionStoreProviderIT.java index bbd0699..72c6bff 100644 --- a/src/test/java/com/kingsrook/qbits/sessionstore/providers/TableBasedSessionStoreProviderIT.java +++ b/src/test/java/com/kingsrook/qbits/sessionstore/providers/TableBasedSessionStoreProviderIT.java @@ -110,7 +110,7 @@ class TableBasedSessionStoreProviderIT conn.createStatement().execute(""" CREATE TABLE `storedSession` ( `id` INTEGER AUTO_INCREMENT PRIMARY KEY, - `sessionUuid` VARCHAR(36) NOT NULL UNIQUE, + `sessionUuid` VARCHAR(64) NOT NULL UNIQUE, `userId` VARCHAR(255), `sessionData` TEXT, `expiresAt` TIMESTAMP NOT NULL, -- 2.54.0 From 375cf053a15dbced10c94afb55485ceb71af9264 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 6 Nov 2026 18:10:29 -0600 Subject: [PATCH 2/2] build: keep the JaCoCo agent on the unit tests; run the PostgreSQL IT under a UTC JVM in failsafe The UTC argLine was set on surefire, replacing the argLine that jacoco prepare-agent injects, so the unit tests ran without the agent and the class-coverage gate fell to 0.70 (min 0.80). The UTC setting belongs to failsafe, which runs the PostgreSQL IT; it now appends to the jacoco argLine with @{argLine}, with an empty default property. Co-Authored-By: Claude Sonnet 5.5 --- pom.xml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index dd2059b..0c7492e 100644 --- a/pom.xml +++ b/pom.xml @@ -21,6 +21,7 @@ 0.2.0-SNAPSHOT 21 21 + 7.2.0 - -Duser.timezone=UTC @@ -199,6 +198,9 @@ **/*IntegrationTest.java **/*IT.java + + @{argLine} -Duser.timezone=UTC -- 2.54.0