diff --git a/pom.xml b/pom.xml
index 2fd6769..0c7492e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -21,6 +21,7 @@
0.2.0-SNAPSHOT
21
21
+
7.2.0
+ @{argLine} -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,