Reusable Spring Boot building blocks for backend applications: authentication (JWT + API key),
local user mirror, tags, key/value settings, outbound webhooks, and row-level multi-tenancy. The
library bundles each feature as an independent Spring @Configuration, so applications can opt
into the full stack with a single import or pick the parts they need.
- Java 21
- Spring Boot 3.5.x
- A relational database supported by Spring Data JPA (PostgreSQL is used in CI / integration tests)
Releases are published to Maven Central; SNAPSHOTs are published to the Sonatype Central Portal
snapshot repository on every push to main.
<dependency>
<groupId>com.open-elements</groupId>
<artifactId>spring-services</artifactId>
<version><!-- latest released version --></version>
</dependency>To consume a -SNAPSHOT build, also declare the Central Portal snapshot repository (releases still
come from Maven Central):
<repositories>
<repository>
<id>central-portal-snapshots</id>
<name>Central Portal Snapshots</name>
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
<releases><enabled>false</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>The simplest way to wire the platform is to import FullSpringServiceConfig (this wires every
feature except the opt-in sidecar features — Search and DB-Backup — which you enable explicitly, see
below):
@SpringBootApplication
@Import(FullSpringServiceConfig.class)
public class MyApplication {
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}Applications that only need a subset can import the individual feature configurations directly —
for example SecurityConfig, TenantConfig, TagConfig, WebhookConfig, SettingsConfig,
ApiKeyConfig. Multi-tenancy can also be enabled declaratively via the @EnableTenant
meta-annotation.
The two features that talk to an external sidecar — Search (Meilisearch) and DB-Backup — are
disabled by default, even when you import FullSpringServiceConfig. Importing the config does not
activate them; you enable each explicitly. When a feature is enabled, its host is required and a
missing/blank host fails startup (no silent localhost fallback):
# Search (Meilisearch) — off by default
openelements.meilisearch.enabled=true
openelements.meilisearch.host=https://meilisearch.internal:7700 # required when enabled
openelements.meilisearch.master-key=${MEILI_MASTER_KEY}
openelements.meilisearch.index-prefix= # optional, default ""
# DB-Backup sidecar — off by default
openelements.db-backup.enabled=true
openelements.db-backup.base-url=https://db-backup.internal:8081 # required when enabled
openelements.db-backup.api-token=${DB_BACKUP_API_TOKEN} # required for authenticated callsIf a feature stays disabled, none of its beans are created and no connection settings are needed.
Secrets (master-key, api-token) must come from environment variables or secret management, never
from committed configuration.
- JWT / OAuth2 Resource Server — Standard Spring Security JWT validation with
roles-claim toROLE_*mapping in addition to the defaultSCOPE_*mapping fromscope/scp. - API Key Authentication —
X-API-Keyheader authentication via a custom Spring Security filter that runs ahead of the bearer-token filter. Keys (crm_prefix + 48 random alphanumeric characters fromSecureRandom) are stored as a SHA-256 hash plus a short display prefix; the raw key is returned exactly once at creation. API keys grant read-only access (GET/HEAD/OPTIONS) — mutating operations require a JWT. - Uniform Authentication-Failure Responses — Both filter chains share a single
JsonAuthenticationEntryPoint. Every401 Unauthorizedcarries a stable JSON body{"status":401,"error":"Unauthorized","message":"<reason>"}and an RFC 7235 §3.1WWW-Authenticateheader (Beareron the JWT chain,ApiKey realm="external"on the external chain). - User Service — Lazily provisions a local user row from the JWT subject (
sub) and keepsname,emailandavatarUrlin sync with the matching JWT claims. Carries three SCIM- aligned identifying fields —sub(OIDC subject, nullable for SCIM-pre-provisioned rows),externalId(stable IdP-side id), anduserName(RFC 7643 unique business key, withpreferred_username → email → sub → "user-<UUID>"fallback chain) — with a tiered JIT-login lookup (findBySub → findByExternalId → findByUserName → provision) that opportunistically backfills the new identifiers on existing rows. The avatar URL points directly at the identity provider; clients render it without any proxying by this library. Concurrent first-logins for the samesubare coordinated through the database's unique constraint + aREQUIRES_NEW-transactionalUserProvisionerhelper that lets the outer transaction recover from the race without poisoning. See theservices.userpackage documentation for the full design. - User Account Deactivation —
UserEntity.activeis the canonical deactivation flag, honored uniformly across all authentication paths. JWT chain:UserService.getCurrentUserEntity()throwsAccessDeniedExceptionforactive = false→ HTTP403 Forbidden. Opaque-token chain (planned for spec 010):PrincipalDirectory.resolveUser(...)returnsactive = false→BadOpaqueTokenException→ HTTP401 Unauthorized. JIT provisioning of a SCIM-pre-provisioned inactive user is also blocked — nosubis ever written onto a deactivated row. PrincipalDirectoryport — Bridge between the (planned) opaque-token validation layer and the local user mirror. Default implementationUserEntityPrincipalDirectoryqueries byexternalIdand returns the liveactiveflag. Role/group population deferred to the SCIM-Provider spec.- Typed Authentication Surface —
AuthService.getUserInformation()returnsOptional<UserInformation>: present for JWT-authenticated requests, empty for non-JWT principals (API keys, primitive auth). Callers must explicitly handle the empty case rather than receiving a sentinel value that could be silently persisted. - Tags — CRUD service for color-coded labels. A
PreTagDeleteEventis published before deletion so other features can detach references — or veto the deletion by throwing. - Settings — Plain string-key / string-value store via
SettingsDataService, useful for configuration that lives in the database rather than inapplication.yml. - Webhooks — Outbound webhook subscriptions. Data lifecycle events trigger a webhook dispatch
after the originating transaction commits; HTTP POST is performed asynchronously via Spring
RestClient(10 s connect/read timeout) and the last response status is persisted on the webhook row. Apingoperation is exposed for testing a subscription on demand. - Multi-Tenancy — Row-level isolation in a shared schema. Tenant id is taken from the authenticated principal; a JPA pre-persist guard fails fast if a row would be written without one. Tenant-aware base classes mirror the non-tenant data abstractions and make cross-tenant reads/writes impossible by construction.
- Generic Data Layer — Reusable abstractions (
AbstractEntitywith UUID primary key and audit timestamps,AbstractDbBackedDataServicetemplate) that wire CRUD, transactions and lifecycle events for any domain object. - Lifecycle Events —
OnObjectCreate,OnObjectUpdateandOnObjectDeleteare published synchronously inside the originating transaction; consumers opt into post-commit behaviour with@TransactionalEventListener. - Search (opt-in, off by default) — Meilisearch-backed full-text search via a thin
RestClientwrapper (MeilisearchClient). Enabled withopenelements.meilisearch.enabled=true;hostis then required. At startup the lib exchanges the master key for a scoped runtime key, applies declarative per-index settings, and runs a full reindex: the application contributes oneSearchIndexBootstrapStepbean per index (streaming already-mapped documents) and an optionalIndexSettings/ScopedKeySpecbean. The lib never sees domain types.Highlighterturns Meilisearch's_formattedoutput into HTML-safe highlighted fragments. Once enabled, an unreachable sidecar is skipped with a warning, so the feature is inert until Meilisearch is available. Connection settings bind fromopenelements.meilisearch.*(see Optional sidecar features). - DB-Backup client (opt-in, off by default) — thin
RestClientwrapper (DbBackupClient) for the db-backup-service sidecar: trigger backups, poll job status, list and stream backup artefacts. Enabled withopenelements.db-backup.enabled=true;base-urlis then required. Connection settings bind fromopenelements.db-backup.*(see Optional sidecar features).
For per-package overviews, see the package-info.java files under
src/main/java/com/openelements/spring/base/.
spring-services 1.1.0 tightens the security stack to match Spring Boot best practices. Three
changes are visible to consumers; everything else is internal cleanup.
-
AuthService.getUserInformation()now returnsOptional<UserInformation>instead ofUserInformationwith a"UNKNOWN"sentinel for non-JWT principals. Migration:// Before UserInformation info = authService.getUserInformation(); // After UserInformation info = authService.getUserInformation() .orElseThrow(() -> new IllegalStateException("Not a JWT request")); // or, when you want to tolerate API-key / primitive auth: Optional<UserInformation> info = authService.getUserInformation();
-
401 Unauthorizedresponses changed shape. Both the JWT and external API-key chains now return:{ "status": 401, "error": "Unauthorized", "message": "<reason>" }…with a
WWW-Authenticateheader (BearerorApiKey realm="external"). Consumers that parse the failure body must adapt; consumers that only check status codes are unaffected. -
Connection pool sizing for first-login concurrency. The new
UserProvisionerruns the initialINSERTin aREQUIRES_NEWinner transaction, which holds two pool connections per thread for the duration of the provisioning hop (outer suspended + inner active). Size the HikariCP pool with the formulapeak_concurrent_first_logins × 2 + steady-stateto avoid deadlock under bulk-onboarding bursts. Steady-state traffic is unaffected — the second connection is only held during the brief provisioning window for a previously-unknown user.
No schema migration, no property rename, no API path change.
spring-services 1.2.0 extends UserEntity with three new identifying fields and an active
flag to prepare for SCIM 2.0 provisioning (the SCIM endpoints themselves will follow in a
later release). Consumers must run one Flyway migration when upgrading.
-
UserInformationrecord gained two fields:userNameandexternalId. Direct callers that construct the record themselves (test factories, ad-hoc instantiations) need to pass the two new fields. The default value mappingAuthServiceapplies on JIT login isuserName = preferred_username(withemail → sub → "user-<UUID>"fallback) andexternalId = sub. The library is at1.2.0-SNAPSHOT; record extensions are tolerated. -
UserEntityschema change. Consuming apps' Flyway timeline gains one migration:-- Vx__scim_foundation_user_model.sql ALTER TABLE users ALTER COLUMN sub DROP NOT NULL; ALTER TABLE users ADD COLUMN external_id VARCHAR(255); ALTER TABLE users ADD CONSTRAINT users_external_id_uk UNIQUE (external_id); ALTER TABLE users ADD COLUMN user_name VARCHAR(255); UPDATE users SET user_name = COALESCE(email, sub, 'user-' || id::text) WHERE user_name IS NULL; ALTER TABLE users ALTER COLUMN user_name SET NOT NULL; ALTER TABLE users ADD CONSTRAINT users_user_name_uk UNIQUE (user_name); ALTER TABLE users ADD COLUMN active BOOLEAN NOT NULL DEFAULT TRUE;
Existing rows:
activedefaults toTRUE,external_idanduser_nameare backfilled on next interactive login (external_id ← jwt.sub,user_name ← preferred_usernamewith fallback). The Flyway script above also seedsuser_namefromemail/subto satisfy theNOT NULLconstraint at migration time. -
Deactivation surface.
UserEntity.setActive(false)blocks the user across all authentication paths from the very next request — no logout / token re-issue needed when flipped back. Use it from your SCIM deprovisioning handler. -
AuthService.getUserInformation()now requirespreferred_usernamefor unambiguous user-name correlation. If two users share the same email and the IdP omitspreferred_username, both fall back touserName = emailand the second JIT login fails withIllegalStateException(the library refuses to silently merge two identities). Resolution: configure the IdP to assertpreferred_usernamefor every user. -
New
PrincipalDirectoryport inservices.apitoken(temporarily owned by this spec, superseded by the upcoming API Token Module spec). Default implementationUserEntityPrincipalDirectoryresolves users live fromUserEntitybyexternal_id. Consumers can override the bean to plug in a custom source (e.g. cache, IdP live-call).
./mvnw clean verify # build + run tests
./mvnw spotless:apply # apply Google Java Format
./mvnw spotless:check # verify formatting (also runs in CI)src/main/java/com/openelements/spring/base/
├── FullSpringServiceConfig.java — Aggregate @Import of every feature config
├── data/ — Generic CRUD abstractions
├── events/ — Lifecycle events
├── security/ — Filter chain, JWT converter, AuthService
│ ├── apikey/ — X-API-Key authentication filter
│ └── user/ — Local user mirror
├── services/
│ ├── apikey/ — API key data layer
│ ├── search/ — Meilisearch client, startup reindex, highlighter
│ ├── settings/ — Key/value settings
│ ├── tag/ — Tag CRUD + PreTagDeleteEvent
│ └── webhook/ — Outbound webhook delivery
└── tenant/ — Multi-tenancy primitives
Every push to main triggers the snapshot.yml GitHub Actions workflow, which builds the project, runs all tests, and
publishes a SNAPSHOT artifact to the Sonatype Central Portal snapshot repository at:
https://central.sonatype.com/repository/maven-snapshots/
Publishing is gated on the POM version ending in -SNAPSHOT: the brief release-version commit that release.sh
pushes to main is skipped cleanly (not failed) rather than published. Authentication uses the same
MAVEN_CENTRAL_USERNAME / MAVEN_CENTRAL_PASSWORD secrets as the full release. No manual steps are required, and
SNAPSHOT publishing only happens on main — feature branches only run the build workflow.
Releases are published to Maven Central via JReleaser, executed by the release.yml GitHub Actions
workflow. The workflow runs whenever a vA.B.C tag is pushed: it verifies the POM version matches the
tag, builds and signs the artifacts, uploads them to Maven Central, and creates a GitHub Release with a
changelog. The deployment runs in CI, not on your machine — no local GPG key or Maven Central
credentials are required to cut a release.
The workflow reads all credentials from GitHub Actions secrets. Configure these once under Settings → Secrets and variables → Actions:
| Secret | Description |
|---|---|
MAVEN_CENTRAL_USERNAME |
Maven Central portal username (token user) |
MAVEN_CENTRAL_PASSWORD |
Maven Central portal token |
GPG_PASSPHRASE |
GPG key passphrase |
GPG_PUBLIC_KEY |
GPG public key, ASCII-armored (gpg --armor --export <KEY_ID>) |
GPG_SECRET_KEY |
GPG private key, ASCII-armored (gpg --armor --export-secret-key <KEY_ID>) |
GITHUB_TOKEN is provided automatically by Actions and needs no setup.
./release.sh <release-version> <next-snapshot-version>Example:
./release.sh 1.0.0 1.1.0-SNAPSHOTThis will:
- Set the project version to
1.0.0 - Build, test, and assemble the publication artifacts locally with
mvn -Ppublication clean verify(the same profile the release uses, so a broken Javadoc@link, a missing source jar, or an SBOM failure is caught here — before any tag is pushed) - Generate the release/upgrade documentation under
docs/via Claude Code — best-effort: if theclaudeCLI is not installed the step logs a warning and continues, so it never blocks a release - Commit and push the release version (including the generated doc)
- Create and push the
v1.0.0tag — this triggers therelease.ymlworkflow, which deploys to Maven Central and creates the GitHub Release - Set the project version to
1.1.0-SNAPSHOT - Commit and push the next snapshot version
The actual Maven Central deployment happens asynchronously in CI — watch the Actions tab for the
"Release Artifacts" run. If any local step fails the script stops immediately (set -e); if it fails
after the tag has been pushed, the workflow can be re-run from the Actions tab.
Apache License 2.0 — see LICENSE.