Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5a0e0f43d
|
No files matched your search
@@ -1,12 +1,12 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.12
|
||||
workflows:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
orb_version: 0.7.12
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
profile: qbit
|
||||
orb_version: '0.8'
|
||||
orb_version: 0.7.12
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
|
||||
+1
-25
@@ -5,12 +5,7 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased] - 0.1.0
|
||||
|
||||
First published release. Version numbers are assigned by the shared Gitea
|
||||
Maven workflow from the branch or tag that publishes (see README "Releasing");
|
||||
the `revision` property on `develop` stays `0.1.0-SNAPSHOT` until the release
|
||||
merges back.
|
||||
## [Unreleased] - 0.1.0-SNAPSHOT
|
||||
|
||||
### Added
|
||||
- In-memory session store provider with LRU eviction
|
||||
@@ -20,22 +15,3 @@ merges back.
|
||||
- Sliding expiration (configurable per-provider)
|
||||
- Scheduled cleanup process for expired sessions
|
||||
- QBit producer for declarative configuration
|
||||
- `SHARED_REDIS` provider: application-scoped shared sessions on the pool owned
|
||||
by the optional `qbit-redis` QBit, with explicit deployment/application/
|
||||
authentication-domain namespacing, bounded payloads, atomic GETEX touch, and a
|
||||
runtime service that releases registrations on stop and rebuilds them on
|
||||
restart (`docs/SESSION-REGISTRATION.md`)
|
||||
- Explicit `QInstance` overloads on `QSessionStoreQBitContext` and
|
||||
application-scoped registration through
|
||||
`QSessionStoreRegistry.getInstance(qInstance)`; two applications in one JVM
|
||||
no longer see each other's sessions
|
||||
- Health check reports the target application selected by `HealthCheckInput`
|
||||
|
||||
### Changed
|
||||
- Depends on published QQQ core `4.1.0-RC.22` (the first release-line core with
|
||||
`QSessionStoreRegistry.getInstance(QInstance)`) and `qbit-redis 0.1.0-RC.1`;
|
||||
the snapshot coordinates used while the shared-session work was in flight
|
||||
(`4.1.0-collab-33464d37e-SNAPSHOT`, `0.1.0-redis-7fe5492-SNAPSHOT`) are gone
|
||||
- Jedis 7.2.0, Testcontainers 2.0.3, AssertJ 3.27.7 (GHSA-rqfh-9r24-8c9r)
|
||||
- Hosts that register a provider with core outside a request context must use
|
||||
the explicit `QSessionStoreRegistry.getInstance(qInstance)` overload
|
||||
@@ -8,21 +8,7 @@ Every QQQ request authenticates and builds a `QSession` containing security keys
|
||||
|
||||
## Project Status
|
||||
|
||||
**0.1.0** is the first published release line (release candidates publish as
|
||||
`0.1.0-RC.N`). API may change before 1.0. See [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## Compatibility
|
||||
|
||||
| qbit-session-store | QQQ core (`qqq-bom-pom`) | qbit-redis (optional, `SHARED_REDIS` only) |
|
||||
|--------------------|--------------------------|--------------------------------------------|
|
||||
| 0.1.0 line | 4.1.0-RC.22 or later 4.1 | 0.1.0-RC.1 or later 0.1 |
|
||||
|
||||
The `SHARED_REDIS` provider and application-scoped registration call
|
||||
`QSessionStoreRegistry.getInstance(QInstance)` and `clearIfSame`, which core
|
||||
added after 4.1.0-RC.21; this QBit does not compile against RC.21 or earlier.
|
||||
Artifacts publish to the Gitea Maven registry
|
||||
(`https://git.kof22.com/api/packages/QRun-IO/maven`), which requires an
|
||||
authenticated read.
|
||||
**0.1.0-SNAPSHOT** -- under active development. API may change before 1.0.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -49,7 +35,7 @@ Add the dependency to your project:
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-session-store</artifactId>
|
||||
<version>0.1.0</version>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
@@ -152,53 +138,6 @@ new QSessionStoreQBitProducer()
|
||||
.produce(qInstance);
|
||||
```
|
||||
|
||||
### Shared Redis infrastructure
|
||||
|
||||
Use `SHARED_REDIS` when the application installs `qbit-redis`. Include that optional
|
||||
artifact explicitly in the host and produce its named connection metadata before
|
||||
session-store. The Redis runtime owns TLS, credentials, bounded pools and health.
|
||||
Session-store owns JSON serialization, session expiry and payload limits.
|
||||
|
||||
```java
|
||||
new QSessionStoreQBitProducer()
|
||||
.withConfig(new QSessionStoreQBitConfig()
|
||||
.withProviderType(QSessionStoreProviderType.SHARED_REDIS)
|
||||
.withSharedRedis(new SharedRedisSessionStoreSettings(
|
||||
"sessions", "production", "orders-app", "authentication", 65536))
|
||||
.withDefaultTtl(Duration.ofHours(1))
|
||||
.withEnableSlidingExpiration(true))
|
||||
.produce(qInstance);
|
||||
```
|
||||
|
||||
Settings are connection name, deployment, application, authentication-domain scope,
|
||||
and maximum serialized bytes. Nodes serving the same application/domain must use
|
||||
identical scope; distinct applications/domains must use distinct scope. Resolve it
|
||||
from host configuration, never an unauthenticated request. Redis namespacing encodes
|
||||
each segment independently. This provider does not interpret per-user tenant claims.
|
||||
|
||||
Operations require the owning Redis runtime to be started. The provider never opens
|
||||
or closes a pool and never falls back to another connection. `loadAndTouch` uses
|
||||
atomic GETEX when sliding is enabled, plain GET otherwise; Redis 6.2+ is required.
|
||||
Missing values are cache misses; infrastructure, size and decoding failures throw.
|
||||
Failed remove also throws so logout can retry. TTLs must be 1 ms through 365 days;
|
||||
serialized payload bounds may be configured up to 1 MiB. No cleanup job is produced
|
||||
because Redis owns expiry. Framework Redis and session-store health checks report
|
||||
availability; the provider's status string is only descriptive.
|
||||
|
||||
The existing `REDIS` provider retains its independent legacy pool/configuration.
|
||||
Selecting `SHARED_REDIS` is explicit and uses a new namespace; it does not migrate
|
||||
existing cache entries. A framework runtime service releases its owned registry/context entries in
|
||||
`afterApplicationStop`, after dependent services have stopped; it never closes
|
||||
the Redis pool. Repeated cleanup is harmless and preserves replacement providers.
|
||||
Restart reconstructs registration from metadata after Redis starts. Manual hosts
|
||||
must honor the same runtime callbacks. Shared providers weakly reference their
|
||||
application so an abandoned pre-start registration cannot retain the owner.
|
||||
|
||||
Local acceptance covers real HTTP authentication/logout and Redis outage/retry
|
||||
through the core sample's `redis-acceptance` profile. Coordinated artifact
|
||||
publication and clean host adoption remain release gates. Redis failover topology
|
||||
qualification is future work tracked in [QQQ #1301](https://github.com/QRun-IO/qqq/issues/1301).
|
||||
|
||||
### Custom Provider
|
||||
|
||||
```java
|
||||
@@ -211,18 +150,10 @@ new QSessionStoreQBitProducer()
|
||||
|
||||
## Accessing the Provider
|
||||
|
||||
After producing the QBit, retrieve the provider for its owning application.
|
||||
Within a request, `getProvider()` uses `QContext.getQInstance()`. Startup and
|
||||
background callers should use the explicit overload below. The qbit automatically
|
||||
registers against the QInstance supplied to `produce`; it never installs a
|
||||
JVM-wide fallback for other applications.
|
||||
|
||||
Hosts registering directly with core must use
|
||||
`QSessionStoreRegistry.getInstance(qInstance).register(provider)` outside request
|
||||
context. See [registration migration](docs/SESSION-REGISTRATION.md).
|
||||
After producing the QBit, retrieve the provider via context:
|
||||
|
||||
```java
|
||||
QSessionStoreProviderInterface provider = QSessionStoreQBitContext.getProvider(qInstance);
|
||||
QSessionStoreProviderInterface provider = QSessionStoreQBitContext.getProvider();
|
||||
|
||||
// Store a session
|
||||
provider.store(session.getUuid(), session, Duration.ofHours(1));
|
||||
@@ -241,31 +172,6 @@ provider.remove(sessionUuid);
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
## Releasing
|
||||
|
||||
Versions are computed by the shared `KofTwentyTwo/ci-workflows` Maven workflow
|
||||
(`.gitea/workflows/build.yaml`) from what is pushed; the `revision` property on
|
||||
`develop` stays `0.1.0-SNAPSHOT` and is never hand-edited for a release.
|
||||
|
||||
| Push | Published version | Side effects |
|
||||
|----------------------------|--------------------------|---------------------------------------------------------------|
|
||||
| `develop` | `0.1.0-SNAPSHOT` | none |
|
||||
| branch `release/0.1` | `0.1.0-RC.1`, `RC.2`, .. | commits "Bump version to X [skip ci]" to the branch, tags `vX` |
|
||||
| tag `v0.1.0` | `0.1.0` | pushes the version bump to `main` |
|
||||
| tag `publish-<name>` | `0.1.0-<feature>-<sha>-SNAPSHOT` | none |
|
||||
|
||||
To cut the first release: merge this release preparation into `develop`, create
|
||||
`release/0.1` from `develop` and push it (publishes `0.1.0-RC.1`), qualify the
|
||||
candidate, then tag the bumped `release/0.1` head `v0.1.0` and push the tag
|
||||
(publishes `0.1.0`). Merge `release/0.1` back into `develop`; the workflow then
|
||||
advances `develop` to `0.2.0-SNAPSHOT`. Record the date in `CHANGELOG.md` when
|
||||
tagging. GitHub is a read-only shadow; never push there.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 - See [LICENSE](LICENSE)
|
||||
|
||||
Launcher acceptance covers normal shutdown, failure of a later runtime, and Redis
|
||||
authentication failure before session-runtime startup followed by retry. In that
|
||||
early failure case no Redis pool is published; the weak pre-start registration
|
||||
remains configured but cannot execute session operations until Redis starts.
|
||||
@@ -1,34 +0,0 @@
|
||||
# Application-scoped session store registration
|
||||
|
||||
The candidate core registry resolves the QInstance in QContext. Application
|
||||
startup must register explicitly, even when no request context exists:
|
||||
|
||||
```java
|
||||
QSessionStoreRegistry.getInstance(qInstance).register(provider);
|
||||
```
|
||||
|
||||
Calls to `getInstance()` within a request use that request's application. Without
|
||||
a QInstance, that call accesses a separate standalone registry. An application
|
||||
never inherits that standalone provider. Existing hosts that register outside
|
||||
QContext must migrate to the explicit overload alongside this core candidate.
|
||||
Deploy matching session-store/core versions; this is not a transparent change for
|
||||
old context-free startup integrations.
|
||||
|
||||
Session-store's producer now supplies its target QInstance to both core registration
|
||||
and qbit context. Context getters/setters/clear operate on the current application,
|
||||
with explicit QInstance overloads for metadata production/background operations.
|
||||
Health metadata selects its target application and the health probe uses the
|
||||
QInstance in HealthCheckInput (falling back to current context only if omitted).
|
||||
|
||||
Regression reproduced before the fix: after application A stored a session,
|
||||
unconfigured application B loaded that same session through QSessionStoreHelper.
|
||||
The test now checks independent same-ID sessions, isolation of clearing, and health
|
||||
checks for an explicit target while a different application is on the caller thread.
|
||||
|
||||
Hosts sharing a Redis server must configure separate application/authentication
|
||||
domain namespaces. The `SHARED_REDIS` provider uses those explicit settings and
|
||||
the owning Redis QBit's pool. Its runtime releases owned registrations on stop
|
||||
and reconstructs them on restart. Local acceptance covers launcher lifecycle,
|
||||
HTTP authentication/logout and Redis outage/retry; coordinated published-artifact
|
||||
and clean-host verification remain release gates. See the shared Redis setup in
|
||||
[README](../README.md).
|
||||
@@ -18,24 +18,16 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<properties>
|
||||
<revision>0.3.0-SNAPSHOT</revision>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<argLine></argLine>
|
||||
<jedis.version>7.2.0</jedis.version>
|
||||
<!-- Published QQQ 4.1 core. 4.1.0-RC.22 is the first release-line core that carries
|
||||
QSessionStoreRegistry.getInstance(QInstance) and clearIfSame, which the
|
||||
application-scoped SHARED_REDIS provider requires (RC.21 and earlier do not compile). -->
|
||||
<qqq.version>4.1.0-RC.26</qqq.version>
|
||||
<!-- Published qbit-redis release candidate whose public api (RedisAccess, RedisCache,
|
||||
RedisNamespace) the SHARED_REDIS provider is built against. -->
|
||||
<qbit-redis.version>0.2.0-RC.1</qbit-redis.version>
|
||||
<jedis.version>5.1.0</jedis.version>
|
||||
</properties>
|
||||
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.2.1</version>
|
||||
<version>1.4.0</version>
|
||||
</parent>
|
||||
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
@@ -86,7 +78,7 @@
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-bom-pom</artifactId>
|
||||
<version>${qqq.version}</version>
|
||||
<version>4.1.0-RC.18</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
@@ -99,13 +91,6 @@
|
||||
<artifactId>qqq-backend-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-redis</artifactId>
|
||||
<version>${qbit-redis.version}</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Redis client (optional - for RedisSessionStoreProvider) -->
|
||||
<dependency>
|
||||
<groupId>redis.clients</groupId>
|
||||
@@ -115,22 +100,11 @@
|
||||
</dependency>
|
||||
|
||||
<!-- Test dependencies -->
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-middleware-javalin</artifactId>
|
||||
<version>${qqq.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-rdbms</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-backend-module-postgres</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
@@ -149,7 +123,7 @@
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<version>3.27.7</version>
|
||||
<version>3.27.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
@@ -164,13 +138,13 @@
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<version>1.19.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>testcontainers-junit-jupiter</artifactId>
|
||||
<version>2.0.3</version>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>1.19.3</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
@@ -198,9 +172,6 @@
|
||||
<include>**/*IntegrationTest.java</include>
|
||||
<include>**/*IT.java</include>
|
||||
</includes>
|
||||
<!-- PostgreSQL instants (TableBasedSessionStorePostgresIT) are only correct under a UTC JVM.
|
||||
@{argLine} keeps the JaCoCo agent that prepare-agent puts in the argLine property. -->
|
||||
<argLine>@{argLine} -Duser.timezone=UTC</argLine>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
|
||||
@@ -18,12 +18,9 @@ package com.kingsrook.qbits.sessionstore;
|
||||
|
||||
import com.kingsrook.qbits.sessionstore.providers.InMemorySessionStoreProvider;
|
||||
import com.kingsrook.qbits.sessionstore.providers.RedisSessionStoreProvider;
|
||||
import com.kingsrook.qbits.sessionstore.providers.SharedRedisSessionStoreProvider;
|
||||
import com.kingsrook.qbits.sessionstore.providers.TableBasedSessionStoreProvider;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -36,23 +33,12 @@ public class QSessionStoreProviderFactory
|
||||
** Create a provider instance based on the configured type.
|
||||
***************************************************************************/
|
||||
public static QSessionStoreProviderInterface createProvider(QSessionStoreQBitConfig config) throws QException
|
||||
{
|
||||
return createProvider(QContext.getQInstance(), config);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Bind providers needing managed infrastructure to their owning application.
|
||||
***************************************************************************/
|
||||
public static QSessionStoreProviderInterface createProvider(QInstance instance, QSessionStoreQBitConfig config) throws QException
|
||||
{
|
||||
QSessionStoreProviderInterface provider = switch(config.getProviderType())
|
||||
{
|
||||
case IN_MEMORY -> new InMemorySessionStoreProvider();
|
||||
case TABLE_BASED -> new TableBasedSessionStoreProvider();
|
||||
case REDIS -> new RedisSessionStoreProvider();
|
||||
case SHARED_REDIS -> new SharedRedisSessionStoreProvider(instance);
|
||||
case CUSTOM -> QCodeLoader.getAdHoc(QSessionStoreProviderInterface.class, config.getCustomProviderCodeReference());
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,5 @@ public enum QSessionStoreProviderType
|
||||
IN_MEMORY,
|
||||
TABLE_BASED,
|
||||
REDIS,
|
||||
SHARED_REDIS,
|
||||
CUSTOM
|
||||
}
|
||||
@@ -30,7 +30,6 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
public class QSessionStoreQBitConfig implements QBitConfig
|
||||
{
|
||||
private QSessionStoreProviderType providerType;
|
||||
private SharedRedisSessionStoreSettings sharedRedis;
|
||||
private Duration defaultTtl = Duration.ofHours(1);
|
||||
private Boolean enableSlidingExpiration = true;
|
||||
private String backendName;
|
||||
@@ -78,18 +77,6 @@ public class QSessionStoreQBitConfig implements QBitConfig
|
||||
}
|
||||
}
|
||||
|
||||
if(providerType == QSessionStoreProviderType.SHARED_REDIS)
|
||||
{
|
||||
if(sharedRedis == null)
|
||||
{
|
||||
errors.add("sharedRedis settings are required for SHARED_REDIS provider");
|
||||
}
|
||||
if(defaultTtl == null || defaultTtl.isNegative() || defaultTtl.compareTo(Duration.ofMillis(1)) < 0 || defaultTtl.compareTo(Duration.ofDays(365)) > 0)
|
||||
{
|
||||
errors.add("Shared Redis default TTL must be between one millisecond and 365 days");
|
||||
}
|
||||
}
|
||||
|
||||
if(providerType == QSessionStoreProviderType.CUSTOM)
|
||||
{
|
||||
if(customProviderCodeReference == null)
|
||||
@@ -547,23 +534,4 @@ public class QSessionStoreQBitConfig implements QBitConfig
|
||||
return (this);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Explicit connection and namespace for the shared Redis provider.
|
||||
***************************************************************************/
|
||||
public SharedRedisSessionStoreSettings getSharedRedis()
|
||||
{
|
||||
return sharedRedis;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Select shared infrastructure without retaining Redis credentials here.
|
||||
***************************************************************************/
|
||||
public QSessionStoreQBitConfig withSharedRedis(SharedRedisSessionStoreSettings settings)
|
||||
{
|
||||
sharedRedis = settings;
|
||||
return this;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,144 +16,63 @@
|
||||
package com.kingsrook.qbits.sessionstore;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Application-scoped configuration and provider access for session-store actions.
|
||||
** Static context holder for the Session Store QBit configuration and provider.
|
||||
*******************************************************************************/
|
||||
public class QSessionStoreQBitContext
|
||||
{
|
||||
private static final Map<QInstance, QSessionStoreQBitConfig> CONFIGS = new WeakHashMap<>();
|
||||
private static final Map<QInstance, QSessionStoreProviderInterface> PROVIDERS = new WeakHashMap<>();
|
||||
private static QSessionStoreQBitConfig config;
|
||||
private static QSessionStoreProviderInterface provider;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Get configuration for the current application.
|
||||
** Getter for config
|
||||
***************************************************************************/
|
||||
public static QSessionStoreQBitConfig getConfig()
|
||||
{
|
||||
return getConfig(QContext.getQInstance());
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Get configuration for an explicitly selected application.
|
||||
***************************************************************************/
|
||||
public static synchronized QSessionStoreQBitConfig getConfig(QInstance instance)
|
||||
{
|
||||
return CONFIGS.get(instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Set configuration for the current application.
|
||||
** Setter for config
|
||||
***************************************************************************/
|
||||
public static void setConfig(QSessionStoreQBitConfig config)
|
||||
{
|
||||
setConfig(QContext.getQInstance(), config);
|
||||
QSessionStoreQBitContext.config = config;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Register configuration during application metadata production.
|
||||
***************************************************************************/
|
||||
public static synchronized void setConfig(QInstance instance, QSessionStoreQBitConfig config)
|
||||
{
|
||||
if(config == null)
|
||||
{
|
||||
CONFIGS.remove(instance);
|
||||
}
|
||||
else
|
||||
{
|
||||
CONFIGS.put(instance, config);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Get the current application's provider; never use another application's.
|
||||
** Getter for provider
|
||||
***************************************************************************/
|
||||
public static QSessionStoreProviderInterface getProvider()
|
||||
{
|
||||
return getProvider(QContext.getQInstance());
|
||||
return provider;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Get an application's provider explicitly without changing thread context.
|
||||
***************************************************************************/
|
||||
public static synchronized QSessionStoreProviderInterface getProvider(QInstance instance)
|
||||
{
|
||||
return PROVIDERS.get(instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Set the current application's provider.
|
||||
** Setter for provider
|
||||
***************************************************************************/
|
||||
public static void setProvider(QSessionStoreProviderInterface provider)
|
||||
{
|
||||
setProvider(QContext.getQInstance(), provider);
|
||||
QSessionStoreQBitContext.provider = provider;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Register a provider for its owning application during metadata production.
|
||||
***************************************************************************/
|
||||
public static synchronized void setProvider(QInstance instance, QSessionStoreProviderInterface provider)
|
||||
{
|
||||
if(provider == null)
|
||||
{
|
||||
PROVIDERS.remove(instance);
|
||||
}
|
||||
else
|
||||
{
|
||||
PROVIDERS.put(instance, provider);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Clear only the current application's context.
|
||||
** Clear all context (useful for testing).
|
||||
***************************************************************************/
|
||||
public static void clear()
|
||||
{
|
||||
clear(QContext.getQInstance());
|
||||
config = null;
|
||||
provider = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Clear only the explicitly selected application's context.
|
||||
***************************************************************************/
|
||||
public static synchronized void clear(QInstance instance)
|
||||
{
|
||||
CONFIGS.remove(instance);
|
||||
PROVIDERS.remove(instance);
|
||||
}
|
||||
/***************************************************************************
|
||||
** Clear shutdown registrations only if they still belong to this provider.
|
||||
***************************************************************************/
|
||||
public static synchronized boolean clearIfSame(QInstance instance, QSessionStoreProviderInterface expected)
|
||||
{
|
||||
if(PROVIDERS.get(instance) != expected)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
clear(instance);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -60,13 +60,6 @@ public class QSessionStoreQBitProducer implements QBitProducer
|
||||
throw new QException("SessionStore QBit configuration errors: " + String.join(", ", errors));
|
||||
}
|
||||
|
||||
if(qInstance.getQBits().values().stream().anyMatch(bit -> GROUP_ID.equals(bit.getGroupId())
|
||||
&& ARTIFACT_ID.equals(bit.getArtifactId())))
|
||||
{
|
||||
throw new QException("SessionStore QBit is already installed on this application");
|
||||
}
|
||||
QSessionStoreProviderInterface provider = QSessionStoreProviderFactory.createProvider(qInstance, config);
|
||||
|
||||
///////////////////////////////
|
||||
// Register QBit identity //
|
||||
///////////////////////////////
|
||||
@@ -82,6 +75,7 @@ public class QSessionStoreQBitProducer implements QBitProducer
|
||||
///////////////////////////////
|
||||
// Create provider instance //
|
||||
///////////////////////////////
|
||||
QSessionStoreProviderInterface provider = QSessionStoreProviderFactory.createProvider(config);
|
||||
LOG.info("Created session store provider", logPair("type", config.getProviderType()));
|
||||
|
||||
///////////////////////////////
|
||||
@@ -96,8 +90,7 @@ public class QSessionStoreQBitProducer implements QBitProducer
|
||||
///////////////////////////////
|
||||
// Produce cleanup process //
|
||||
///////////////////////////////
|
||||
if(Boolean.TRUE.equals(config.getEnableCleanupProcess())
|
||||
&& config.getProviderType() != QSessionStoreProviderType.SHARED_REDIS)
|
||||
if(Boolean.TRUE.equals(config.getEnableCleanupProcess()))
|
||||
{
|
||||
CleanExpiredSessionsProcessMetaDataProducer.produce(qInstance, config);
|
||||
LOG.info("Produced cleanup process");
|
||||
@@ -106,17 +99,13 @@ public class QSessionStoreQBitProducer implements QBitProducer
|
||||
///////////////////////////////
|
||||
// Store in context //
|
||||
///////////////////////////////
|
||||
QSessionStoreQBitContext.setConfig(qInstance, config);
|
||||
QSessionStoreQBitContext.setProvider(qInstance, provider);
|
||||
QSessionStoreQBitContext.setConfig(config);
|
||||
QSessionStoreQBitContext.setProvider(provider);
|
||||
|
||||
///////////////////////////////
|
||||
// Register with core registry //
|
||||
///////////////////////////////
|
||||
QSessionStoreRegistry.getInstance(qInstance).register(provider);
|
||||
if(config.getProviderType() == QSessionStoreProviderType.SHARED_REDIS)
|
||||
{
|
||||
qInstance.withRuntimeService(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(SharedRedisSessionStoreRuntimeService.class));
|
||||
}
|
||||
QSessionStoreRegistry.getInstance().register(provider);
|
||||
LOG.info("Registered session store provider with core registry");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
|
||||
}
|
||||
-147
@@ -1,147 +0,0 @@
|
||||
/*
|
||||
* 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.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import com.kingsrook.qbits.redis.api.RedisAccess;
|
||||
import com.kingsrook.qbits.redis.api.RedisNamespace;
|
||||
import com.kingsrook.qbits.sessionstore.providers.SharedRedisSessionStoreProvider;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QRuntimeServiceInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreRegistry;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Own shared session registrations; Redis alone owns the backing connection pool.
|
||||
*******************************************************************************/
|
||||
public class SharedRedisSessionStoreRuntimeService implements QRuntimeServiceInterface
|
||||
{
|
||||
private static final Map<QInstance, Object> OWNERS = new WeakHashMap<>();
|
||||
private final Object token = new Object();
|
||||
private QInstance instance;
|
||||
private QSessionStoreProviderInterface provider;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Framework lifecycle identity.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return "shared-redis-session-store";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Require the named Redis pool first; reconstruct registrations on restart.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public synchronized void start(QInstance owner) throws QException
|
||||
{
|
||||
if(owner == null)
|
||||
{
|
||||
throw new QException("Shared Redis session runtime requires an application");
|
||||
}
|
||||
synchronized(OWNERS)
|
||||
{
|
||||
if(instance != null || OWNERS.containsKey(owner))
|
||||
{
|
||||
throw new QException("Shared Redis session runtime is already started");
|
||||
}
|
||||
OWNERS.put(owner, token);
|
||||
}
|
||||
QSessionStoreProviderInterface candidate = QSessionStoreQBitContext.getProvider(owner);
|
||||
try
|
||||
{
|
||||
if(candidate != null && !(candidate instanceof SharedRedisSessionStoreProvider))
|
||||
{
|
||||
throw new QException("Application has another session provider");
|
||||
}
|
||||
var config = owner.getQBits().values().stream().map(bit -> bit.getConfig())
|
||||
.filter(QSessionStoreQBitConfig.class::isInstance)
|
||||
.map(QSessionStoreQBitConfig.class::cast)
|
||||
.filter(value -> value.getProviderType() == QSessionStoreProviderType.SHARED_REDIS)
|
||||
.findFirst().orElseThrow();
|
||||
var settings = config.getSharedRedis();
|
||||
RedisAccess.cache(owner, settings.connectionName(), new RedisNamespace(settings.deployment(),
|
||||
settings.application(), settings.tenant(), "session-store"), settings.maximumValueBytes());
|
||||
var registered = QSessionStoreRegistry.getInstance(owner).getProvider().orElse(null);
|
||||
if(registered != null && registered != candidate)
|
||||
{
|
||||
throw new QException("Application has another session provider");
|
||||
}
|
||||
if(candidate == null)
|
||||
{
|
||||
candidate = QSessionStoreProviderFactory.createProvider(owner, config);
|
||||
}
|
||||
QSessionStoreQBitContext.setConfig(owner, config);
|
||||
QSessionStoreQBitContext.setProvider(owner, candidate);
|
||||
QSessionStoreRegistry.getInstance(owner).register(candidate);
|
||||
instance = owner;
|
||||
provider = candidate;
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
if(candidate instanceof SharedRedisSessionStoreProvider)
|
||||
{
|
||||
QSessionStoreRegistry.getInstance(owner).clearIfSame(candidate);
|
||||
QSessionStoreQBitContext.clearIfSame(owner, candidate);
|
||||
}
|
||||
synchronized(OWNERS)
|
||||
{
|
||||
OWNERS.remove(owner, token);
|
||||
}
|
||||
throw new QException("Shared Redis session startup failed; check connection startup and provider ownership");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Keep registrations available until dependent HTTP and scheduler work stops.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void stop()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Release only our registrations, with no QContext requirement or pool closure.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public synchronized void afterApplicationStop()
|
||||
{
|
||||
if(instance == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
QSessionStoreRegistry.getInstance(instance).clearIfSame(provider);
|
||||
QSessionStoreQBitContext.clearIfSame(instance, provider);
|
||||
synchronized(OWNERS)
|
||||
{
|
||||
OWNERS.remove(instance, token);
|
||||
}
|
||||
provider = null;
|
||||
instance = null;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
* 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.nio.charset.StandardCharsets;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Explicit shared Redis ownership and namespace; credentials stay in Redis runtime.
|
||||
*******************************************************************************/
|
||||
public record SharedRedisSessionStoreSettings(String connectionName, String deployment, String application, String tenant, Integer maximumValueBytes)
|
||||
{
|
||||
/***************************************************************************
|
||||
** Bound namespace inputs and serialized session payloads before registration.
|
||||
***************************************************************************/
|
||||
public SharedRedisSessionStoreSettings
|
||||
{
|
||||
for(String value : new String[] {connectionName, deployment, application, tenant})
|
||||
{
|
||||
if(value == null || value.isBlank() || value.getBytes(StandardCharsets.UTF_8).length > 256)
|
||||
{
|
||||
throw new IllegalArgumentException("Shared Redis connection and scope must be nonblank and at most 256 bytes");
|
||||
}
|
||||
}
|
||||
if(maximumValueBytes == null || maximumValueBytes < 1 || maximumValueBytes > 1048576)
|
||||
{
|
||||
throw new IllegalArgumentException("Shared Redis session size must be between 1 and 1048576 bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
/*
|
||||
* 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<String> 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<String> naming)
|
||||
{
|
||||
return naming.apply(config.applyPrefix(config.getTableName()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The physical column names (exact metadata names).
|
||||
***************************************************************************/
|
||||
public static List<String> physicalColumnNames(QSessionStoreQBitConfig config)
|
||||
{
|
||||
return physicalColumnNames(config, EXACT);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The physical column names under a naming rule.
|
||||
***************************************************************************/
|
||||
public static List<String> physicalColumnNames(QSessionStoreQBitConfig config, UnaryOperator<String> naming)
|
||||
{
|
||||
List<String> 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<String> 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<String> 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" : "");
|
||||
}
|
||||
|
||||
}
|
||||
+2
-14
@@ -22,19 +22,12 @@ 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
|
||||
{
|
||||
@@ -49,11 +42,6 @@ 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")
|
||||
@@ -61,10 +49,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 Key (digest)")
|
||||
.withLabel("Session UUID")
|
||||
.withIsRequired(true)
|
||||
.withIsEditable(false)
|
||||
.withMaxLength(64))
|
||||
.withMaxLength(36))
|
||||
.withField(new QFieldMetaData(StoredSession.FIELD_USER_ID, QFieldType.STRING)
|
||||
.withLabel("User ID")
|
||||
.withIsEditable(false))
|
||||
|
||||
+9
-28
@@ -25,7 +25,6 @@ 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;
|
||||
@@ -58,24 +57,6 @@ 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.
|
||||
***************************************************************************/
|
||||
@@ -118,8 +99,8 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
|
||||
{
|
||||
Duration effectiveTtl = ttl != null ? ttl : defaultTtl;
|
||||
Instant expiresAt = Instant.now().plus(effectiveTtl);
|
||||
cache.put(sessionUuid, new CachedSession(copy(session), expiresAt));
|
||||
LOG.debug("Stored session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("expiresAt", expiresAt));
|
||||
cache.put(sessionUuid, new CachedSession(session, expiresAt));
|
||||
LOG.debug("Stored session", logPair("sessionUuid", sessionUuid), logPair("expiresAt", expiresAt));
|
||||
}
|
||||
|
||||
|
||||
@@ -139,11 +120,11 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
|
||||
if(cached.isExpired())
|
||||
{
|
||||
cache.remove(sessionUuid);
|
||||
LOG.debug("Session expired", logPair("session", SessionCodec.logTag(sessionUuid)));
|
||||
LOG.debug("Session expired", logPair("sessionUuid", sessionUuid));
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(copy(cached.session()));
|
||||
return Optional.of(cached.session());
|
||||
}
|
||||
|
||||
|
||||
@@ -155,7 +136,7 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
|
||||
public void remove(String sessionUuid)
|
||||
{
|
||||
cache.remove(sessionUuid);
|
||||
LOG.debug("Removed session", logPair("session", SessionCodec.logTag(sessionUuid)));
|
||||
LOG.debug("Removed session", logPair("sessionUuid", sessionUuid));
|
||||
}
|
||||
|
||||
|
||||
@@ -171,7 +152,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("session", SessionCodec.logTag(sessionUuid)), logPair("newExpiresAt", newExpiresAt));
|
||||
LOG.debug("Touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +184,7 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
|
||||
if(cached.isExpired())
|
||||
{
|
||||
cache.remove(sessionUuid);
|
||||
LOG.debug("Session expired", logPair("session", SessionCodec.logTag(sessionUuid)));
|
||||
LOG.debug("Session expired", logPair("sessionUuid", sessionUuid));
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -212,9 +193,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("session", SessionCodec.logTag(sessionUuid)), logPair("newExpiresAt", newExpiresAt));
|
||||
LOG.debug("Loaded and touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt));
|
||||
|
||||
return Optional.of(copy(cached.session()));
|
||||
return Optional.of(cached.session());
|
||||
}
|
||||
|
||||
|
||||
|
||||
+14
-14
@@ -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 + SessionCodec.key(sessionUuid);
|
||||
return keyPrefix + sessionUuid;
|
||||
}
|
||||
|
||||
|
||||
@@ -97,14 +97,14 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
|
||||
{
|
||||
Duration effectiveTtl = ttl != null ? ttl : defaultTtl;
|
||||
String key = buildKey(sessionUuid);
|
||||
String sessionJson = SessionCodec.encode(sessionUuid, session);
|
||||
String sessionJson = JsonUtils.toJson(session);
|
||||
|
||||
jedis.setex(key, effectiveTtl.toSeconds(), sessionJson);
|
||||
LOG.debug("Stored session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("ttlSeconds", effectiveTtl.toSeconds()));
|
||||
LOG.debug("Stored session in Redis", logPair("sessionUuid", sessionUuid), logPair("ttlSeconds", effectiveTtl.toSeconds()));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Failed to store session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
|
||||
LOG.warn("Failed to store session in Redis", logPair("sessionUuid", sessionUuid), e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,12 +126,12 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
QSession session = SessionCodec.decode(sessionUuid, sessionJson);
|
||||
QSession session = JsonUtils.toObject(sessionJson, QSession.class);
|
||||
return Optional.of(session);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Failed to load session from Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
|
||||
LOG.warn("Failed to load session from Redis", logPair("sessionUuid", 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("session", SessionCodec.logTag(sessionUuid)));
|
||||
LOG.debug("Removed session from Redis", logPair("sessionUuid", sessionUuid));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Failed to remove session from Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
|
||||
LOG.warn("Failed to remove session from Redis", logPair("sessionUuid", 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("session", SessionCodec.logTag(sessionUuid)), logPair("newTtlSeconds", defaultTtl.toSeconds()));
|
||||
LOG.debug("Touched session in Redis", logPair("sessionUuid", sessionUuid), logPair("newTtlSeconds", defaultTtl.toSeconds()));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Failed to touch session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
|
||||
LOG.warn("Failed to touch session in Redis", logPair("sessionUuid", sessionUuid), e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,13 +229,13 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
QSession session = SessionCodec.decode(sessionUuid, sessionJson);
|
||||
LOG.debug("Loaded and touched session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newTtlSeconds", defaultTtl.toSeconds()));
|
||||
QSession session = JsonUtils.toObject(sessionJson, QSession.class);
|
||||
LOG.debug("Loaded and touched session in Redis", logPair("sessionUuid", sessionUuid), logPair("newTtlSeconds", defaultTtl.toSeconds()));
|
||||
return Optional.of(session);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Failed to load and touch session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
|
||||
LOG.warn("Failed to load and touch session in Redis", logPair("sessionUuid", sessionUuid), e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
-235
@@ -1,235 +0,0 @@
|
||||
/*
|
||||
* 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.io.IOException;
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.redis.api.RedisAccess;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Session rules over application-owned Redis infrastructure. Never owns a pool.
|
||||
*******************************************************************************/
|
||||
public final class SharedRedisSessionStoreProvider implements QSessionStoreProviderInterface
|
||||
{
|
||||
private final WeakReference<QInstance> instance;
|
||||
private SharedRedisSessionStoreSettings settings;
|
||||
private RedisNamespace namespace;
|
||||
private Duration defaultTtl;
|
||||
private boolean sliding;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Bind to the explicit owner, independent of the request thread's context.
|
||||
***************************************************************************/
|
||||
public SharedRedisSessionStoreProvider(QInstance instance)
|
||||
{
|
||||
this.instance = new WeakReference<>(Objects.requireNonNull(instance, "Shared Redis requires an owning QInstance"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Do not retain an abandoned application through a static registry value.
|
||||
***************************************************************************/
|
||||
private QInstance owner()
|
||||
{
|
||||
QInstance owner = instance.get();
|
||||
if(owner == null)
|
||||
{
|
||||
throw new IllegalStateException("Owning application is no longer available");
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Validate metadata without creating infrastructure; install Redis first.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void configure(QSessionStoreQBitConfig config)
|
||||
{
|
||||
if(settings != null)
|
||||
{
|
||||
throw new IllegalStateException("Shared Redis session provider is already configured");
|
||||
}
|
||||
var configured = Objects.requireNonNull(config.getSharedRedis());
|
||||
Duration ttl = validTtl(config.getDefaultTtl());
|
||||
if(!RedisAccess.isConfigured(owner(), configured.connectionName()))
|
||||
{
|
||||
throw new IllegalArgumentException("Shared Redis session connection is not configured on the owning application");
|
||||
}
|
||||
namespace = new RedisNamespace(configured.deployment(), configured.application(), configured.tenant(), "session-store");
|
||||
defaultTtl = ttl;
|
||||
sliding = Boolean.TRUE.equals(config.getEnableSlidingExpiration());
|
||||
settings = configured;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Resolve only started owner infrastructure; never allocate a fallback pool.
|
||||
***************************************************************************/
|
||||
private RedisCache cache()
|
||||
{
|
||||
if(settings == null)
|
||||
{
|
||||
throw new IllegalStateException("Shared Redis session provider is not configured");
|
||||
}
|
||||
return RedisAccess.cache(owner(), settings.connectionName(), namespace, settings.maximumValueBytes());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Serialize within session-store and atomically set value plus expiry.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void store(String id, QSession session, Duration ttl)
|
||||
{
|
||||
cache().put(SessionCodec.key(id), SessionCodec.encode(id, Objects.requireNonNull(session)).getBytes(StandardCharsets.UTF_8), validTtl(ttl == null ? defaultTtl : ttl));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Absence is a cache miss; infrastructure and decoding errors propagate.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Optional<QSession> load(String id)
|
||||
{
|
||||
return decode(id, cache().get(SessionCodec.key(id)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Failed eviction must propagate so logout can retry rather than claim success.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void remove(String id)
|
||||
{
|
||||
cache().remove(SessionCodec.key(id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Explicit renewal never recreates an expired or deleted session.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void touch(String id)
|
||||
{
|
||||
cache().touch(SessionCodec.key(id), defaultTtl);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Respect sliding-expiration configuration with one atomic read/renew command.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Optional<QSession> loadAndTouch(String id)
|
||||
{
|
||||
String key = SessionCodec.key(id);
|
||||
return decode(id, sliding ? cache().getAndTouch(key, defaultTtl) : cache().get(key));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Bound decoded payloads and never include session contents in error messages.
|
||||
***************************************************************************/
|
||||
private Optional<QSession> decode(String id, Optional<byte[]> value)
|
||||
{
|
||||
return value.map(bytes ->
|
||||
{
|
||||
if(bytes.length > settings.maximumValueBytes())
|
||||
{
|
||||
throw new IllegalStateException("Stored session exceeds its configured size limit");
|
||||
}
|
||||
try
|
||||
{
|
||||
return Objects.requireNonNull(SessionCodec.decode(id, new String(bytes, StandardCharsets.UTF_8)));
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
throw new IllegalStateException("Stored session could not be decoded");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Bound TTL arithmetic and prevent immortal or truncated sub-millisecond data.
|
||||
***************************************************************************/
|
||||
private static Duration validTtl(Duration ttl)
|
||||
{
|
||||
if(ttl == null || ttl.compareTo(Duration.ofMillis(1)) < 0 || ttl.compareTo(Duration.ofDays(365)) > 0)
|
||||
{
|
||||
throw new IllegalArgumentException("Session TTL must be between one millisecond and 365 days");
|
||||
}
|
||||
return ttl;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Report the configured session lifetime.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Duration getDefaultTtl()
|
||||
{
|
||||
return defaultTtl;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Redis owns expiration; no scans or wildcard deletes are needed.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void cleanExpired()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Health truth belongs to the shared runtime's framework health checks.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String status()
|
||||
{
|
||||
return "Shared Redis: health is reported by the Redis runtime";
|
||||
}
|
||||
}
|
||||
+153
-192
@@ -16,26 +16,23 @@
|
||||
package com.kingsrook.qbits.sessionstore.providers;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.Serializable;
|
||||
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;
|
||||
@@ -43,22 +40,19 @@ 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
|
||||
{
|
||||
@@ -67,7 +61,6 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
|
||||
private QSessionStoreQBitConfig config;
|
||||
private String tableName;
|
||||
private Duration defaultTtl;
|
||||
private boolean sliding;
|
||||
|
||||
|
||||
|
||||
@@ -80,196 +73,85 @@ 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, replacing any existing copy.
|
||||
** Store a session with the given TTL.
|
||||
***************************************************************************/
|
||||
@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 stored = SessionCodec.encode(sessionUuid, session);
|
||||
String userId = session.getUser() != null ? session.getUser().getIdReference() : null;
|
||||
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);
|
||||
|
||||
runWithSystemSession(() ->
|
||||
{
|
||||
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);
|
||||
}
|
||||
InsertInput insertInput = new InsertInput();
|
||||
insertInput.setTableName(tableName);
|
||||
insertInput.setRecords(List.of(record));
|
||||
new InsertAction().execute(insertInput);
|
||||
return null;
|
||||
});
|
||||
|
||||
LOG.debug("Stored session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("expiresAt", expiresAt));
|
||||
LOG.debug("Stored session", logPair("sessionUuid", sessionUuid), logPair("expiresAt", expiresAt));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Failed to store session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
|
||||
remove(sessionUuid);
|
||||
LOG.warn("Failed to store session", logPair("sessionUuid", sessionUuid), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** 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<QRecord> 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.
|
||||
** Load a session by UUID.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Optional<QSession> load(String sessionUuid)
|
||||
{
|
||||
return read(sessionUuid, false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Load a session and, when sliding expiration is on, renew it.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Optional<QSession> loadAndTouch(String sessionUuid)
|
||||
{
|
||||
return read(sessionUuid, sliding);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Shared read: expired, unreadable and mismatched rows are removed and
|
||||
** reported as a miss.
|
||||
***************************************************************************/
|
||||
private Optional<QSession> read(String sessionUuid, boolean touch)
|
||||
{
|
||||
String key = SessionCodec.key(sessionUuid);
|
||||
try
|
||||
{
|
||||
return runWithSystemSession(() ->
|
||||
QRecord record = runWithSystemSession(() ->
|
||||
{
|
||||
QRecord record = get(key);
|
||||
if(record == null)
|
||||
{
|
||||
return Optional.<QSession>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.<QSession>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.<QSession>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);
|
||||
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();
|
||||
});
|
||||
|
||||
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("session", SessionCodec.logTag(sessionUuid)), e);
|
||||
LOG.warn("Failed to load session", logPair("sessionUuid", sessionUuid), e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -277,7 +159,7 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Remove a session by id.
|
||||
** Remove a session by UUID.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void remove(String sessionUuid)
|
||||
@@ -286,35 +168,26 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
|
||||
{
|
||||
runWithSystemSession(() ->
|
||||
{
|
||||
deleteByKey(SessionCodec.key(sessionUuid));
|
||||
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);
|
||||
return null;
|
||||
});
|
||||
LOG.debug("Removed session", logPair("session", SessionCodec.logTag(sessionUuid)));
|
||||
|
||||
LOG.debug("Removed session", logPair("sessionUuid", sessionUuid));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Failed to remove session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
|
||||
LOG.warn("Failed to remove session", logPair("sessionUuid", sessionUuid), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** 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.
|
||||
** Touch a session to reset its TTL.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void touch(String sessionUuid)
|
||||
@@ -325,24 +198,33 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
|
||||
|
||||
runWithSystemSession(() ->
|
||||
{
|
||||
QRecord record = get(SessionCodec.key(sessionUuid));
|
||||
if(record != null)
|
||||
/////////////////////////////////////////////////////////////////
|
||||
// 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 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(new QRecord()
|
||||
.withValue("id", record.getValue("id"))
|
||||
.withValue(StoredSession.FIELD_EXPIRES_AT, newExpiresAt)));
|
||||
updateInput.setRecords(List.of(updateRecord));
|
||||
new UpdateAction().execute(updateInput);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
LOG.debug("Touched session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newExpiresAt", newExpiresAt));
|
||||
LOG.debug("Touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Failed to touch session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
|
||||
LOG.warn("Failed to touch session", logPair("sessionUuid", sessionUuid), e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +241,83 @@ 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<QSession> 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.<QSession>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.
|
||||
***************************************************************************/
|
||||
@@ -397,9 +356,11 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
|
||||
{
|
||||
long count = runWithSystemSession(() ->
|
||||
{
|
||||
CountInput countInput = new CountInput();
|
||||
countInput.setTableName(tableName);
|
||||
return new CountAction().execute(countInput).getCount().longValue();
|
||||
QueryInput queryInput = new QueryInput();
|
||||
queryInput.setTableName(tableName);
|
||||
queryInput.setFilter(new QQueryFilter());
|
||||
QueryOutput queryOutput = new QueryAction().execute(queryInput);
|
||||
return (long) queryOutput.getRecords().size();
|
||||
});
|
||||
return "TableBased: " + count + " sessions in " + tableName;
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
/*
|
||||
* 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.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Prove memory consumers can start without either optional Redis artifact.
|
||||
*******************************************************************************/
|
||||
class OptionalRedisSessionConsumerTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** Platform parent prevents optional classes leaking from the Maven runner.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void memoryConsumerWithoutRedis() throws Exception
|
||||
{
|
||||
String classpath = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path"));
|
||||
URL[] urls = Arrays.stream(classpath.split(java.io.File.pathSeparator))
|
||||
.filter(entry -> !entry.replace('\\', '/').contains("/qbit-redis/")
|
||||
&& !Path.of(entry).getFileName().toString().startsWith("jedis-"))
|
||||
.map(entry ->
|
||||
{
|
||||
try
|
||||
{
|
||||
return Path.of(entry).toUri().toURL();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}).toArray(URL[]::new);
|
||||
ClassLoader previous = Thread.currentThread().getContextClassLoader();
|
||||
try(var loader = new URLClassLoader(urls, ClassLoader.getPlatformClassLoader()))
|
||||
{
|
||||
assertThrows(ClassNotFoundException.class,
|
||||
() -> loader.loadClass("com.kingsrook.qbits.redis.api.RedisAccess"));
|
||||
assertThrows(ClassNotFoundException.class,
|
||||
() -> loader.loadClass("redis.clients.jedis.RedisClient"));
|
||||
Thread.currentThread().setContextClassLoader(loader);
|
||||
loader.loadClass(Probe.class.getName()).getMethod("exercise").invoke(null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Thread.currentThread().setContextClassLoader(previous);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Loaded with session-store and core but without Redis classes.
|
||||
***************************************************************************/
|
||||
public static class Probe
|
||||
{
|
||||
/************************************************************************
|
||||
** Producer, registry and in-memory storage remain usable independently.
|
||||
************************************************************************/
|
||||
public static void exercise() throws Exception
|
||||
{
|
||||
var instance = new com.kingsrook.qqq.backend.core.model.metadata.QInstance();
|
||||
try
|
||||
{
|
||||
new QSessionStoreQBitProducer().withConfig(new QSessionStoreQBitConfig()
|
||||
.withProviderType(QSessionStoreProviderType.IN_MEMORY).withEnableCleanupProcess(false))
|
||||
.produce(instance, "session-store");
|
||||
var provider = QSessionStoreQBitContext.getProvider(instance);
|
||||
provider.store("id", new com.kingsrook.qqq.backend.core.model.session.QSession()
|
||||
.withValue("marker", "memory"), java.time.Duration.ofSeconds(30));
|
||||
assertEquals("memory", provider.load("id").orElseThrow().getValue("marker"));
|
||||
provider.remove("id");
|
||||
assertTrue(provider.load("id").isEmpty());
|
||||
}
|
||||
finally
|
||||
{
|
||||
QSessionStoreQBitContext.clear(instance);
|
||||
com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreRegistry.getInstance(instance).clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ package com.kingsrook.qbits.sessionstore;
|
||||
|
||||
import java.time.Duration;
|
||||
import com.kingsrook.qbits.sessionstore.providers.InMemorySessionStoreProvider;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreRegistry;
|
||||
@@ -64,19 +63,14 @@ class QSessionStoreQBitProducerTest
|
||||
assertThat(qInstance.getQBits()).hasSize(1);
|
||||
|
||||
// Verify provider was created and stored in context
|
||||
assertThat(QSessionStoreQBitContext.getProvider(qInstance)).isInstanceOf(InMemorySessionStoreProvider.class);
|
||||
assertThat(QSessionStoreQBitContext.getConfig(qInstance)).isSameAs(config);
|
||||
assertThat(QSessionStoreQBitContext.getProvider()).isInstanceOf(InMemorySessionStoreProvider.class);
|
||||
assertThat(QSessionStoreQBitContext.getConfig()).isSameAs(config);
|
||||
|
||||
// Verify cleanup process was created
|
||||
assertThat(qInstance.getProcess("cleanExpiredSessions")).isNotNull();
|
||||
|
||||
// Verify registered with core registry
|
||||
assertThat(QSessionStoreRegistry.getInstance(qInstance).getProvider()).isPresent();
|
||||
var originalProvider = QSessionStoreQBitContext.getProvider(qInstance);
|
||||
assertThatThrownBy(() -> new QSessionStoreQBitProducer().withConfig(config).produce(qInstance, "duplicate"))
|
||||
.isInstanceOf(QException.class).hasMessageContaining("already installed");
|
||||
assertThat(QSessionStoreQBitContext.getProvider(qInstance)).isSameAs(originalProvider);
|
||||
assertThat(qInstance.getQBits()).hasSize(1);
|
||||
assertThat(QSessionStoreRegistry.getInstance().getProvider()).isPresent();
|
||||
}
|
||||
|
||||
|
||||
@@ -149,41 +143,4 @@ class QSessionStoreQBitProducerTest
|
||||
assertThat(QSessionStoreQBitProducer.VERSION).isEqualTo("0.1.0");
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Metadata production must target the supplied instance despite caller context.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void isolatesApplicationProviders() throws QException
|
||||
{
|
||||
QInstance original = QContext.getQInstance();
|
||||
QInstance first = new QInstance();
|
||||
QInstance second = new QInstance();
|
||||
var firstConfig = new QSessionStoreQBitConfig().withProviderType(QSessionStoreProviderType.IN_MEMORY);
|
||||
var secondConfig = new QSessionStoreQBitConfig().withProviderType(QSessionStoreProviderType.IN_MEMORY);
|
||||
try
|
||||
{
|
||||
QContext.setQInstance(second);
|
||||
new QSessionStoreQBitProducer().withConfig(firstConfig).produce(first, "first");
|
||||
assertThat(QSessionStoreQBitContext.getProvider()).isNull();
|
||||
assertThat(QSessionStoreRegistry.getInstance().getProvider()).isEmpty();
|
||||
new QSessionStoreQBitProducer().withConfig(secondConfig).produce(second, "second");
|
||||
var secondProvider = QSessionStoreQBitContext.getProvider();
|
||||
assertThat(QSessionStoreQBitContext.getConfig()).isSameAs(secondConfig);
|
||||
QContext.setQInstance(first);
|
||||
assertThat(QSessionStoreQBitContext.getConfig()).isSameAs(firstConfig);
|
||||
assertThat(QSessionStoreQBitContext.getProvider()).isNotSameAs(secondProvider);
|
||||
assertThat(QSessionStoreRegistry.getInstance().getProvider()).contains(QSessionStoreQBitContext.getProvider());
|
||||
QSessionStoreQBitContext.clear();
|
||||
QContext.setQInstance(second);
|
||||
assertThat(QSessionStoreQBitContext.getProvider()).isSameAs(secondProvider);
|
||||
}
|
||||
finally
|
||||
{
|
||||
QSessionStoreQBitContext.clear(first);
|
||||
QSessionStoreQBitContext.clear(second);
|
||||
QSessionStoreRegistry.getInstance(first).clear();
|
||||
QSessionStoreRegistry.getInstance(second).clear();
|
||||
QContext.setQInstance(original);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
/*
|
||||
* 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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/*
|
||||
* 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, " "));
|
||||
}
|
||||
|
||||
}
|
||||
+32
-5
@@ -27,33 +27,57 @@ import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.DockerClientFactory;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Integration tests for RedisSessionStoreProvider using Testcontainers.
|
||||
** These tests require Docker; unavailable infrastructure fails verification.
|
||||
** These tests require Docker to be running. If Docker is not available,
|
||||
** the tests will be skipped.
|
||||
*******************************************************************************/
|
||||
class RedisSessionStoreProviderIT
|
||||
{
|
||||
private static final int REDIS_PORT = 6379;
|
||||
|
||||
private static boolean dockerAvailable = false;
|
||||
private static GenericContainer<?> redis;
|
||||
private RedisSessionStoreProvider provider;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Start required Redis infrastructure; failures must fail verification.
|
||||
** Check Docker availability and start container if available.
|
||||
***************************************************************************/
|
||||
@BeforeAll
|
||||
static void startContainer()
|
||||
{
|
||||
redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine"))
|
||||
.withExposedPorts(REDIS_PORT);
|
||||
redis.start();
|
||||
try
|
||||
{
|
||||
dockerAvailable = DockerClientFactory.instance().isDockerAvailable();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
dockerAvailable = false;
|
||||
}
|
||||
|
||||
if(dockerAvailable)
|
||||
{
|
||||
try
|
||||
{
|
||||
redis = new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
|
||||
.withExposedPorts(REDIS_PORT);
|
||||
redis.start();
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
dockerAvailable = false;
|
||||
redis = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +99,9 @@ class RedisSessionStoreProviderIT
|
||||
@BeforeEach
|
||||
void setUp()
|
||||
{
|
||||
assumeTrue(dockerAvailable && redis != null && redis.isRunning(),
|
||||
"Docker is not available or Redis container is not running - skipping test");
|
||||
|
||||
provider = new RedisSessionStoreProvider();
|
||||
|
||||
QSessionStoreQBitConfig config = new QSessionStoreQBitConfig()
|
||||
|
||||
-551
@@ -1,551 +0,0 @@
|
||||
/*
|
||||
* 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.time.Duration;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.redis.RedisConnectionConfig;
|
||||
import com.kingsrook.qbits.redis.RedisQBitConfig;
|
||||
import com.kingsrook.qbits.redis.RedisQBitProducer;
|
||||
import com.kingsrook.qbits.redis.RedisRuntimeService;
|
||||
import com.kingsrook.qbits.redis.api.RedisAccess;
|
||||
import com.kingsrook.qbits.redis.api.RedisNamespace;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real shared infrastructure proves namespace, lifecycle and expiry ownership.
|
||||
*******************************************************************************/
|
||||
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.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void sharedPoolsIsolationExpiryAndFailure() throws Exception
|
||||
{
|
||||
QInstance original = QContext.getQInstance();
|
||||
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
QInstance first = host(redis, "app-a", true);
|
||||
QInstance second = host(redis, "app-a", false);
|
||||
QInstance other = host(redis, "app-b", true);
|
||||
var a = QSessionStoreQBitContext.getProvider(first);
|
||||
var b = QSessionStoreQBitContext.getProvider(second);
|
||||
var c = QSessionStoreQBitContext.getProvider(other);
|
||||
var firstRuntime = new RedisRuntimeService();
|
||||
var secondRuntime = new RedisRuntimeService();
|
||||
var otherRuntime = new RedisRuntimeService();
|
||||
try
|
||||
{
|
||||
assertThrows(IllegalStateException.class, () -> a.load("session"));
|
||||
firstRuntime.start(first);
|
||||
secondRuntime.start(second);
|
||||
otherRuntime.start(other);
|
||||
QContext.setQInstance(other);
|
||||
a.store("session", new QSession().withValue("owner", "app-a"), null);
|
||||
assertEquals("app-a", b.load("session").orElseThrow().getValue("owner"));
|
||||
assertTrue(c.load("session").isEmpty());
|
||||
c.store("session", new QSession().withValue("owner", "app-b"), null);
|
||||
a.remove("session");
|
||||
assertTrue(b.load("session").isEmpty());
|
||||
assertEquals("app-b", c.load("session").orElseThrow().getValue("owner"));
|
||||
a.touch("session");
|
||||
assertTrue(a.loadAndTouch("session").isEmpty());
|
||||
|
||||
var scope = new RedisNamespace("test", "app-a", "authentication", "session-store");
|
||||
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(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(SessionCodec.key("fixed"))) <= 200);
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos();
|
||||
while(b.load("fixed").isPresent() && System.nanoTime() < deadline)
|
||||
{
|
||||
Thread.sleep(10);
|
||||
}
|
||||
assertTrue(b.load("fixed").isEmpty());
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> a.store("bad", new QSession(), Duration.ZERO));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> a.store("large", new QSession().withValue("large", "x".repeat(70000)), null));
|
||||
RedisAccess.cache(first, "sessions", scope, 65536)
|
||||
.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());
|
||||
a.cleanExpired();
|
||||
assertTrue(a.status().contains("health"));
|
||||
assertEquals(Duration.ofSeconds(30), a.getDefaultTtl());
|
||||
|
||||
firstRuntime.stop();
|
||||
firstRuntime.afterApplicationStop();
|
||||
assertThrows(IllegalStateException.class, () -> a.load("sliding"));
|
||||
assertTrue(b.load("sliding").isPresent());
|
||||
firstRuntime.start(first);
|
||||
assertTrue(a.load("sliding").isPresent());
|
||||
redis.stop();
|
||||
assertThrows(RuntimeException.class, () -> a.remove("sliding"));
|
||||
assertThrows(RuntimeException.class, () -> a.load("sliding"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
for(var runtime : List.of(firstRuntime, secondRuntime, otherRuntime))
|
||||
{
|
||||
runtime.stop();
|
||||
runtime.afterApplicationStop();
|
||||
}
|
||||
for(var instance : List.of(first, second, other))
|
||||
{
|
||||
QSessionStoreQBitContext.clear(instance);
|
||||
QSessionStoreRegistry.getInstance(instance).clear();
|
||||
}
|
||||
QContext.setQInstance(original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Startup failures and shutdown must release only the owning registrations.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void registrationLifecycle() throws Exception
|
||||
{
|
||||
QInstance original = QContext.getQInstance();
|
||||
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
QInstance first = host(redis, "app-a", true);
|
||||
QInstance second = host(redis, "app-b", true);
|
||||
var firstPool = new RedisRuntimeService();
|
||||
var secondPool = new RedisRuntimeService();
|
||||
var firstService = new SharedRedisSessionStoreRuntimeService();
|
||||
var secondService = new SharedRedisSessionStoreRuntimeService();
|
||||
try
|
||||
{
|
||||
assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
|
||||
() -> firstService.start(first));
|
||||
assertNull(QSessionStoreQBitContext.getProvider(first));
|
||||
assertTrue(QSessionStoreRegistry.getInstance(first).getProvider().isEmpty());
|
||||
firstService.afterApplicationStop();
|
||||
firstPool.start(first);
|
||||
secondPool.start(second);
|
||||
firstService.start(first);
|
||||
secondService.start(second);
|
||||
var firstProvider = QSessionStoreQBitContext.getProvider(first);
|
||||
var secondProvider = QSessionStoreQBitContext.getProvider(second);
|
||||
assertNotNull(firstProvider);
|
||||
assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
|
||||
() -> firstService.start(first));
|
||||
var duplicate = new SharedRedisSessionStoreRuntimeService();
|
||||
assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
|
||||
() -> duplicate.start(first));
|
||||
duplicate.afterApplicationStop();
|
||||
assertSame(firstProvider, QSessionStoreQBitContext.getProvider(first));
|
||||
QContext.setQInstance(second);
|
||||
firstService.stop();
|
||||
assertSame(firstProvider, QSessionStoreQBitContext.getProvider(first));
|
||||
firstService.afterApplicationStop();
|
||||
firstService.afterApplicationStop();
|
||||
assertNull(QSessionStoreQBitContext.getProvider(first));
|
||||
assertNull(QSessionStoreQBitContext.getConfig(first));
|
||||
assertTrue(QSessionStoreRegistry.getInstance(first).getProvider().isEmpty());
|
||||
assertSame(secondProvider, QSessionStoreQBitContext.getProvider(second));
|
||||
assertEquals("PONG", RedisRuntimeService.connection(first, "sessions").client().ping());
|
||||
firstService.start(first);
|
||||
assertNotSame(firstProvider, QSessionStoreQBitContext.getProvider(first));
|
||||
var replacement = new InMemorySessionStoreProvider();
|
||||
QSessionStoreRegistry.getInstance(first).register(replacement);
|
||||
QSessionStoreQBitContext.setProvider(first, replacement);
|
||||
firstService.afterApplicationStop();
|
||||
assertSame(replacement, QSessionStoreQBitContext.getProvider(first));
|
||||
assertSame(replacement, QSessionStoreRegistry.getInstance(first).getProvider().orElseThrow());
|
||||
assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
|
||||
() -> firstService.start(first));
|
||||
assertSame(replacement, QSessionStoreQBitContext.getProvider(first));
|
||||
assertSame(replacement, QSessionStoreRegistry.getInstance(first).getProvider().orElseThrow());
|
||||
}
|
||||
finally
|
||||
{
|
||||
firstService.afterApplicationStop();
|
||||
secondService.afterApplicationStop();
|
||||
firstPool.afterApplicationStop();
|
||||
secondPool.afterApplicationStop();
|
||||
for(var instance : List.of(first, second))
|
||||
{
|
||||
QSessionStoreQBitContext.clear(instance);
|
||||
QSessionStoreRegistry.getInstance(instance).clear();
|
||||
}
|
||||
QContext.setQInstance(original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Exercise real launcher callbacks and rollback after a later service fails.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void applicationLauncherOwnsNormalAndFailedShutdown() throws Exception
|
||||
{
|
||||
var original = QContext.capture();
|
||||
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
QInstance instance = host(redis, "launcher", true);
|
||||
var http = new java.util.concurrent.atomic.AtomicReference<org.eclipse.jetty.server.Server>();
|
||||
var launcher = com.kingsrook.qqq.middleware.javalin.QApplicationLauncher.run(
|
||||
application(instance), launcherConfig(http));
|
||||
var pool = RedisRuntimeService.connection(instance, "sessions");
|
||||
try
|
||||
{
|
||||
assertTrue(http.get().isStarted());
|
||||
assertTrue(launcher.getStartedServiceNames().containsAll(List.of("redis", "shared-redis-session-store")));
|
||||
var provider = QSessionStoreQBitContext.getProvider(instance);
|
||||
provider.store("launcher-session", new QSession().withValue("owner", "launcher"), null);
|
||||
assertEquals("launcher", provider.load("launcher-session").orElseThrow().getValue("owner"));
|
||||
QContext.clear();
|
||||
}
|
||||
finally
|
||||
{
|
||||
launcher.stop();
|
||||
}
|
||||
launcher.stop();
|
||||
assertTrue(http.get().isStopped());
|
||||
assertNull(QSessionStoreQBitContext.getProvider(instance));
|
||||
assertNull(QSessionStoreQBitContext.getConfig(instance));
|
||||
assertTrue(QSessionStoreRegistry.getInstance(instance).getProvider().isEmpty());
|
||||
assertThrows(IllegalStateException.class, () -> RedisRuntimeService.connection(instance, "sessions"));
|
||||
assertThrows(RuntimeException.class, () -> pool.client().ping());
|
||||
|
||||
QInstance failing = host(redis, "failed-launcher", true);
|
||||
failing.withRuntimeService(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(FailAfterSessions.class));
|
||||
var failedHttp = new java.util.concurrent.atomic.AtomicReference<org.eclipse.jetty.server.Server>();
|
||||
var failure = assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
|
||||
() -> com.kingsrook.qqq.middleware.javalin.QApplicationLauncher.run(application(failing), launcherConfig(failedHttp)));
|
||||
assertTrue(failure.getMessage().contains("owned late failure"));
|
||||
assertNotNull(failedHttp.get());
|
||||
assertTrue(failedHttp.get().isStopped());
|
||||
assertNull(QSessionStoreQBitContext.getProvider(failing));
|
||||
assertNull(QSessionStoreQBitContext.getConfig(failing));
|
||||
assertTrue(QSessionStoreRegistry.getInstance(failing).getProvider().isEmpty());
|
||||
assertThrows(IllegalStateException.class, () -> RedisRuntimeService.connection(failing, "sessions"));
|
||||
|
||||
redis.execInContainer("redis-cli", "CONFIG", "SET", "requirepass", "owned-startup-password");
|
||||
QInstance early = host(redis, "early-failure", true);
|
||||
var configuredProvider = QSessionStoreQBitContext.getProvider(early);
|
||||
var earlyHttp = new java.util.concurrent.atomic.AtomicReference<org.eclipse.jetty.server.Server>();
|
||||
var earlyFailure = assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
|
||||
() -> com.kingsrook.qqq.middleware.javalin.QApplicationLauncher.run(application(early), launcherConfig(earlyHttp)));
|
||||
assertTrue(earlyFailure.getMessage().contains("Redis startup failed"));
|
||||
assertTrue(earlyHttp.get().isStopped());
|
||||
assertThrows(IllegalStateException.class, () -> RedisRuntimeService.connection(early, "sessions"));
|
||||
assertSame(configuredProvider, QSessionStoreQBitContext.getProvider(early));
|
||||
assertThrows(IllegalStateException.class, () -> configuredProvider.load("unstarted"));
|
||||
assertEquals(0, redis.execInContainer("redis-cli", "-a", "owned-startup-password",
|
||||
"CONFIG", "SET", "requirepass", "").getExitCode());
|
||||
var retriedHttp = new java.util.concurrent.atomic.AtomicReference<org.eclipse.jetty.server.Server>();
|
||||
var retried = com.kingsrook.qqq.middleware.javalin.QApplicationLauncher.run(application(early), launcherConfig(retriedHttp));
|
||||
try
|
||||
{
|
||||
assertTrue(retriedHttp.get().isStarted());
|
||||
configuredProvider.store("retry", new QSession(), null);
|
||||
assertTrue(configuredProvider.load("retry").isPresent());
|
||||
}
|
||||
finally
|
||||
{
|
||||
retried.stop();
|
||||
}
|
||||
assertTrue(retriedHttp.get().isStopped());
|
||||
assertNull(QSessionStoreQBitContext.getProvider(early));
|
||||
assertTrue(QSessionStoreRegistry.getInstance(early).getProvider().isEmpty());
|
||||
assertThrows(IllegalStateException.class, () -> RedisRuntimeService.connection(early, "sessions"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
QContext.init(original);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The launcher receives the same already-produced metadata as the runtime tests.
|
||||
***************************************************************************/
|
||||
private com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication application(QInstance instance)
|
||||
{
|
||||
return new com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication()
|
||||
{
|
||||
/**********************************************************************
|
||||
** Return this test's owned application, never a global fixture.
|
||||
**********************************************************************/
|
||||
@Override
|
||||
public QInstance defineQInstance()
|
||||
{
|
||||
return instance;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Bind an ephemeral loopback HTTP port and observe the real Jetty lifecycle.
|
||||
***************************************************************************/
|
||||
private com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig launcherConfig(
|
||||
java.util.concurrent.atomic.AtomicReference<org.eclipse.jetty.server.Server> http)
|
||||
{
|
||||
return new com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig()
|
||||
.withRegisterShutdownHook(false).withServerCustomizer(server -> server.withPort(0)
|
||||
.withServeFrontendNext(false).withServeFrontendMaterialDashboard(false)
|
||||
.withJavalinConfigCustomizer(config ->
|
||||
{
|
||||
config.jetty.host = "127.0.0.1";
|
||||
config.jetty.modifyServer(http::set);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fail only after proving Redis and session registration have started.
|
||||
***************************************************************************/
|
||||
public static class FailAfterSessions implements com.kingsrook.qqq.backend.core.instances.QRuntimeServiceInterface
|
||||
{
|
||||
/***********************************************************************
|
||||
** Stable failure-stage name for launcher diagnostics.
|
||||
***********************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return "owned-late-failure";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** The thrown message proves the preceding runtime dependencies were ready.
|
||||
***********************************************************************/
|
||||
@Override
|
||||
public void start(QInstance instance) throws com.kingsrook.qqq.backend.core.exceptions.QException
|
||||
{
|
||||
if(QSessionStoreQBitContext.getProvider(instance) == null
|
||||
|| !"PONG".equals(RedisRuntimeService.connection(instance, "sessions").client().ping()))
|
||||
{
|
||||
throw new com.kingsrook.qqq.backend.core.exceptions.QException("Preceding services were not ready");
|
||||
}
|
||||
throw new com.kingsrook.qqq.backend.core.exceptions.QException("owned late failure");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***********************************************************************
|
||||
** No resources are acquired by this deliberately failing service.
|
||||
***********************************************************************/
|
||||
@Override
|
||||
public void stop()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Reject bad settings or missing infrastructure before publishing qbit metadata.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void invalidConfigurationDoesNotRegister() throws Exception
|
||||
{
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new SharedRedisSessionStoreSettings("", "test", "app", "auth", 100));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new SharedRedisSessionStoreSettings("pool", "test", "app", "auth", 0));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new SharedRedisSessionStoreSettings("pool", "test", "app", "auth", 1048577));
|
||||
QInstance instance = new QInstance();
|
||||
var config = new QSessionStoreQBitConfig().withProviderType(QSessionStoreProviderType.SHARED_REDIS);
|
||||
assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
|
||||
() -> new QSessionStoreQBitProducer().withConfig(config).produce(instance, "session-store"));
|
||||
config.withSharedRedis(new SharedRedisSessionStoreSettings("missing", "test", "app", "auth", 65536));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new QSessionStoreQBitProducer().withConfig(config).produce(instance, "session-store"));
|
||||
assertTrue(instance.getQBits().isEmpty());
|
||||
assertTrue(QSessionStoreRegistry.getInstance(instance).getProvider().isEmpty());
|
||||
assertNull(QSessionStoreQBitContext.getProvider(instance));
|
||||
config.withDefaultTtl(Duration.ZERO);
|
||||
assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
|
||||
() -> new QSessionStoreQBitProducer().withConfig(config).produce(instance, "session-store"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Compose framework metadata and bind the session provider before pool startup.
|
||||
***************************************************************************/
|
||||
private QInstance host(GenericContainer<?> redis, String application, boolean sliding) throws Exception
|
||||
{
|
||||
var instance = new QInstance();
|
||||
instance.addBackend(new com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData()
|
||||
.withName("memory").withBackendType(com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule.class));
|
||||
instance.withInstanceDefaultAuthentication(new com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData()
|
||||
.withName("test").withType(com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType.FULLY_ANONYMOUS));
|
||||
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(FixtureEndpoint.host(redis)).withPort(FixtureEndpoint.port(redis))
|
||||
.withTls(false).withSocketTimeoutMillis(300).withConnectionTimeoutMillis(300)))
|
||||
.produce(instance);
|
||||
new QSessionStoreQBitProducer().withConfig(new QSessionStoreQBitConfig()
|
||||
.withProviderType(QSessionStoreProviderType.SHARED_REDIS)
|
||||
.withSharedRedis(new SharedRedisSessionStoreSettings("sessions", "test", application, "authentication", 65536))
|
||||
.withDefaultTtl(Duration.ofSeconds(30)).withEnableSlidingExpiration(sliding))
|
||||
.produce(instance, "session-store");
|
||||
assertNull(instance.getProcess("cleanExpiredSessions"));
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
-491
@@ -1,491 +0,0 @@
|
||||
/*
|
||||
* 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<String> 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> T on(QInstance instance, java.util.function.Supplier<T> 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<String> rows()
|
||||
{
|
||||
List<String> 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);
|
||||
}
|
||||
|
||||
}
|
||||
+1
-1
@@ -110,7 +110,7 @@ class TableBasedSessionStoreProviderIT
|
||||
conn.createStatement().execute("""
|
||||
CREATE TABLE `storedSession` (
|
||||
`id` INTEGER AUTO_INCREMENT PRIMARY KEY,
|
||||
`sessionUuid` VARCHAR(64) NOT NULL UNIQUE,
|
||||
`sessionUuid` VARCHAR(36) NOT NULL UNIQUE,
|
||||
`userId` VARCHAR(255),
|
||||
`sessionData` TEXT,
|
||||
`expiresAt` TIMESTAMP NOT NULL,
|
||||
|
||||
Reference in new issue
Block a user