Building an Authorization Server with Spring Boot 4 and Oracle Database

Hi again everyone!

In this post I want to show you how to build a small authorization server using Spring Boot, Spring Security, Spring Authorization Server, and Oracle Database. The idea is simple: we want an application that can expose OAuth2/OIDC authorization-server endpoints, authenticate users whose details are stored in Oracle Database, and provide a small REST API for managing those users.

The complete code for this example is in the azn-server repository. In this article we will build it from scratch and look at the important pieces along the way.

One important note before we start: this version of the example is on the Spring Boot 4.x code line. The repository currently uses Spring Boot 4.0.6, Java 21, Spring Framework 7.0.7, Spring Security 7.0.5, Spring Cloud 2025.1.1, Liquibase 5.0.2 from the Spring Boot BOM, and the Oracle Spring Boot starters.

If you have seen the Spring Boot 3.x version of this sample, the application shape is intentionally the same. The Boot 4 version updates the dependency line and uses the new modular starter names and package names introduced across Spring Boot 4, Spring Framework 7, and Spring Security 7.

What we are building

The application has three main responsibilities:

  • Expose OAuth2 and OpenID Connect endpoints using Spring Authorization Server.
  • Store application users in Oracle Database.
  • Provide a secured user-management API backed by Spring Security method security.

Spring Security gives us a lot here. It gives us the authentication framework, password encoding, UserDetailsService integration, filter chains, method-level authorization, role hierarchy support, and the authorization-server protocol endpoints. Oracle Database gives us a durable user repository, schema ownership, constraints, identity columns, auditing triggers, and a real database engine for integration tests.

This is the application shape:

  • Spring Boot starts the service.
  • Liquibase creates or updates the Oracle schema user.
  • Liquibase creates the USERS table and audit trigger.
  • JPA maps the USERS table into a User entity.
  • Spring Security loads users from that JPA repository.
  • Spring Authorization Server exposes the OAuth2/OIDC endpoints.
  • The REST API lets administrators manage users.

Let’s walk through the build.

Create the Spring Boot project

We start with a normal Spring Boot application. The important thing is to include the dependencies for web endpoints, Spring Authorization Server, actuator, JPA, Liquibase, Oracle UCP, Oracle wallet support, and the test stack.

The parent and version properties select the Spring Boot 4 and Spring Cloud lines:

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.0.6</version>
        <relativePath/>
    </parent>

    <properties>
        <java.version>21</java.version>
        <spring-cloud.version>2025.1.1</spring-cloud.version>
        <oracle-spring-boot-starter-version>26.1.1</oracle-spring-boot-starter-version>
    </properties>

Here is the dependency section from pom.xml:

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security-oauth2-authorization-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-liquibase</artifactId>
        </dependency>
        <dependency>
            <groupId>com.oracle.database.spring</groupId>
            <artifactId>oracle-spring-boot-starter-ucp</artifactId>
            <version>${oracle-spring-boot-starter-version}</version>
        </dependency>
        <dependency>
            <groupId>com.oracle.database.spring</groupId>
            <artifactId>oracle-spring-boot-starter-wallet</artifactId>
            <version>${oracle-spring-boot-starter-version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-registry-prometheus</artifactId>
        </dependency>

        <!-- test dependencies -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security-oauth2-authorization-server-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-testcontainers</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-oracle-free</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

The Spring Cloud BOM is imported separately:

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

There are a couple of things to point out here.

First, spring-boot-starter-security-oauth2-authorization-server brings in the Spring Authorization Server support that provides the OAuth2/OIDC protocol endpoints. That means we do not have to hand-code token endpoints, metadata endpoints, JWK endpoints, or the protocol filter chain.

Spring Boot 4 is more modular than the 3.x line. For this servlet application, the web starter is now spring-boot-starter-webmvc, the test slice starter is spring-boot-starter-webmvc-test, and the authorization-server starter lives under the security naming scheme. The Testcontainers 2 artifacts also use the testcontainers-* artifact names shown above. Letting the Spring Boot parent manage the versions keeps Spring Framework, Spring Security, Liquibase, Jackson, Hibernate, and Testcontainers aligned.

Second, the Oracle UCP starter gives us Oracle Universal Connection Pool integration through Spring Boot configuration. That is useful for real services because the database connection pool is not an afterthought – it is part of the application runtime.

Third, Liquibase owns the schema. Hibernate validates the schema, but Liquibase creates it. That is usually the right split for applications where the database is important enough to be managed deliberately.

Configure Oracle Database and Liquibase

The application uses two database identities:

  • A Liquibase/admin identity that can create and update the application schema.
  • A runtime schema user named USER_REPO that the application uses for normal database access.

Here is the application configuration:

server:
port: 8080
spring:
application:
name: @project.artifactId@
cloud:
# Discovery is opt-in so local runs and tests do not attempt to register.
discovery:
enabled: ${EUREKA_CLIENT_ENABLED:false}
threads:
virtual:
enabled: true
datasource:
# Runtime connections authenticate directly as the application schema user.
url: ${AZN_DATASOURCE_URL:${SPRING_DATASOURCE_URL:}}
username: ${AZN_USER_REPO_USERNAME:USER_REPO}
password: ${AZN_USER_REPO_PASSWORD}
driver-class-name: oracle.jdbc.OracleDriver
type: oracle.ucp.jdbc.PoolDataSource
oracleucp:
connection-factory-class-name: oracle.jdbc.pool.OracleDataSource
connection-pool-name: AznServerConnectionPool
initial-pool-size: 15
min-pool-size: 10
max-pool-size: 30
jpa:
# Keep database access inside service/controller methods, not view rendering.
open-in-view: false
hibernate:
# Liquibase owns schema changes; Hibernate only validates the result.
ddl-auto: validate
properties:
hibernate:
format_sql: true
show-sql: false
liquibase:
# Liquibase uses the admin account directly so it can create USER_REPO.
change-log: classpath:db/changelog/controller.yaml
url: ${AZN_DATASOURCE_URL:${SPRING_DATASOURCE_URL:}}
user: ${AZN_LIQUIBASE_USERNAME:${AZN_DATASOURCE_USERNAME:${SPRING_LIQUIBASE_USER:${SPRING_DATASOURCE_USERNAME:}}}}
password: ${AZN_LIQUIBASE_PASSWORD:${AZN_DATASOURCE_PASSWORD:${SPRING_LIQUIBASE_PASSWORD:${SPRING_DATASOURCE_PASSWORD:}}}}
parameters:
userRepoPassword: ${AZN_USER_REPO_PASSWORD}
enabled: ${RUN_LIQUIBASE:true}
azn:
bootstrap-users:
admin-password: ${ORACTL_ADMIN_PASSWORD:}
user-password: ${ORACTL_USER_PASSWORD:}
management:
endpoint:
health:
show-details: when_authorized
roles: ACTUATOR
endpoints:
web:
exposure:
# Keep actuator surface small; SecurityConfig protects non-health/info endpoints.
include: health,info,prometheus
eureka:
instance:
hostname: ${spring.application.name}
preferIpAddress: true
client:
# Supported for deployments, disabled by default for local/test startup.
service-url:
defaultZone: ${EUREKA_SERVER_ADDRESS:http://localhost:8761/eureka/}
fetch-registry: true
register-with-eureka: true
enabled: ${EUREKA_CLIENT_ENABLED:false}
# Logging
logging:
level:
org.springframework.web: INFO
org.springframework.security: INFO
oracle.obaas.aznserver: INFO

I like this arrangement because the runtime user is not the same as the schema-management user. Liquibase gets the elevated account it needs to create and manage USER_REPO, and the running application connects as USER_REPO. That is a clean security boundary.

Oracle UCP is configured as the datasource type:

    type: oracle.ucp.jdbc.PoolDataSource
    oracleucp:
      connection-factory-class-name: oracle.jdbc.pool.OracleDataSource
      connection-pool-name: AznServerConnectionPool
      initial-pool-size: 15
      min-pool-size: 10
      max-pool-size: 30

That gives us Oracle-aware connection pooling with very little Spring code. We get the operational benefit of a pool that is meant for Oracle Database, while still configuring it in the usual Spring Boot way.

Create the schema with Liquibase

The changelog controller is small:

---
databaseChangeLog:
- include:
file: classpath:db/changelog/dbuser.sql
- include:
file: classpath:db/changelog/table.sql
- include:
file: classpath:db/changelog/trigger.sql

The first changelog creates and maintains the USER_REPO database user:

-- liquibase formatted sql
-- changeset az_admin:initial_user endDelimiter:/ runAlways:true runOnChange:true
DECLARE
l_user VARCHAR2(255);
l_tblspace VARCHAR2(255);
BEGIN
BEGIN
SELECT username INTO l_user FROM DBA_USERS WHERE USERNAME='USER_REPO';
EXCEPTION WHEN no_data_found THEN
EXECUTE IMMEDIATE 'CREATE USER "USER_REPO" IDENTIFIED BY "${userRepoPassword}"';
END;
EXECUTE IMMEDIATE 'ALTER USER "USER_REPO" IDENTIFIED BY "${userRepoPassword}" ACCOUNT UNLOCK';
SELECT default_tablespace INTO l_tblspace FROM dba_users WHERE username = 'USER_REPO';
EXECUTE IMMEDIATE 'ALTER USER "USER_REPO" QUOTA UNLIMITED ON ' || l_tblspace;
EXECUTE IMMEDIATE 'GRANT CONNECT TO "USER_REPO"';
EXECUTE IMMEDIATE 'GRANT RESOURCE TO "USER_REPO"';
EXECUTE IMMEDIATE 'ALTER USER "USER_REPO" DEFAULT ROLE CONNECT,RESOURCE';
END;
/
--rollback drop user "USER_REPO" cascade;

The next changelog creates the user table:

-- liquibase formatted sql
-- changeset az_admin:initial_table
CREATE TABLE USER_REPO.USERS
(
USER_ID NUMBER GENERATED ALWAYS AS IDENTITY (START WITH 1 CACHE 20),
PASSWORD VARCHAR2(255 CHAR) NOT NULL,
ROLES VARCHAR2(255 CHAR) NOT NULL,
USERNAME VARCHAR2(255 CHAR) NOT NULL,
CREATED_ON TIMESTAMP DEFAULT SYSDATE,
CREATED_BY VARCHAR2 (100) DEFAULT COALESCE(
REGEXP_SUBSTR(SYS_CONTEXT('USERENV','CLIENT_IDENTIFIER'),'^[^:]*'),
SYS_CONTEXT('USERENV','SESSION_USER')),
UPDATED_ON TIMESTAMP ,
UPDATED_BY VARCHAR2 (255),
PRIMARY KEY (USER_ID),
CONSTRAINT USERNAME_UQ UNIQUE (USERNAME)
) LOGGING;
COMMENT ON TABLE USER_REPO.USERS is 'Application user repository for OAuth2/OIDC user management';
COMMENT ON COLUMN USER_REPO.USERS.PASSWORD is 'BCrypt hash of the application user password; never store cleartext';
ALTER TABLE USER_REPO.USERS ADD EMAIL VARCHAR2(255 CHAR) NULL;
ALTER TABLE USER_REPO.USERS ADD OTP VARCHAR2(255 CHAR) NULL;
COMMENT ON COLUMN USER_REPO.USERS.OTP is 'BCrypt hash of the one-time password; never store cleartext';
--rollback DROP TABLE USER_REPO.USERS;

There are some good Oracle Database features doing useful work here:

  • GENERATED ALWAYS AS IDENTITY gives us database-managed user ids.
  • The unique constraint protects usernames at the database level.
  • Column comments document sensitive columns right where they live.
  • The table belongs to the USER_REPO schema, not to the application admin user.

Finally, we add a small audit trigger:

-- liquibase formatted sql
-- changeset az_admin:initial_trigger endDelimiter:/
CREATE OR REPLACE EDITIONABLE TRIGGER "USER_REPO"."AUDIT_TRG" BEFORE
UPDATE ON USER_REPO.USERS FOR EACH ROW
BEGIN
:NEW.UPDATED_ON := SYSDATE;
:NEW.UPDATED_BY := COALESCE(REGEXP_SUBSTR(SYS_CONTEXT('USERENV', 'CLIENT_IDENTIFIER'), '^[^:]*'), SYS_CONTEXT('USERENV', 'SESSION_USER'));
END;
/
--rollback DROP TRIGGER "USER_REPO"."AUDIT_TRG";

This is a nice example of letting the database enforce something that belongs in the database. Every update gets audit fields set consistently, whether the update came from this Spring application or from another controlled path later.

Map the Oracle table to a JPA entity

Now we need a JPA entity for the USER_REPO.USERS table.

// Copyright (c) 2023, 2026, Oracle and/or its affiliates.
package oracle.obaas.aznserver.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Entity
@Table(name = "users", schema = "user_repo")
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = {"password", "otp"})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "USER_ID")
private Long userId;
@Column(name = "USERNAME", nullable = false)
private String username;
/**
* Stores the BCrypt hash that is persisted in USER_REPO.USERS.PASSWORD.
* Cleartext passwords may be accepted at API boundaries, but they must be
* encoded before this entity is saved.
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Column(name = "PASSWORD", nullable = false, length = 255)
private String password;
@Column(name = "ROLES", nullable = false)
private String roles;
@Column(name = "EMAIL")
private String email;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Column(name = "OTP")
private String otp;
/**
* Create a user object.
*
* @param username The username.
* @param password The encoded password hash for persistence.
* @param roles The roles assigned the user, as a comma separated list, e.g.
* "ROLE_USER,ROLE_ADMIN".
*/
public User(String username, String password, String roles) {
this.username = username;
this.password = password;
this.roles = roles;
}
// This constructor should only be used during testing with a mock repository,
// when we need to set the id manually
public User(long userId, String username, String password, String roles) {
this(username, password, roles);
this.userId = userId;
}
/**
* Create a user object.
*
* @param username The username.
* @param password The encoded password hash for persistence.
* @param roles The roles assigned the user, as a comma separated list, e.g.
* "ROLE_USER,ROLE_ADMIN".
* @param email The email associated with user account.
*/
public User(String username, String password, String roles, String email) {
this(username, password, roles);
this.email = email;
}
}

There are two small but important security choices in this class.

First, password and otp are write-only for JSON serialization. That means the API can accept these values in request bodies, but it will not serialize them back into responses.

Second, Lombok’s @ToString excludes password and otp. That helps prevent secrets from being accidentally written into logs.

The repository is exactly what we want from Spring Data JPA: small, declarative, and focused on the queries the service needs.

// Copyright (c) 2022, 2023, Oracle and/or its affiliates.
package oracle.obaas.aznserver.repository;
import java.util.List;
import java.util.Optional;
import oracle.obaas.aznserver.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByUsernameIgnoreCase(String username);
Optional<User> findByUserId(Long userId);
List<User> findUsersByUsernameStartsWithIgnoreCase(String username);
Optional<User> findByEmailIgnoreCase(String email);
}

This is one of the places where Spring Data JPA shines. The method names communicate intent, Spring implements the queries, and the application code stays readable.

Adapt the database user to Spring Security

Spring Security authenticates with UserDetails. Our database user is a domain object, so we wrap it in a SecurityUser.

// Copyright (c) 2022, 2026, Oracle and/or its affiliates.
package oracle.obaas.aznserver.model;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
public class SecurityUser implements UserDetails {
private final User user;
public SecurityUser(User user) {
this.user = user;
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
if (StringUtils.isBlank(user.getRoles())) {
return List.of();
}
return Arrays.stream(user
.getRoles()
.split(","))
.map(SimpleGrantedAuthority::new)
.toList();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}

Then we create a UserDetailsService backed by the JPA repository:

// Copyright (c) 2022, 2026, Oracle and/or its affiliates.
package oracle.obaas.aznserver.service;
import oracle.obaas.aznserver.model.SecurityUser;
import oracle.obaas.aznserver.repository.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class JpaUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public JpaUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SecurityUser user = userRepository
.findByUsername(username)
.map(SecurityUser::new)
.orElseThrow(() -> new UsernameNotFoundException("Authentication failed"));
return user;
}
}

This is the bridge between Oracle Database and Spring Security. Once this service exists, Spring Security can authenticate users stored in USER_REPO.USERS.

Configure Spring Security and Spring Authorization Server

The security configuration is the heart of the application. It does several things:

  • Creates a role hierarchy.
  • Enables method security.
  • Creates a dedicated authorization-server filter chain.
  • Creates a separate actuator filter chain.
  • Creates a stateless API filter chain.
  • Provides password encoding.
  • Provides development/test signing keys.
  • Optionally creates a local OAuth client.

Here is the role hierarchy:

    public static final String ROLE_HIERARCHY = "ROLE_ADMIN > ROLE_USERn"
            + "ROLE_ADMIN > ROLE_CONFIG_EDITORn"
            + "ROLE_CONFIG_EDITOR > ROLE_USER";

    /**
     * Configure a role hierarchy such that ADMIN "includes"/implies USER.
     * 
     * @return the hierarchy.
     */

    @Bean
    public RoleHierarchy roleHierarchy() {
        return RoleHierarchyImpl.fromHierarchy(ROLE_HIERARCHY);
    }

    /**
     * Configure method security to use the role hierarchy.
     * 
     * @param roleHierarchy injected by Spring.
     * @return The MethodSecurityExpressionHandler.
     */
    @Bean
    public MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) {
        DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
        expressionHandler.setRoleHierarchy(roleHierarchy);
        return expressionHandler;
    }

Role hierarchy is one of those Spring Security features that is easy to miss but very useful. If an administrator should also be treated as a user, we do not have to duplicate every role check everywhere. We can teach Spring Security that ROLE_ADMIN includes ROLE_USER.

The authorization server gets its own filter chain:

    /**
     * Authorization Server endpoints use their own filter chain so OAuth protocol
     * handling does not inherit API-specific stateless settings.
     *
     * @param http HttpSecurity injected by Spring.
     * @return the SecurityFilterChain.
     * @throws Exception if unable to create the chain.
     */
    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
            throws Exception {
        log.debug("In authorizationServerSecurityFilterChain");
        OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
                new OAuth2AuthorizationServerConfigurer();

        http
            .securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
            .with(authorizationServerConfigurer, authorizationServer ->
                authorizationServer.oidc(Customizer.withDefaults()))
            .authorizeHttpRequests((authorize) -> authorize
                .requestMatchers("/.well-known/**", "/oauth2/jwks").permitAll()
                .anyRequest().authenticated())
            .csrf((csrf) -> csrf.ignoringRequestMatchers(authorizationServerConfigurer.getEndpointsMatcher()))
            .exceptionHandling((exceptions) -> exceptions.defaultAuthenticationEntryPointFor(
                new LoginUrlAuthenticationEntryPoint("/login"),
                new MediaTypeRequestMatcher(MediaType.TEXT_HTML)));
        return http.build();
    }

This is where Spring Authorization Server does a lot of heavy lifting. The endpoints matcher identifies the protocol endpoints, OIDC support is enabled, and the well-known metadata and JWK endpoints are allowed anonymously.

In the Spring Boot 4 version, the authorization-server configurer comes from the Spring Security 7 package org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization. The older static factory used by the Boot 3 version is gone, so the sample constructs the configurer directly and then applies it to HttpSecurity.

The user-management API has a different shape. It is stateless and uses HTTP Basic:

    /**
     * Create a SecurityFilterChain for the user-management API.
     * @param http HttpSecurity injected by Spring. 
     * @param userDetailsService the JPA-backed user details service.
     * @return the SecurityFilterChain.
     * @throws Exception if unable to create the chain.
     */
    @Bean
    @Order(3)
    public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http, UserDetailsService userDetailsService)
            throws Exception {
        log.debug("In apiSecurityFilterChain");
        http
            .securityMatcher("/user/api/**", "/error/**")
            .authorizeHttpRequests((authorize) -> authorize
                .requestMatchers("/error/**").permitAll()
                .requestMatchers("/user/api/v1/ping").permitAll()
                .requestMatchers("/user/api/v1/forgot").permitAll()
                .anyRequest().authenticated()
            )
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .httpBasic(Customizer.withDefaults())
            .userDetailsService(userDetailsService);
        // The user-management API is stateless and does not use browser sessions or cookies.
        http.csrf(csrf -> csrf.disable());
        return http.build();
    }

The separation between the authorization-server chain and the API chain matters. The OAuth2/OIDC endpoints are protocol endpoints. The user API is a REST API. They have different security needs, so they get different chains.

The authentication provider uses our JPA-backed user details service and a BCrypt password encoder:

    /**
     * Create an Authentication Provider for our UserDetailsService.
     * @param userDetailsService the JPA-backed user details service.
     * @param passwordEncoder password encoder for stored password hashes.
     * @return the AuthenticationProvider.
     */
    @Bean
    public DaoAuthenticationProvider authenticationProvider(UserDetailsService userDetailsService,
            PasswordEncoder passwordEncoder) {
        DaoAuthenticationProvider auth = new DaoAuthenticationProvider(userDetailsService);
        auth.setPasswordEncoder(passwordEncoder);
        return auth;
    }

And the password encoder is:

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

Passwords in the API enter as clear text at the boundary, but they are stored as BCrypt hashes in Oracle Database. That is exactly the line we want: clear text only at the edge, hashes at rest.

For local development and tests, the application can create an opt-in registered client:

    /**
     * Create an opt-in local client for test and developer-only contexts.
     *
     * Production deployments should configure registered clients explicitly using
     * Spring Boot's authorization-server client properties.
     *
     * @param passwordEncoder password encoder for the client secret.
     * @param clientSecret configured client secret.
     * @return a local RegisteredClientRepository.
     */
    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnProperty(prefix = "azn.authorization-server.default-client", name = "enabled",
            havingValue = "true")
    public RegisteredClientRepository localRegisteredClientRepository(PasswordEncoder passwordEncoder,
            @Value("${azn.authorization-server.default-client.secret:}") String clientSecret) {
        if (!StringUtils.hasText(clientSecret)) {
            throw new IllegalStateException("azn.authorization-server.default-client.secret must be set when "
                    + "azn.authorization-server.default-client.enabled=true");
        }
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("azn-local-client")
                .clientSecret(passwordEncoder.encode(clientSecret))
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .redirectUri("http://127.0.0.1:8080/login/oauth2/code/azn-local-client")
                .scope(OidcScopes.OPENID)
                .scope("user.read")
                .clientSettings(ClientSettings.builder().requireProofKey(true).build())
                .build();
        return new InMemoryRegisteredClientRepository(registeredClient);
    }

Notice that this is opt-in. That is intentional. Local convenience is useful, but production registered clients should be configured deliberately.

The JWK source is also local by default:

    /**
     * Provide process-local signing keys for development and tests.
     *
     * Production deployments should replace this bean with persistent key material
     * so tokens remain verifiable across restarts and rolling deploys.
     *
     * @return the JWK source.
     */
    @Bean
    @ConditionalOnMissingBean
    public JWKSource<SecurityContext> jwkSource() {
        log.warn("Using process-local generated RSA signing keys. Configure a persistent JWKSource bean for "
                + "production so issued tokens remain verifiable across restarts and rolling deploys.");
        RSAKey rsaKey = generateRsa();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
    }

That is fine for development and tests. For production, you would provide persistent signing key material so tokens remain verifiable across restarts and rolling deployments.

Bootstrap the first users

An authorization server needs at least one user to get started. This application creates three bootstrap users on startup by default:

  • obaas-admin
  • obaas-user
  • obaas-config

The initializer is in the main application class:

    @Bean
    @ConditionalOnProperty(prefix = "azn.bootstrap-users", name = "enabled", havingValue = "true",
            matchIfMissing = true)
    ApplicationRunner userStoreInitializer(UserRepository users, PasswordEncoder passwordEncoder,
            @Value("${azn.bootstrap-users.admin-password:}") String adminPassword,
            @Value("${azn.bootstrap-users.user-password:}") String userPassword) {
        return args -> initUserStore(users, passwordEncoder, adminPassword, userPassword);
    }

And the implementation creates missing users with BCrypt-encoded passwords:

    public static void initUserStore(UserRepository users, PasswordEncoder encoder,
            String adminPassword, String userPassword) {
        log.debug("ENTER initUserStore");

        String obaasAdminPwd = adminPassword;
        String obaasUserPwd = userPassword;
        String obaasConfigPwd = obaasUserPwd;

        // Check for obaas-user, if not existent create the user
        if (users.findByUsername(OBAAS_USER).isEmpty()) {
            log.debug("Creating user obaas-user");

            obaasUserPwd = bootstrapPassword("azn.bootstrap-users.user-password", obaasUserPwd);

            users.saveAndFlush(new User(OBAAS_USER, encoder.encode(obaasUserPwd),
                    "ROLE_USER"));
        }

        // Check for obaas-admin, if not existent create the user
        Optional<User> adminUser = users.findByUsername(OBAAS_ADMIN);
        if (adminUser.isEmpty()) {
            log.debug("Creating user obaas-admin");

            obaasAdminPwd = bootstrapPassword("azn.bootstrap-users.admin-password", obaasAdminPwd);

            users.saveAndFlush(new User(OBAAS_ADMIN, encoder.encode(obaasAdminPwd),
                    "ROLE_ADMIN,ROLE_CONFIG_EDITOR,ROLE_USER"));
        }

        // Check for obaas-config, if not existent create the user with the same pwd as
        // obaas-user
        if (users.findByUsernameIgnoreCase(OBAAS_CONFIG).isEmpty()) {
            log.debug("Creating user obaas-config");

            obaasConfigPwd = bootstrapPassword("azn.bootstrap-users.user-password", obaasConfigPwd);

            users.saveAndFlush(new User(OBAAS_CONFIG, encoder.encode(obaasConfigPwd),
                    "ROLE_CONFIG_EDITOR,ROLE_USER"));
        }
    }

The bootstrap passwords come from external configuration. If bootstrap users are enabled and the password properties are missing, startup fails:

    private static String bootstrapPassword(String propertyName, String configuredPassword) {
        if (StringUtils.isNotBlank(configuredPassword)) {
            return configuredPassword;
        }
        throw new IllegalStateException(propertyName + " must be set when azn.bootstrap-users.enabled=true");
    }

That is much better than quietly creating default passwords.

Build the user-management API

The API is a normal Spring REST controller:

@RestController
@RequestMapping("/user/api/v1")
@Slf4j
public class DbUserRepoController {
public static final String ROLE_ADMIN = "ADMIN";
private static final String isAdminUser = "hasRole('ADMIN')";
private static final String isUser = "hasRole('USER')";
private static final Pattern PASSWORD_PATTERN =
Pattern.compile("^(?=.*[?!$%^*\-_])(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).{12,}$");
final UserRepository userRepository;
final PasswordEncoder passwordEncoder;
public DbUserRepoController(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}

The connect endpoint is a simple authenticated check:

    @PreAuthorize("hasAnyRole('ADMIN','USER','CONFIG_EDITOR')")
    @GetMapping("/connect")
    public ResponseEntity<String> connect() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        String authorities = userDetails.getAuthorities().toString();

        log.debug("/connect Username: {}", authentication.getName());
        log.debug("/connect Authorities: {}", userDetails.getAuthorities());
        log.debug("/connect Details: {}", authentication.getDetails());

        return new ResponseEntity<>(authorities, HttpStatus.OK);
    }

Creating a user is restricted to admins:

    @PreAuthorize(isAdminUser)
    @PostMapping("/createUser")
    public ResponseEntity<?> createUser(@RequestBody User user) {

        // If user exists return HTTP Status 409.
        Optional<User> checkUser = userRepository.findByUsernameIgnoreCase(user.getUsername());
        if (checkUser.isPresent()) {
            log.debug("User exists");
            return new ResponseEntity<>("User already exists", HttpStatus.CONFLICT);
        }

        if (!isValidPassword(user.getPassword())) {
            return new ResponseEntity<>("Password does not meet complexity requirements",
                    HttpStatus.UNPROCESSABLE_ENTITY);
        }

        if (StringUtils.isNotEmpty(user.getEmail())) {
            Optional<User> userAlreadyAssociatedWithEMail = userRepository.findByEmailIgnoreCase(user.getEmail());
            if (userAlreadyAssociatedWithEMail.isPresent()) {
                log.debug("User exists");
                return new ResponseEntity<>("Another user exists with same email", HttpStatus.CONFLICT);
            }
        }

        // Validate roles in RequestBody
        boolean hasValidRole = validateRole(user);
        log.debug("Valid role: {}", hasValidRole);

        // If Valid role create the user else send HTTP 422
        if (hasValidRole) {
            try {
                User users = userRepository.save(new User(
                        user.getUsername(),
                        passwordEncoder.encode(user.getPassword()),
                        user.getRoles(), user.getEmail()));
                return new ResponseEntity<>(users, HttpStatus.CREATED);
            } catch (Exception e) {
                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
            }
        } else {
            return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
        }
    }

There are a few important details in here:

  • Usernames are checked case-insensitively.
  • Duplicate emails are rejected.
  • Password complexity is enforced before saving.
  • Roles are validated against the enum.
  • Passwords are encoded before the entity is persisted.

The use of ResponseEntity.status(...).build() is intentional in the Boot 4 version. Spring Framework 7 adds ResponseEntity constructors that make new ResponseEntity<>(null, status) ambiguous, so empty responses should use the builder API.

The password validation is intentionally small and explicit:

    private boolean isValidPassword(String password) {
        return StringUtils.isNotBlank(password) && PASSWORD_PATTERN.matcher(password).matches();
    }

The role validation uses the enum:

    private boolean validateRole(User user) {
        try {
            if (StringUtils.isBlank(user.getRoles())) {
                return false;
            }
            Arrays.stream(user.getRoles().toUpperCase()
                    .replace("[", "")
                    .replace("]", "")
                    .replace(" ", "")
                    .split(","))
                    .map(UserRoles::valueOf)
                    .toList();
            return true;
        } catch (IllegalArgumentException illegalArgumentException) {
            return false;
        }
    }

Password changes are available to admins or to the user changing their own password:

    @PreAuthorize(isUser)
    @PutMapping("/updatePassword")
    public ResponseEntity<User> changePassword(@RequestBody UserInfoDto userInfo) {

        if (!isValidPassword(userInfo.password())) {
            return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
        }

        // Check if the user is a user with ADMIN
        SecurityContext securityContext = SecurityContextHolder.getContext();
        boolean isAdminUser = false;

        for (GrantedAuthority role : securityContext.getAuthentication().getAuthorities()) {
            if (role.getAuthority().contains(ROLE_ADMIN)) {
                isAdminUser = true;
            }
        }

        // TODO: Must update the correspondent secret??

        // If the username of the authenticated user matches the requestbody username,
        // or if it is a user with ROLE_ADMIN
        if ((userInfo.username().compareTo(securityContext.getAuthentication().getName()) == 0) || isAdminUser) {
            try {
                Optional<User> user = userRepository.findByUsername(userInfo.username());
                if (user.isPresent()) {
                    user.get().setPassword(passwordEncoder.encode(userInfo.password()));
                    userRepository.saveAndFlush(user.get());
                    return ResponseEntity.status(HttpStatus.OK).build();
                } else {
                    return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
                }
            } catch (Exception e) {
                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
            }
        } else {
            return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
        }
    }

And the forgot-password flow keeps OTP values hashed too:

    @PostMapping("/forgot")
    public ResponseEntity<UserInfoDto> createOTP(@RequestBody(required = true) User inUser) {
        if (StringUtils.isNotEmpty(inUser.getUsername()) && StringUtils.isNotEmpty(inUser.getOtp())) {
            try {
                Optional<User> user = userRepository.findByUsernameIgnoreCase(inUser.getUsername());
                if (user.isEmpty()) {
                    return new ResponseEntity<>(HttpStatus.NO_CONTENT);
                }
                user.get().setOtp(passwordEncoder.encode(inUser.getOtp()));
                userRepository.saveAndFlush(user.get());
                return ResponseEntity.status(HttpStatus.OK).build();
            } catch (Exception e) {
                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
            }

        } else {
            return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
        }
    }

The reset endpoint compares the provided OTP against the stored BCrypt hash:

    @PutMapping("/forgot")
    public ResponseEntity<?> reset(@RequestBody(required = true) User inUser) {
        if (StringUtils.isNotEmpty(inUser.getUsername()) && StringUtils.isNotEmpty(inUser.getOtp())
                && StringUtils.isNotEmpty(inUser.getPassword())) {
            if (!isValidPassword(inUser.getPassword())) {
                return new ResponseEntity<>("Password does not meet complexity requirements",
                        HttpStatus.UNPROCESSABLE_ENTITY);
            }
            try {
                Optional<User> user = userRepository.findByUsernameIgnoreCase(inUser.getUsername());
                if (user.isEmpty()) {
                    return new ResponseEntity<>("User does not exist", HttpStatus.NO_CONTENT);
                }

                if (StringUtils.isEmpty(user.get().getOtp())) {
                    return new ResponseEntity<>("OTP not  generated.", HttpStatus.CONFLICT);
                }

                if (StringUtils.isEmpty(user.get().getPassword())) {
                    return new ResponseEntity<>("Password not  provided.", HttpStatus.CONFLICT);
                }

                if (!passwordEncoder.matches(inUser.getOtp(), user.get().getOtp())) {
                    return new ResponseEntity<>("OTP does not match.", HttpStatus.CONFLICT);
                }

                if (passwordEncoder.matches(inUser.getPassword(), user.get().getPassword())) {
                    return new ResponseEntity<>("Password can not be same as previous.", HttpStatus.CONFLICT);
                }

                user.get().setOtp(null);
                user.get().setPassword(passwordEncoder.encode(inUser.getPassword()));
                userRepository.saveAndFlush(user.get());

                return new ResponseEntity<>("Password successfully changed.",
                        HttpStatus.OK);
            } catch (Exception e) {
                return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
            }

        } else {
            return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).build();
        }
    }

Again, the pattern is the same: accept secret values at the boundary, compare or encode them through Spring Security’s PasswordEncoder, and do not disclose them in responses.

Run Locally

Now let’s run the finished application. This section follows the Run Locally flow from the repository README, because it is the quickest way to prove that Oracle Database is available, Liquibase can create the schema, and the bootstrap users can be created.

Start with an Oracle database that the Liquibase admin user can connect to. For a disposable local Oracle database, you can use the same image family as the integration tests:

docker run --name azn-oracle --rm -p 1521:1521
-e ORACLE_PASSWORD='LocalSystem123!'
gvenzl/oracle-free:23.26.1-slim-faststart

In another terminal, configure the app. The USER_REPO password is the Oracle schema password that Liquibase assigns to the runtime database user. The bootstrap passwords are application user passwords and will be stored as BCrypt hashes in USER_REPO.USERS.

export AZN_DATASOURCE_URL='jdbc:oracle:thin:@//localhost:1521/FREEPDB1'
export AZN_LIQUIBASE_USERNAME='SYSTEM'
export AZN_LIQUIBASE_PASSWORD='LocalSystem123!'
export AZN_USER_REPO_USERNAME='USER_REPO'
export AZN_USER_REPO_PASSWORD='LocalUserRepo123!'
export ORACTL_ADMIN_PASSWORD='LocalAdmin123!'
export ORACTL_USER_PASSWORD='LocalUser123!'
export AZN_AUTHORIZATION_SERVER_DEFAULT_CLIENT_ENABLED=true
export AZN_AUTHORIZATION_SERVER_DEFAULT_CLIENT_SECRET='LocalClient123!'

Run the application:

mvn spring-boot:run

The app listens on http://localhost:8080.

Smoke Test API

Now we can walk through the finished code using the Smoke Test API flow from the README. These calls verify that the app is running, the Authorization Server endpoints are exposed, users can authenticate, and an admin can create and update users.

Before we call the API, set up the shell variables used by the walkthrough:

export BASE_URL='http://localhost:8080'
export ADMIN_USER='obaas-admin'
export ADMIN_PASSWORD='LocalAdmin123!'
export TEST_USER='readme-user'
export TEST_PASSWORD='ReadmeUser123!'
export TEST_PASSWORD_2='ReadmeUser456!'
export TEST_EMAIL='readme-user@example.com'

First, check the anonymous endpoints:

curl -i "$BASE_URL/actuator/health"
curl -i "$BASE_URL/user/api/v1/ping"
curl -i "$BASE_URL/.well-known/oauth-authorization-server"
curl -i "$BASE_URL/oauth2/jwks"

This verifies the basic shape of the running service. Actuator health is available, the unauthenticated ping endpoint works, the authorization server metadata is published, and the JWK endpoint is available for token verification.

Now authenticate with the bootstrap admin user:

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD" "$BASE_URL/user/api/v1/connect"
curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD" "$BASE_URL/user/api/v1/pingadmin"

The admin user was inserted during startup by the bootstrap initializer. The password came from configuration and was stored in Oracle Database as a BCrypt hash.

If you are rerunning this sequence, remove the sample user first:

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD"
-X DELETE
"$BASE_URL/user/api/v1/deleteUsername?username=$TEST_USER"

Create a user. Passwords must be at least 12 characters and include uppercase, lowercase, a number, and one of ?!$%^*-_.

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD"
-H 'Content-Type: application/json'
-d "{"username":"$TEST_USER","password":"$TEST_PASSWORD","roles":"ROLE_USER","email":"$TEST_EMAIL"}"
"$BASE_URL/user/api/v1/createUser"

This call exercises several things at once. Spring Security authorizes the admin request, the controller validates the role and password, the password is encoded with BCrypt, and JPA stores the user in USER_REPO.USERS.

Verify that the new user can authenticate and use a user endpoint:

curl -i -u "$TEST_USER:$TEST_PASSWORD" "$BASE_URL/user/api/v1/connect"
curl -i -u "$TEST_USER:$TEST_PASSWORD" "$BASE_URL/user/api/v1/pinguser"

Now find the user as an admin. Password and OTP fields are write-only and should not appear in the response body.

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD"
"$BASE_URL/user/api/v1/findUser?username=$TEST_USER"

That response is a useful security check. The API can accept sensitive values, but it should not echo them back.

Next, change the user’s role, then authenticate with the same user against the config-editor endpoint:

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD"
-X PUT
-H 'Content-Type: application/json'
-d "{"username":"$TEST_USER","roles":"ROLE_CONFIG_EDITOR"}"
"$BASE_URL/user/api/v1/changeRole"
curl -i -u "$TEST_USER:$TEST_PASSWORD" "$BASE_URL/user/api/v1/pingceditor"

That shows the method-security path working. The role stored in Oracle Database changes, Spring Security reads it through the JPA-backed UserDetailsService, and the endpoint authorization follows the updated authorities.

Change the user’s email:

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD"
-X PUT
-H 'Content-Type: application/json'
-d "{"username":"$TEST_USER","email":"updated-$TEST_EMAIL"}"
"$BASE_URL/user/api/v1/changeEmail"

Change the user’s password as the user, then authenticate with the new password:

curl -i -u "$TEST_USER:$TEST_PASSWORD"
-X PUT
-H 'Content-Type: application/json'
-d "{"username":"$TEST_USER","password":"$TEST_PASSWORD_2"}"
"$BASE_URL/user/api/v1/updatePassword"
curl -i -u "$TEST_USER:$TEST_PASSWORD_2" "$BASE_URL/user/api/v1/connect"

Again, the cleartext password only crosses the API boundary. The value stored in Oracle Database is a BCrypt hash.

Exercise the forgot-password flow. The OTP is accepted in the request but is stored as a BCrypt hash and is not disclosed by the lookup endpoint.

curl -i
-H 'Content-Type: application/json'
-d "{"username":"$TEST_USER","otp":"123456"}"
"$BASE_URL/user/api/v1/forgot"
curl -i "$BASE_URL/user/api/v1/forgot?username=$TEST_USER"
curl -i
-X PUT
-H 'Content-Type: application/json'
-d "{"username":"$TEST_USER","otp":"123456","password":"ReadmeReset123!"}"
"$BASE_URL/user/api/v1/forgot"

Finally, verify the opt-in local OAuth client with the client credentials flow:

curl -i -u 'azn-local-client:LocalClient123!'
-d 'grant_type=client_credentials'
-d 'scope=user.read'
"$BASE_URL/oauth2/token"

That call hits the Spring Authorization Server token endpoint and should return a bearer access token.

When you are finished, you can clean up the demo user:

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD"
-X DELETE
"$BASE_URL/user/api/v1/deleteUsername?username=$TEST_USER"

The most useful local endpoints are:

  • GET /actuator/health
  • GET /.well-known/oauth-authorization-server
  • GET /oauth2/jwks
  • GET /user/api/v1/ping

At this point we have an Oracle-backed user repository, Spring Security authentication against that repository, a working user-management API, and Spring Authorization Server issuing tokens.

Test against a real Oracle Database

The integration tests use Testcontainers with Oracle Free:

@Testcontainers
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
abstract class OracleIntegrationTestSupport {
private static final DockerImageName ORACLE_IMAGE =
DockerImageName.parse("gvenzl/oracle-free:23.26.1-slim-faststart");
private static final AtomicInteger POOL_SEQUENCE = new AtomicInteger();
private static final SecureRandom PASSWORD_RANDOM = new SecureRandom();
static final String BOOTSTRAP_PASSWORD = generatedPassword();
static final String USER_REPO_PASSWORD = generatedPassword();
private static final String ORACLE_PASSWORD = generatedPassword();
@Container
static final OracleContainer ORACLE = new OracleContainer(ORACLE_IMAGE)
.withPassword(ORACLE_PASSWORD);

The test support wires Spring Boot to the container:

    static void configureOracleProperties(DynamicPropertyRegistry registry) {
        String poolName = "AznServerOracleIT-" + POOL_SEQUENCE.incrementAndGet();

        registry.add("spring.datasource.url", ORACLE::getJdbcUrl);
        registry.add("spring.datasource.username", () -> "USER_REPO");
        registry.add("spring.datasource.password", () -> USER_REPO_PASSWORD);
        registry.add("spring.datasource.driver-class-name", ORACLE::getDriverClassName);
        registry.add("spring.datasource.type", () -> "oracle.ucp.jdbc.PoolDataSource");
        registry.add("spring.datasource.oracleucp.connection-factory-class-name",
                () -> "oracle.jdbc.pool.OracleDataSource");
        registry.add("spring.datasource.oracleucp.connection-pool-name", () -> poolName);
        registry.add("spring.datasource.oracleucp.initial-pool-size", () -> "1");
        registry.add("spring.datasource.oracleucp.min-pool-size", () -> "1");
        registry.add("spring.datasource.oracleucp.max-pool-size", () -> "4");
        registry.add("spring.liquibase.url", ORACLE::getJdbcUrl);
        registry.add("spring.liquibase.user", () -> "system");
        registry.add("spring.liquibase.password", ORACLE::getPassword);
        registry.add("spring.liquibase.parameters.userRepoPassword", () -> USER_REPO_PASSWORD);
        registry.add("spring.liquibase.enabled", () -> "true");
        registry.add("azn.bootstrap-users.enabled", () -> "true");
        registry.add("azn.bootstrap-users.admin-password", () -> BOOTSTRAP_PASSWORD);
        registry.add("azn.bootstrap-users.user-password", () -> BOOTSTRAP_PASSWORD);
        registry.add("azn.authorization-server.default-client.secret", () -> "TestLocalClientSecret123!");
        registry.add("eureka.client.enabled", () -> "false");
        registry.add("spring.cloud.discovery.enabled", () -> "false");
        registry.add("spring.cloud.service-registry.auto-registration.enabled", () -> "false");
    }

This is a big benefit of the Oracle Testcontainers support. The tests exercise the actual database behavior: Liquibase, schema creation, identity columns, BCrypt hashes stored in the table, and the Spring Boot datasource configuration.

The authorization server integration test verifies metadata, JWKs, and token issuance:

    @Test
    void exposesAuthorizationServerMetadataAndJwks() {
        ResponseEntity<String> metadata = restTemplate.getForEntity(
                url("/.well-known/oauth-authorization-server"), String.class);
        ResponseEntity<String> jwks = restTemplate.getForEntity(url("/oauth2/jwks"), String.class);

        assertThat(metadata.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(metadata.getBody()).contains("authorization_endpoint", "token_endpoint", "jwks_uri");
        assertThat(jwks.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(jwks.getBody()).contains(""keys"");
    }

    @Test
    void issuesClientCredentialsAccessToken() {
        HttpHeaders headers = new HttpHeaders();
        headers.setBasicAuth("integration-client", "integration-secret");
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("grant_type", "client_credentials");
        body.add("scope", "user.read");

        ResponseEntity<String> response = restTemplate.postForEntity(url("/oauth2/token"),
                new HttpEntity<>(body, headers), String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).contains("access_token", "Bearer");
    }

And the user API integration test verifies that secrets are not leaked:

    @Test
    void adminCanCreateAndFindUserWithoutLeakingSecrets() {
        TestRestTemplate admin = restTemplate.withBasicAuth("obaas-admin", BOOTSTRAP_PASSWORD);
        Map<String, String> request = Map.of(
                "username", "api-user",
                "password", "StrongPass123!",
                "roles", "ROLE_USER",
                "email", "api-user@example.com");

        ResponseEntity<String> createResponse = admin.postForEntity(url("/user/api/v1/createUser"),
                request, String.class);
        ResponseEntity<String> findResponse = admin.getForEntity(url("/user/api/v1/findUser?username=api-user"),
                String.class);

        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(createResponse.getBody()).contains("api-user").doesNotContain("StrongPass123!");
        assertThat(findResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(findResponse.getBody())
                .contains("api-user")
                .doesNotContain("StrongPass123!")
                .doesNotContain("otp");
        assertThat(userRepository.findByUsername("api-user"))
                .hasValueSatisfying(user -> {
                    assertThat(user.getPassword()).isNotEqualTo("StrongPass123!").startsWith("$2");
                    assertThat(passwordEncoder.matches("StrongPass123!", user.getPassword())).isTrue();
                });
    }

That is exactly the sort of test I like for this kind of application. It verifies behavior from the outside, and then checks the database-backed repository to confirm the security property we care about: the cleartext password was not stored.

Wrap up

We now have a working Spring Boot 4 authorization server backed by Oracle Database.

Spring Security and Spring Authorization Server give us the authentication framework, filter chains, method-level authorization, password encoding, OAuth2/OIDC endpoints, JWK support, and token issuance. Oracle Database gives us a proper persistent user repository with schema ownership, constraints, audit fields, identity columns, and real integration testing through Testcontainers.

There are a few production topics that deserve their own treatment, especially persistent signing keys, production registered-client storage, wallet-based database connectivity, deployment configuration, and observability. But the core pattern is here: let Spring Security handle security, let Oracle Database handle the durable user store, and keep the boundary between them small and explicit.

The important Spring Boot 4 changes in this version are mostly at the edges: updated starter names, Spring Security 7 package/API moves, Spring Framework 7 response builder usage for empty responses, and Testcontainers 2 artifact names. The application design stays pleasantly boring, which is exactly what I want from a framework upgrade.

Posted in Uncategorized | Tagged , , , , , , , | Leave a comment

Building an Authorization Server with Spring Boot 3 and Oracle Database

Hi everyone!

In this post I want to show you how to build a small authorization server using Spring Boot, Spring Security, Spring Authorization Server, and Oracle Database. The idea is simple: we want an application that can expose OAuth2/OIDC authorization-server endpoints, authenticate users whose details are stored in Oracle Database, and provide a small REST API for managing those users.

The complete code for this example is in the azn-server repository. In this article we will build it from scratch and look at the important pieces along the way.

One important note before we start: this version of the example is on the Spring Boot 3.x code line. The repository currently uses Spring Boot 3.5.x, Java 21, Spring Authorization Server through the Spring Boot starter, and the Oracle Spring Boot starters. A future post will cover the move to Spring Boot 4.x, including the associated new versions of Spring Framework and Spring Security.

What we are building

The application has three main responsibilities:

  • Expose OAuth2 and OpenID Connect endpoints using Spring Authorization Server.
  • Store application users in Oracle Database.
  • Provide a secured user-management API backed by Spring Security method security.

Spring Security gives us a lot here. It gives us the authentication framework, password encoding, UserDetailsService integration, filter chains, method-level authorization, role hierarchy support, and the authorization-server protocol endpoints. Oracle Database gives us a durable user repository, schema ownership, constraints, identity columns, auditing triggers, and a real database engine for integration tests.

This is the application shape:

  • Spring Boot starts the service.
  • Liquibase creates or updates the Oracle schema user.
  • Liquibase creates the USERS table and audit trigger.
  • JPA maps the USERS table into a User entity.
  • Spring Security loads users from that JPA repository.
  • Spring Authorization Server exposes the OAuth2/OIDC endpoints.
  • The REST API lets administrators manage users.

Let’s walk through the build.

Create the Spring Boot project

We start with a normal Spring Boot application. The important thing is to include the dependencies for web endpoints, Spring Authorization Server, actuator, JPA, Liquibase, Oracle UCP, Oracle wallet support, and the test stack.

Here is the dependency section from pom.xml:

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.liquibase</groupId>
            <artifactId>liquibase-core</artifactId>
        </dependency>
        <dependency>
            <groupId>com.oracle.database.spring</groupId>
            <artifactId>oracle-spring-boot-starter-ucp</artifactId>
            <version>${oracle-spring-boot-starter-version}</version>
        </dependency>
        <dependency>
            <groupId>com.oracle.database.spring</groupId>
            <artifactId>oracle-spring-boot-starter-wallet</artifactId>
            <version>${oracle-spring-boot-starter-version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-registry-prometheus</artifactId>
        </dependency>

        <!-- test dependencies -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-testcontainers</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>oracle-free</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

There are a couple of things to point out here.

First, spring-boot-starter-oauth2-authorization-server brings in the Spring Authorization Server support that provides the OAuth2/OIDC protocol endpoints. That means we do not have to hand-code token endpoints, metadata endpoints, JWK endpoints, or the protocol filter chain.

Second, the Oracle UCP starter gives us Oracle Universal Connection Pool integration through Spring Boot configuration. That is useful for real services because the database connection pool is not an afterthought – it is part of the application runtime.

Third, Liquibase owns the schema. Hibernate validates the schema, but Liquibase creates it. That is usually the right split for applications where the database is important enough to be managed deliberately.

Configure Oracle Database and Liquibase

The application uses two database identities:

  • A Liquibase/admin identity that can create and update the application schema.
  • A runtime schema user named USER_REPO that the application uses for normal database access.

Here is the application configuration:

server:
port: 8080
spring:
application:
name: @project.artifactId@
cloud:
# Discovery is opt-in so local runs and tests do not attempt to register.
discovery:
enabled: ${EUREKA_CLIENT_ENABLED:false}
threads:
virtual:
enabled: true
datasource:
# Runtime connections authenticate directly as the application schema user.
url: ${AZN_DATASOURCE_URL:${SPRING_DATASOURCE_URL:}}
username: ${AZN_USER_REPO_USERNAME:USER_REPO}
password: ${AZN_USER_REPO_PASSWORD}
driver-class-name: oracle.jdbc.OracleDriver
type: oracle.ucp.jdbc.PoolDataSource
oracleucp:
connection-factory-class-name: oracle.jdbc.pool.OracleDataSource
connection-pool-name: AznServerConnectionPool
initial-pool-size: 15
min-pool-size: 10
max-pool-size: 30
jpa:
# Keep database access inside service/controller methods, not view rendering.
open-in-view: false
hibernate:
# Liquibase owns schema changes; Hibernate only validates the result.
ddl-auto: validate
properties:
hibernate:
format_sql: true
show-sql: false
liquibase:
# Liquibase uses the admin account directly so it can create USER_REPO.
change-log: classpath:db/changelog/controller.yaml
url: ${AZN_DATASOURCE_URL:${SPRING_DATASOURCE_URL:}}
user: ${AZN_LIQUIBASE_USERNAME:${AZN_DATASOURCE_USERNAME:${SPRING_LIQUIBASE_USER:${SPRING_DATASOURCE_USERNAME:}}}}
password: ${AZN_LIQUIBASE_PASSWORD:${AZN_DATASOURCE_PASSWORD:${SPRING_LIQUIBASE_PASSWORD:${SPRING_DATASOURCE_PASSWORD:}}}}
parameters:
userRepoPassword: ${AZN_USER_REPO_PASSWORD}
enabled: ${RUN_LIQUIBASE:true}
azn:
bootstrap-users:
admin-password: ${ORACTL_ADMIN_PASSWORD:}
user-password: ${ORACTL_USER_PASSWORD:}
management:
endpoint:
health:
show-details: when_authorized
roles: ACTUATOR
endpoints:
web:
exposure:
# Keep actuator surface small; SecurityConfig protects non-health/info endpoints.
include: health,info,prometheus
eureka:
instance:
hostname: ${spring.application.name}
preferIpAddress: true
client:
# Supported for deployments, disabled by default for local/test startup.
service-url:
defaultZone: ${EUREKA_SERVER_ADDRESS:http://localhost:8761/eureka/}
fetch-registry: true
register-with-eureka: true
enabled: ${EUREKA_CLIENT_ENABLED:false}
# Logging
logging:
level:
org.springframework.web: INFO
org.springframework.security: INFO
oracle.obaas.aznserver: INFO

I like this arrangement because the runtime user is not the same as the schema-management user. Liquibase gets the elevated account it needs to create and manage USER_REPO, and the running application connects as USER_REPO. That is a clean security boundary.

Oracle UCP is configured as the datasource type:

    type: oracle.ucp.jdbc.PoolDataSource
    oracleucp:
      connection-factory-class-name: oracle.jdbc.pool.OracleDataSource
      connection-pool-name: AznServerConnectionPool
      initial-pool-size: 15
      min-pool-size: 10
      max-pool-size: 30

That gives us Oracle-aware connection pooling with very little Spring code. We get the operational benefit of a pool that is meant for Oracle Database, while still configuring it in the usual Spring Boot way.

Create the schema with Liquibase

The changelog controller is small:

---
databaseChangeLog:
- include:
file: classpath:db/changelog/dbuser.sql
- include:
file: classpath:db/changelog/table.sql
- include:
file: classpath:db/changelog/trigger.sql

The first changelog creates and maintains the USER_REPO database user:

-- liquibase formatted sql
-- changeset az_admin:initial_user endDelimiter:/ runAlways:true runOnChange:true
DECLARE
l_user VARCHAR2(255);
l_tblspace VARCHAR2(255);
BEGIN
BEGIN
SELECT username INTO l_user FROM DBA_USERS WHERE USERNAME='USER_REPO';
EXCEPTION WHEN no_data_found THEN
EXECUTE IMMEDIATE 'CREATE USER "USER_REPO" IDENTIFIED BY "${userRepoPassword}"';
END;
EXECUTE IMMEDIATE 'ALTER USER "USER_REPO" IDENTIFIED BY "${userRepoPassword}" ACCOUNT UNLOCK';
SELECT default_tablespace INTO l_tblspace FROM dba_users WHERE username = 'USER_REPO';
EXECUTE IMMEDIATE 'ALTER USER "USER_REPO" QUOTA UNLIMITED ON ' || l_tblspace;
EXECUTE IMMEDIATE 'GRANT CONNECT TO "USER_REPO"';
EXECUTE IMMEDIATE 'GRANT RESOURCE TO "USER_REPO"';
EXECUTE IMMEDIATE 'ALTER USER "USER_REPO" DEFAULT ROLE CONNECT,RESOURCE';
END;
/
--rollback drop user "USER_REPO" cascade;

The next changelog creates the user table:

-- liquibase formatted sql
-- changeset az_admin:initial_table
CREATE TABLE USER_REPO.USERS
(
USER_ID NUMBER GENERATED ALWAYS AS IDENTITY (START WITH 1 CACHE 20),
PASSWORD VARCHAR2(255 CHAR) NOT NULL,
ROLES VARCHAR2(255 CHAR) NOT NULL,
USERNAME VARCHAR2(255 CHAR) NOT NULL,
CREATED_ON TIMESTAMP DEFAULT SYSDATE,
CREATED_BY VARCHAR2 (100) DEFAULT COALESCE(
REGEXP_SUBSTR(SYS_CONTEXT('USERENV','CLIENT_IDENTIFIER'),'^[^:]*'),
SYS_CONTEXT('USERENV','SESSION_USER')),
UPDATED_ON TIMESTAMP ,
UPDATED_BY VARCHAR2 (255),
PRIMARY KEY (USER_ID),
CONSTRAINT USERNAME_UQ UNIQUE (USERNAME)
) LOGGING;
COMMENT ON TABLE USER_REPO.USERS is 'Application user repository for OAuth2/OIDC user management';
COMMENT ON COLUMN USER_REPO.USERS.PASSWORD is 'BCrypt hash of the application user password; never store cleartext';
ALTER TABLE USER_REPO.USERS ADD EMAIL VARCHAR2(255 CHAR) NULL;
ALTER TABLE USER_REPO.USERS ADD OTP VARCHAR2(255 CHAR) NULL;
COMMENT ON COLUMN USER_REPO.USERS.OTP is 'BCrypt hash of the one-time password; never store cleartext';
--rollback DROP TABLE USER_REPO.USERS;

There are some good Oracle Database features doing useful work here:

  • GENERATED ALWAYS AS IDENTITY gives us database-managed user ids.
  • The unique constraint protects usernames at the database level.
  • Column comments document sensitive columns right where they live.
  • The table belongs to the USER_REPO schema, not to the application admin user.

Finally, we add a small audit trigger:

-- liquibase formatted sql
-- changeset az_admin:initial_trigger endDelimiter:/
CREATE OR REPLACE EDITIONABLE TRIGGER "USER_REPO"."AUDIT_TRG" BEFORE
UPDATE ON USER_REPO.USERS FOR EACH ROW
BEGIN
:NEW.UPDATED_ON := SYSDATE;
:NEW.UPDATED_BY := COALESCE(REGEXP_SUBSTR(SYS_CONTEXT('USERENV', 'CLIENT_IDENTIFIER'), '^[^:]*'), SYS_CONTEXT('USERENV', 'SESSION_USER'));
END;
/
--rollback DROP TRIGGER "USER_REPO"."AUDIT_TRG";

This is a nice example of letting the database enforce something that belongs in the database. Every update gets audit fields set consistently, whether the update came from this Spring application or from another controlled path later.

Map the Oracle table to a JPA entity

Now we need a JPA entity for the USER_REPO.USERS table.

// Copyright (c) 2023, 2026, Oracle and/or its affiliates.
package oracle.obaas.aznserver.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Entity
@Table(name = "users", schema = "user_repo")
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = {"password", "otp"})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "USER_ID")
private Long userId;
@Column(name = "USERNAME", nullable = false)
private String username;
/**
* Stores the BCrypt hash that is persisted in USER_REPO.USERS.PASSWORD.
* Cleartext passwords may be accepted at API boundaries, but they must be
* encoded before this entity is saved.
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Column(name = "PASSWORD", nullable = false, length = 255)
private String password;
@Column(name = "ROLES", nullable = false)
private String roles;
@Column(name = "EMAIL")
private String email;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Column(name = "OTP")
private String otp;
/**
* Create a user object.
*
* @param username The username.
* @param password The encoded password hash for persistence.
* @param roles The roles assigned the user, as a comma separated list, e.g.
* "ROLE_USER,ROLE_ADMIN".
*/
public User(String username, String password, String roles) {
this.username = username;
this.password = password;
this.roles = roles;
}
// This constructor should only be used during testing with a mock repository,
// when we need to set the id manually
public User(long userId, String username, String password, String roles) {
this(username, password, roles);
this.userId = userId;
}
/**
* Create a user object.
*
* @param username The username.
* @param password The encoded password hash for persistence.
* @param roles The roles assigned the user, as a comma separated list, e.g.
* "ROLE_USER,ROLE_ADMIN".
* @param email The email associated with user account.
*/
public User(String username, String password, String roles, String email) {
this(username, password, roles);
this.email = email;
}
}

There are two small but important security choices in this class.

First, password and otp are write-only for JSON serialization. That means the API can accept these values in request bodies, but it will not serialize them back into responses.

Second, Lombok’s @ToString excludes password and otp. That helps prevent secrets from being accidentally written into logs.

The repository is exactly what we want from Spring Data JPA: small, declarative, and focused on the queries the service needs.

// Copyright (c) 2022, 2023, Oracle and/or its affiliates.
package oracle.obaas.aznserver.repository;
import java.util.List;
import java.util.Optional;
import oracle.obaas.aznserver.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByUsernameIgnoreCase(String username);
Optional<User> findByUserId(Long userId);
List<User> findUsersByUsernameStartsWithIgnoreCase(String username);
Optional<User> findByEmailIgnoreCase(String email);
}

This is one of the places where Spring Data JPA shines. The method names communicate intent, Spring implements the queries, and the application code stays readable.

Adapt the database user to Spring Security

Spring Security authenticates with UserDetails. Our database user is a domain object, so we wrap it in a SecurityUser.

// Copyright (c) 2022, 2026, Oracle and/or its affiliates.
package oracle.obaas.aznserver.model;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
public class SecurityUser implements UserDetails {
private final User user;
public SecurityUser(User user) {
this.user = user;
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
if (StringUtils.isBlank(user.getRoles())) {
return List.of();
}
return Arrays.stream(user
.getRoles()
.split(","))
.map(SimpleGrantedAuthority::new)
.toList();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}

Then we create a UserDetailsService backed by the JPA repository:

// Copyright (c) 2022, 2026, Oracle and/or its affiliates.
package oracle.obaas.aznserver.service;
import oracle.obaas.aznserver.model.SecurityUser;
import oracle.obaas.aznserver.repository.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class JpaUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public JpaUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SecurityUser user = userRepository
.findByUsername(username)
.map(SecurityUser::new)
.orElseThrow(() -> new UsernameNotFoundException("Authentication failed"));
return user;
}
}

This is the bridge between Oracle Database and Spring Security. Once this service exists, Spring Security can authenticate users stored in USER_REPO.USERS.

Configure Spring Security and Spring Authorization Server

The security configuration is the heart of the application. It does several things:

  • Creates a role hierarchy.
  • Enables method security.
  • Creates a dedicated authorization-server filter chain.
  • Creates a separate actuator filter chain.
  • Creates a stateless API filter chain.
  • Provides password encoding.
  • Provides development/test signing keys.
  • Optionally creates a local OAuth client.

Here is the role hierarchy:

    public static final String ROLE_HIERARCHY = "ROLE_ADMIN > ROLE_USERn"
            + "ROLE_ADMIN > ROLE_CONFIG_EDITORn"
            + "ROLE_CONFIG_EDITOR > ROLE_USER";

    /**
     * Configure a role hierarchy such that ADMIN "includes"/implies USER.
     * 
     * @return the hierarchy.
     */

    @Bean
    public RoleHierarchy roleHierarchy() {
        return RoleHierarchyImpl.fromHierarchy(ROLE_HIERARCHY);
    }

    /**
     * Configure method security to use the role hierarchy.
     * 
     * @param roleHierarchy injected by Spring.
     * @return The MethodSecurityExpressionHandler.
     */
    @Bean
    public MethodSecurityExpressionHandler methodSecurityExpressionHandler(RoleHierarchy roleHierarchy) {
        DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
        expressionHandler.setRoleHierarchy(roleHierarchy);
        return expressionHandler;
    }

Role hierarchy is one of those Spring Security features that is easy to miss but very useful. If an administrator should also be treated as a user, we do not have to duplicate every role check everywhere. We can teach Spring Security that ROLE_ADMIN includes ROLE_USER.

The authorization server gets its own filter chain:

    /**
     * Authorization Server endpoints use their own filter chain so OAuth protocol
     * handling does not inherit API-specific stateless settings.
     *
     * @param http HttpSecurity injected by Spring.
     * @return the SecurityFilterChain.
     * @throws Exception if unable to create the chain.
     */
    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
            throws Exception {
        log.debug("In authorizationServerSecurityFilterChain");
        OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
                OAuth2AuthorizationServerConfigurer.authorizationServer();

        http
            .securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
            .with(authorizationServerConfigurer, authorizationServer ->
                authorizationServer.oidc(Customizer.withDefaults()))
            .authorizeHttpRequests((authorize) -> authorize
                .requestMatchers("/.well-known/**", "/oauth2/jwks").permitAll()
                .anyRequest().authenticated())
            .csrf((csrf) -> csrf.ignoringRequestMatchers(authorizationServerConfigurer.getEndpointsMatcher()))
            .exceptionHandling((exceptions) -> exceptions.defaultAuthenticationEntryPointFor(
                new LoginUrlAuthenticationEntryPoint("/login"),
                new MediaTypeRequestMatcher(MediaType.TEXT_HTML)));
        return http.build();
    }

This is where Spring Authorization Server does a lot of heavy lifting. The endpoints matcher identifies the protocol endpoints, OIDC support is enabled, and the well-known metadata and JWK endpoints are allowed anonymously.

The user-management API has a different shape. It is stateless and uses HTTP Basic:

    /**
     * Create a SecurityFilterChain for the user-management API.
     * @param http HttpSecurity injected by Spring. 
     * @param userDetailsService the JPA-backed user details service.
     * @return the SecurityFilterChain.
     * @throws Exception if unable to create the chain.
     */
    @Bean
    @Order(3)
    public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http, UserDetailsService userDetailsService)
            throws Exception {
        log.debug("In apiSecurityFilterChain");
        http
            .securityMatcher("/user/api/**", "/error/**")
            .authorizeHttpRequests((authorize) -> authorize
                .requestMatchers("/error/**").permitAll()
                .requestMatchers("/user/api/v1/ping").permitAll()
                .requestMatchers("/user/api/v1/forgot").permitAll()
                .anyRequest().authenticated()
            )
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .httpBasic(Customizer.withDefaults())
            .userDetailsService(userDetailsService);
        // The user-management API is stateless and does not use browser sessions or cookies.
        http.csrf(csrf -> csrf.disable());
        return http.build();
    }

The separation between the authorization-server chain and the API chain matters. The OAuth2/OIDC endpoints are protocol endpoints. The user API is a REST API. They have different security needs, so they get different chains.

The authentication provider uses our JPA-backed user details service and a BCrypt password encoder:

    /**
     * Create an Authentication Provider for our UserDetailsService.
     * @param userDetailsService the JPA-backed user details service.
     * @param passwordEncoder password encoder for stored password hashes.
     * @return the AuthenticationProvider.
     */
    @Bean
    public DaoAuthenticationProvider authenticationProvider(UserDetailsService userDetailsService,
            PasswordEncoder passwordEncoder) {
        DaoAuthenticationProvider auth = new DaoAuthenticationProvider(userDetailsService);
        auth.setPasswordEncoder(passwordEncoder);
        return auth;
    }

And the password encoder is:

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

Passwords in the API enter as clear text at the boundary, but they are stored as BCrypt hashes in Oracle Database. That is exactly the line we want: clear text only at the edge, hashes at rest.

For local development and tests, the application can create an opt-in registered client:

    /**
     * Create an opt-in local client for test and developer-only contexts.
     *
     * Production deployments should configure registered clients explicitly using
     * Spring Boot's authorization-server client properties.
     *
     * @param passwordEncoder password encoder for the client secret.
     * @param clientSecret configured client secret.
     * @return a local RegisteredClientRepository.
     */
    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnProperty(prefix = "azn.authorization-server.default-client", name = "enabled",
            havingValue = "true")
    public RegisteredClientRepository localRegisteredClientRepository(PasswordEncoder passwordEncoder,
            @Value("${azn.authorization-server.default-client.secret:}") String clientSecret) {
        if (!StringUtils.hasText(clientSecret)) {
            throw new IllegalStateException("azn.authorization-server.default-client.secret must be set when "
                    + "azn.authorization-server.default-client.enabled=true");
        }
        RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
                .clientId("azn-local-client")
                .clientSecret(passwordEncoder.encode(clientSecret))
                .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
                .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
                .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
                .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
                .redirectUri("http://127.0.0.1:8080/login/oauth2/code/azn-local-client")
                .scope(OidcScopes.OPENID)
                .scope("user.read")
                .clientSettings(ClientSettings.builder().requireProofKey(true).build())
                .build();
        return new InMemoryRegisteredClientRepository(registeredClient);
    }

Notice that this is opt-in. That is intentional. Local convenience is useful, but production registered clients should be configured deliberately.

The JWK source is also local by default:

    /**
     * Provide process-local signing keys for development and tests.
     *
     * Production deployments should replace this bean with persistent key material
     * so tokens remain verifiable across restarts and rolling deploys.
     *
     * @return the JWK source.
     */
    @Bean
    @ConditionalOnMissingBean
    public JWKSource<SecurityContext> jwkSource() {
        log.warn("Using process-local generated RSA signing keys. Configure a persistent JWKSource bean for "
                + "production so issued tokens remain verifiable across restarts and rolling deploys.");
        RSAKey rsaKey = generateRsa();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
    }

That is fine for development and tests. For production, you would provide persistent signing key material so tokens remain verifiable across restarts and rolling deployments.

Bootstrap the first users

An authorization server needs at least one user to get started. This application creates three bootstrap users on startup by default:

  • obaas-admin
  • obaas-user
  • obaas-config

The initializer is in the main application class:

    @Bean
    @ConditionalOnProperty(prefix = "azn.bootstrap-users", name = "enabled", havingValue = "true",
            matchIfMissing = true)
    ApplicationRunner userStoreInitializer(UserRepository users, PasswordEncoder passwordEncoder,
            @Value("${azn.bootstrap-users.admin-password:}") String adminPassword,
            @Value("${azn.bootstrap-users.user-password:}") String userPassword) {
        return args -> initUserStore(users, passwordEncoder, adminPassword, userPassword);
    }

And the implementation creates missing users with BCrypt-encoded passwords:

    public static void initUserStore(UserRepository users, PasswordEncoder encoder,
            String adminPassword, String userPassword) {
        log.debug("ENTER initUserStore");

        String obaasAdminPwd = adminPassword;
        String obaasUserPwd = userPassword;
        String obaasConfigPwd = obaasUserPwd;

        // Check for obaas-user, if not existent create the user
        if (users.findByUsername(OBAAS_USER).isEmpty()) {
            log.debug("Creating user obaas-user");

            obaasUserPwd = bootstrapPassword("azn.bootstrap-users.user-password", obaasUserPwd);

            users.saveAndFlush(new User(OBAAS_USER, encoder.encode(obaasUserPwd),
                    "ROLE_USER"));
        }

        // Check for obaas-admin, if not existent create the user
        Optional<User> adminUser = users.findByUsername(OBAAS_ADMIN);
        if (adminUser.isEmpty()) {
            log.debug("Creating user obaas-admin");

            obaasAdminPwd = bootstrapPassword("azn.bootstrap-users.admin-password", obaasAdminPwd);

            users.saveAndFlush(new User(OBAAS_ADMIN, encoder.encode(obaasAdminPwd),
                    "ROLE_ADMIN,ROLE_CONFIG_EDITOR,ROLE_USER"));
        }

        // Check for obaas-config, if not existent create the user with the same pwd as
        // obaas-user
        if (users.findByUsernameIgnoreCase(OBAAS_CONFIG).isEmpty()) {
            log.debug("Creating user obaas-config");

            obaasConfigPwd = bootstrapPassword("azn.bootstrap-users.user-password", obaasConfigPwd);

            users.saveAndFlush(new User(OBAAS_CONFIG, encoder.encode(obaasConfigPwd),
                    "ROLE_CONFIG_EDITOR,ROLE_USER"));
        }
    }

The bootstrap passwords come from external configuration. If bootstrap users are enabled and the password properties are missing, startup fails:

    private static String bootstrapPassword(String propertyName, String configuredPassword) {
        if (StringUtils.isNotBlank(configuredPassword)) {
            return configuredPassword;
        }
        throw new IllegalStateException(propertyName + " must be set when azn.bootstrap-users.enabled=true");
    }

That is much better than quietly creating default passwords.

Build the user-management API

The API is a normal Spring REST controller:

@RestController
@RequestMapping("/user/api/v1")
@Slf4j
public class DbUserRepoController {
public static final String ROLE_ADMIN = "ADMIN";
private static final String isAdminUser = "hasRole('ADMIN')";
private static final String isUser = "hasRole('USER')";
private static final Pattern PASSWORD_PATTERN =
Pattern.compile("^(?=.*[?!$%^*\-_])(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z]).{12,}$");
final UserRepository userRepository;
final PasswordEncoder passwordEncoder;
public DbUserRepoController(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}

The connect endpoint is a simple authenticated check:

    @PreAuthorize("hasAnyRole('ADMIN','USER','CONFIG_EDITOR')")
    @GetMapping("/connect")
    public ResponseEntity<String> connect() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        String authorities = userDetails.getAuthorities().toString();

        log.debug("/connect Username: {}", authentication.getName());
        log.debug("/connect Authorities: {}", userDetails.getAuthorities());
        log.debug("/connect Details: {}", authentication.getDetails());

        return new ResponseEntity<>(authorities, HttpStatus.OK);
    }

Creating a user is restricted to admins:

    @PreAuthorize(isAdminUser)
    @PostMapping("/createUser")
    public ResponseEntity<?> createUser(@RequestBody User user) {

        // If user exists return HTTP Status 409.
        Optional<User> checkUser = userRepository.findByUsernameIgnoreCase(user.getUsername());
        if (checkUser.isPresent()) {
            log.debug("User exists");
            return new ResponseEntity<>("User already exists", HttpStatus.CONFLICT);
        }

        if (!isValidPassword(user.getPassword())) {
            return new ResponseEntity<>("Password does not meet complexity requirements",
                    HttpStatus.UNPROCESSABLE_ENTITY);
        }

        if (StringUtils.isNotEmpty(user.getEmail())) {
            Optional<User> userAlreadyAssociatedWithEMail = userRepository.findByEmailIgnoreCase(user.getEmail());
            if (userAlreadyAssociatedWithEMail.isPresent()) {
                log.debug("User exists");
                return new ResponseEntity<>("Another user exists with same email", HttpStatus.CONFLICT);
            }
        }

        // Validate roles in RequestBody
        boolean hasValidRole = validateRole(user);
        log.debug("Valid role: {}", hasValidRole);

        // If Valid role create the user else send HTTP 422
        if (hasValidRole) {
            try {
                User users = userRepository.save(new User(
                        user.getUsername(),
                        passwordEncoder.encode(user.getPassword()),
                        user.getRoles(), user.getEmail()));
                return new ResponseEntity<>(users, HttpStatus.CREATED);
            } catch (Exception e) {
                return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
            }
        } else {
            return new ResponseEntity<>(null, HttpStatus.UNPROCESSABLE_ENTITY);
        }
    }

There are a few important details in here:

  • Usernames are checked case-insensitively.
  • Duplicate emails are rejected.
  • Password complexity is enforced before saving.
  • Roles are validated against the enum.
  • Passwords are encoded before the entity is persisted.

The password validation is intentionally small and explicit:

    private boolean isValidPassword(String password) {
        return StringUtils.isNotBlank(password) && PASSWORD_PATTERN.matcher(password).matches();
    }

The role validation uses the enum:

    private boolean validateRole(User user) {
        try {
            if (StringUtils.isBlank(user.getRoles())) {
                return false;
            }
            Arrays.stream(user.getRoles().toUpperCase()
                    .replace("[", "")
                    .replace("]", "")
                    .replace(" ", "")
                    .split(","))
                    .map(UserRoles::valueOf)
                    .toList();
            return true;
        } catch (IllegalArgumentException illegalArgumentException) {
            return false;
        }
    }

Password changes are available to admins or to the user changing their own password:

    @PreAuthorize(isUser)
    @PutMapping("/updatePassword")
    public ResponseEntity<User> changePassword(@RequestBody UserInfoDto userInfo) {

        if (!isValidPassword(userInfo.password())) {
            return new ResponseEntity<>(null, HttpStatus.UNPROCESSABLE_ENTITY);
        }

        // Check if the user is a user with ADMIN
        SecurityContext securityContext = SecurityContextHolder.getContext();
        boolean isAdminUser = false;

        for (GrantedAuthority role : securityContext.getAuthentication().getAuthorities()) {
            if (role.getAuthority().contains(ROLE_ADMIN)) {
                isAdminUser = true;
            }
        }

        // TODO: Must update the correspondent secret??

        // If the username of the authenticated user matches the requestbody username,
        // or if it is a user with ROLE_ADMIN
        if ((userInfo.username().compareTo(securityContext.getAuthentication().getName()) == 0) || isAdminUser) {
            try {
                Optional<User> user = userRepository.findByUsername(userInfo.username());
                if (user.isPresent()) {
                    user.get().setPassword(passwordEncoder.encode(userInfo.password()));
                    userRepository.saveAndFlush(user.get());
                    return new ResponseEntity<>(null, HttpStatus.OK);
                } else {
                    return new ResponseEntity<>(null, HttpStatus.NO_CONTENT);
                }
            } catch (Exception e) {
                return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
            }
        } else {
            return new ResponseEntity<>(null, HttpStatus.FORBIDDEN);
        }
    }

And the forgot-password flow keeps OTP values hashed too:

    @PostMapping("/forgot")
    public ResponseEntity<UserInfoDto> createOTP(@RequestBody(required = true) User inUser) {
        if (StringUtils.isNotEmpty(inUser.getUsername()) && StringUtils.isNotEmpty(inUser.getOtp())) {
            try {
                Optional<User> user = userRepository.findByUsernameIgnoreCase(inUser.getUsername());
                if (user.isEmpty()) {
                    return new ResponseEntity<>(HttpStatus.NO_CONTENT);
                }
                user.get().setOtp(passwordEncoder.encode(inUser.getOtp()));
                userRepository.saveAndFlush(user.get());
                return new ResponseEntity<>(null,
                        HttpStatus.OK);
            } catch (Exception e) {
                return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
            }

        } else {
            return new ResponseEntity<>(null, HttpStatus.UNPROCESSABLE_ENTITY);
        }
    }

The reset endpoint compares the provided OTP against the stored BCrypt hash:

    @PutMapping("/forgot")
    public ResponseEntity<?> reset(@RequestBody(required = true) User inUser) {
        if (StringUtils.isNotEmpty(inUser.getUsername()) && StringUtils.isNotEmpty(inUser.getOtp())
                && StringUtils.isNotEmpty(inUser.getPassword())) {
            if (!isValidPassword(inUser.getPassword())) {
                return new ResponseEntity<>("Password does not meet complexity requirements",
                        HttpStatus.UNPROCESSABLE_ENTITY);
            }
            try {
                Optional<User> user = userRepository.findByUsernameIgnoreCase(inUser.getUsername());
                if (user.isEmpty()) {
                    return new ResponseEntity<>("User does not exist", HttpStatus.NO_CONTENT);
                }

                if (StringUtils.isEmpty(user.get().getOtp())) {
                    return new ResponseEntity<>("OTP not  generated.", HttpStatus.CONFLICT);
                }

                if (StringUtils.isEmpty(user.get().getPassword())) {
                    return new ResponseEntity<>("Password not  provided.", HttpStatus.CONFLICT);
                }

                if (!passwordEncoder.matches(inUser.getOtp(), user.get().getOtp())) {
                    return new ResponseEntity<>("OTP does not match.", HttpStatus.CONFLICT);
                }

                if (passwordEncoder.matches(inUser.getPassword(), user.get().getPassword())) {
                    return new ResponseEntity<>("Password can not be same as previous.", HttpStatus.CONFLICT);
                }

                user.get().setOtp(null);
                user.get().setPassword(passwordEncoder.encode(inUser.getPassword()));
                userRepository.saveAndFlush(user.get());

                return new ResponseEntity<>("Password successfully changed.",
                        HttpStatus.OK);
            } catch (Exception e) {
                return new ResponseEntity<>(null, HttpStatus.INTERNAL_SERVER_ERROR);
            }

        } else {
            return new ResponseEntity<>(null, HttpStatus.UNPROCESSABLE_ENTITY);
        }
    }

Again, the pattern is the same: accept secret values at the boundary, compare or encode them through Spring Security’s PasswordEncoder, and do not disclose them in responses.

Run Locally

Now let’s run the finished application. This section follows the Run Locally flow from the repository README, because it is the quickest way to prove that Oracle Database is available, Liquibase can create the schema, and the bootstrap users can be created.

Start with an Oracle database that the Liquibase admin user can connect to. For a disposable local Oracle database, you can use the same image family as the integration tests:

docker run --name azn-oracle --rm -p 1521:1521
-e ORACLE_PASSWORD='LocalSystem123!'
gvenzl/oracle-free:23.26.1-slim-faststart

In another terminal, configure the app. The USER_REPO password is the Oracle schema password that Liquibase assigns to the runtime database user. The bootstrap passwords are application user passwords and will be stored as BCrypt hashes in USER_REPO.USERS.

export AZN_DATASOURCE_URL='jdbc:oracle:thin:@//localhost:1521/FREEPDB1'
export AZN_LIQUIBASE_USERNAME='SYSTEM'
export AZN_LIQUIBASE_PASSWORD='LocalSystem123!'
export AZN_USER_REPO_USERNAME='USER_REPO'
export AZN_USER_REPO_PASSWORD='LocalUserRepo123!'
export ORACTL_ADMIN_PASSWORD='LocalAdmin123!'
export ORACTL_USER_PASSWORD='LocalUser123!'
export AZN_AUTHORIZATION_SERVER_DEFAULT_CLIENT_ENABLED=true
export AZN_AUTHORIZATION_SERVER_DEFAULT_CLIENT_SECRET='LocalClient123!'

Run the application:

mvn spring-boot:run

The app listens on http://localhost:8080.

Smoke Test API

Now we can walk through the finished code using the Smoke Test API flow from the README. These calls verify that the app is running, the Authorization Server endpoints are exposed, users can authenticate, and an admin can create and update users.

Before we call the API, set up the shell variables used by the walkthrough:

export BASE_URL='http://localhost:8080'
export ADMIN_USER='obaas-admin'
export ADMIN_PASSWORD='LocalAdmin123!'
export TEST_USER='readme-user'
export TEST_PASSWORD='ReadmeUser123!'
export TEST_PASSWORD_2='ReadmeUser456!'
export TEST_EMAIL='readme-user@example.com'

First, check the anonymous endpoints:

curl -i "$BASE_URL/actuator/health"
curl -i "$BASE_URL/user/api/v1/ping"
curl -i "$BASE_URL/.well-known/oauth-authorization-server"
curl -i "$BASE_URL/oauth2/jwks"

This verifies the basic shape of the running service. Actuator health is available, the unauthenticated ping endpoint works, the authorization server metadata is published, and the JWK endpoint is available for token verification.

Now authenticate with the bootstrap admin user:

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD" "$BASE_URL/user/api/v1/connect"
curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD" "$BASE_URL/user/api/v1/pingadmin"

The admin user was inserted during startup by the bootstrap initializer. The password came from configuration and was stored in Oracle Database as a BCrypt hash.

If you are rerunning this sequence, remove the sample user first:

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD"
-X DELETE
"$BASE_URL/user/api/v1/deleteUsername?username=$TEST_USER"

Create a user. Passwords must be at least 12 characters and include uppercase, lowercase, a number, and one of ?!$%^*-_.

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD"
-H 'Content-Type: application/json'
-d "{"username":"$TEST_USER","password":"$TEST_PASSWORD","roles":"ROLE_USER","email":"$TEST_EMAIL"}"
"$BASE_URL/user/api/v1/createUser"

This call exercises several things at once. Spring Security authorizes the admin request, the controller validates the role and password, the password is encoded with BCrypt, and JPA stores the user in USER_REPO.USERS.

Verify that the new user can authenticate and use a user endpoint:

curl -i -u "$TEST_USER:$TEST_PASSWORD" "$BASE_URL/user/api/v1/connect"
curl -i -u "$TEST_USER:$TEST_PASSWORD" "$BASE_URL/user/api/v1/pinguser"

Now find the user as an admin. Password and OTP fields are write-only and should not appear in the response body.

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD"
"$BASE_URL/user/api/v1/findUser?username=$TEST_USER"

That response is a useful security check. The API can accept sensitive values, but it should not echo them back.

Next, change the user’s role, then authenticate with the same user against the config-editor endpoint:

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD"
-X PUT
-H 'Content-Type: application/json'
-d "{"username":"$TEST_USER","roles":"ROLE_CONFIG_EDITOR"}"
"$BASE_URL/user/api/v1/changeRole"
curl -i -u "$TEST_USER:$TEST_PASSWORD" "$BASE_URL/user/api/v1/pingceditor"

That shows the method-security path working. The role stored in Oracle Database changes, Spring Security reads it through the JPA-backed UserDetailsService, and the endpoint authorization follows the updated authorities.

Change the user’s email:

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD"
-X PUT
-H 'Content-Type: application/json'
-d "{"username":"$TEST_USER","email":"updated-$TEST_EMAIL"}"
"$BASE_URL/user/api/v1/changeEmail"

Change the user’s password as the user, then authenticate with the new password:

curl -i -u "$TEST_USER:$TEST_PASSWORD"
-X PUT
-H 'Content-Type: application/json'
-d "{"username":"$TEST_USER","password":"$TEST_PASSWORD_2"}"
"$BASE_URL/user/api/v1/updatePassword"
curl -i -u "$TEST_USER:$TEST_PASSWORD_2" "$BASE_URL/user/api/v1/connect"

Again, the cleartext password only crosses the API boundary. The value stored in Oracle Database is a BCrypt hash.

Exercise the forgot-password flow. The OTP is accepted in the request but is stored as a BCrypt hash and is not disclosed by the lookup endpoint.

curl -i
-H 'Content-Type: application/json'
-d "{"username":"$TEST_USER","otp":"123456"}"
"$BASE_URL/user/api/v1/forgot"
curl -i "$BASE_URL/user/api/v1/forgot?username=$TEST_USER"
curl -i
-X PUT
-H 'Content-Type: application/json'
-d "{"username":"$TEST_USER","otp":"123456","password":"ReadmeReset123!"}"
"$BASE_URL/user/api/v1/forgot"

Finally, verify the opt-in local OAuth client with the client credentials flow:

curl -i -u 'azn-local-client:LocalClient123!'
-d 'grant_type=client_credentials'
-d 'scope=user.read'
"$BASE_URL/oauth2/token"

That call hits the Spring Authorization Server token endpoint and should return a bearer access token.

When you are finished, you can clean up the demo user:

curl -i -u "$ADMIN_USER:$ADMIN_PASSWORD"
-X DELETE
"$BASE_URL/user/api/v1/deleteUsername?username=$TEST_USER"

The most useful local endpoints are:

  • GET /actuator/health
  • GET /.well-known/oauth-authorization-server
  • GET /oauth2/jwks
  • GET /user/api/v1/ping

At this point we have an Oracle-backed user repository, Spring Security authentication against that repository, a working user-management API, and Spring Authorization Server issuing tokens.

Test against a real Oracle Database

The integration tests use Testcontainers with Oracle Free:

@Testcontainers
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
abstract class OracleIntegrationTestSupport {
private static final DockerImageName ORACLE_IMAGE =
DockerImageName.parse("gvenzl/oracle-free:23.26.1-slim-faststart");
private static final AtomicInteger POOL_SEQUENCE = new AtomicInteger();
private static final SecureRandom PASSWORD_RANDOM = new SecureRandom();
static final String BOOTSTRAP_PASSWORD = generatedPassword();
static final String USER_REPO_PASSWORD = generatedPassword();
private static final String ORACLE_PASSWORD = generatedPassword();
@Container
static final OracleContainer ORACLE = new OracleContainer(ORACLE_IMAGE)
.withPassword(ORACLE_PASSWORD);

The test support wires Spring Boot to the container:

    static void configureOracleProperties(DynamicPropertyRegistry registry) {
        String poolName = "AznServerOracleIT-" + POOL_SEQUENCE.incrementAndGet();

        registry.add("spring.datasource.url", ORACLE::getJdbcUrl);
        registry.add("spring.datasource.username", () -> "USER_REPO");
        registry.add("spring.datasource.password", () -> USER_REPO_PASSWORD);
        registry.add("spring.datasource.driver-class-name", ORACLE::getDriverClassName);
        registry.add("spring.datasource.type", () -> "oracle.ucp.jdbc.PoolDataSource");
        registry.add("spring.datasource.oracleucp.connection-factory-class-name",
                () -> "oracle.jdbc.pool.OracleDataSource");
        registry.add("spring.datasource.oracleucp.connection-pool-name", () -> poolName);
        registry.add("spring.datasource.oracleucp.initial-pool-size", () -> "1");
        registry.add("spring.datasource.oracleucp.min-pool-size", () -> "1");
        registry.add("spring.datasource.oracleucp.max-pool-size", () -> "4");
        registry.add("spring.liquibase.url", ORACLE::getJdbcUrl);
        registry.add("spring.liquibase.user", () -> "system");
        registry.add("spring.liquibase.password", ORACLE::getPassword);
        registry.add("spring.liquibase.parameters.userRepoPassword", () -> USER_REPO_PASSWORD);
        registry.add("spring.liquibase.enabled", () -> "true");
        registry.add("azn.bootstrap-users.enabled", () -> "true");
        registry.add("azn.bootstrap-users.admin-password", () -> BOOTSTRAP_PASSWORD);
        registry.add("azn.bootstrap-users.user-password", () -> BOOTSTRAP_PASSWORD);
        registry.add("azn.authorization-server.default-client.secret", () -> "TestLocalClientSecret123!");
        registry.add("eureka.client.enabled", () -> "false");
        registry.add("spring.cloud.discovery.enabled", () -> "false");
        registry.add("spring.cloud.service-registry.auto-registration.enabled", () -> "false");
    }

This is a big benefit of the Oracle Testcontainers support. The tests exercise the actual database behavior: Liquibase, schema creation, identity columns, BCrypt hashes stored in the table, and the Spring Boot datasource configuration.

The authorization server integration test verifies metadata, JWKs, and token issuance:

    @Test
    void exposesAuthorizationServerMetadataAndJwks() {
        ResponseEntity<String> metadata = restTemplate.getForEntity(
                url("/.well-known/oauth-authorization-server"), String.class);
        ResponseEntity<String> jwks = restTemplate.getForEntity(url("/oauth2/jwks"), String.class);

        assertThat(metadata.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(metadata.getBody()).contains("authorization_endpoint", "token_endpoint", "jwks_uri");
        assertThat(jwks.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(jwks.getBody()).contains(""keys"");
    }

    @Test
    void issuesClientCredentialsAccessToken() {
        HttpHeaders headers = new HttpHeaders();
        headers.setBasicAuth("integration-client", "integration-secret");
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("grant_type", "client_credentials");
        body.add("scope", "user.read");

        ResponseEntity<String> response = restTemplate.postForEntity(url("/oauth2/token"),
                new HttpEntity<>(body, headers), String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).contains("access_token", "Bearer");
    }

And the user API integration test verifies that secrets are not leaked:

    @Test
    void adminCanCreateAndFindUserWithoutLeakingSecrets() {
        TestRestTemplate admin = restTemplate.withBasicAuth("obaas-admin", BOOTSTRAP_PASSWORD);
        Map<String, String> request = Map.of(
                "username", "api-user",
                "password", "StrongPass123!",
                "roles", "ROLE_USER",
                "email", "api-user@example.com");

        ResponseEntity<String> createResponse = admin.postForEntity(url("/user/api/v1/createUser"),
                request, String.class);
        ResponseEntity<String> findResponse = admin.getForEntity(url("/user/api/v1/findUser?username=api-user"),
                String.class);

        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(createResponse.getBody()).contains("api-user").doesNotContain("StrongPass123!");
        assertThat(findResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(findResponse.getBody())
                .contains("api-user")
                .doesNotContain("StrongPass123!")
                .doesNotContain("otp");
        assertThat(userRepository.findByUsername("api-user"))
                .hasValueSatisfying(user -> {
                    assertThat(user.getPassword()).isNotEqualTo("StrongPass123!").startsWith("$2");
                    assertThat(passwordEncoder.matches("StrongPass123!", user.getPassword())).isTrue();
                });
    }

That is exactly the sort of test I like for this kind of application. It verifies behavior from the outside, and then checks the database-backed repository to confirm the security property we care about: the cleartext password was not stored.

Wrap up

We now have a working Spring Boot 3 authorization server backed by Oracle Database.

Spring Security and Spring Authorization Server give us the authentication framework, filter chains, method-level authorization, password encoding, OAuth2/OIDC endpoints, JWK support, and token issuance. Oracle Database gives us a proper persistent user repository with schema ownership, constraints, audit fields, identity columns, and real integration testing through Testcontainers.

There are a few production topics that deserve their own treatment, especially persistent signing keys, production registered-client storage, wallet-based database connectivity, deployment configuration, and observability. But the core pattern is here: let Spring Security handle security, let Oracle Database handle the durable user store, and keep the boundary between them small and explicit.

In a future post, we will take this example forward to the Spring Boot 4.x line and look at the associated new versions of Spring Framework and Spring Security.

Posted in Uncategorized | Tagged , , , , , , | Leave a comment

Building a Spring Boot Starter for Oracle Spatial

Hi everyone!

Over the last few days I have been working on a new Spring Boot starter for Oracle Spatial.  I did this work with two other real developers, and three AI coding assistants, and I thought it would be interesting to write up the story of how it came together.

This was a good example of how building a starter is not just about getting code to compile or getting a sample app to run. It is also about API shape, developer expectations, reviewer feedback, naming, documentation, tests, and all of those little choices that decide whether something feels natural or awkward once another developer actually tries to use it.

We ended up in a much better place than where we started, but not because the first design was perfect. We got there because the reviews were good, the feedback was honest, and we were willing to change direction once it was clear the API could be better.

So this post is a bit of a behind-the-scenes look at that process.

What we were trying to build

The goal sounded simple enough on paper:

  • create a Spring Boot starter for Oracle Spatial
  • make it easy to work with SDO_GEOMETRY
  • keep the programming model GeoJSON-first
  • provide a sample application that shows realistic use

The kinds of queries we had in mind were the kinds of queries almost everyone reaches for first:

  • store a point or polygon
  • fetch it back as GeoJSON
  • find landmarks near a point
  • find landmarks within or interacting with a polygon

So from the beginning this was going to involve Oracle Spatial operators such as:

  • SDO_UTIL.FROM_GEOJSON
  • SDO_UTIL.TO_GEOJSON
  • SDO_FILTER
  • SDO_RELATE
  • SDO_WITHIN_DISTANCE
  • SDO_NN
  • SDO_GEOM.SDO_DISTANCE

We also wanted a sample app that felt approachable. We used a small San Francisco landmark dataset so the sample would be easy to understand and a little bit fun to play with.

Where we started

The first version of the starter leaned in the direction that I think a lot of us would naturally start with: helper utilities.

We had one piece focused on converting GeoJSON to and from Oracle Spatial, and another piece focused on generating the bits of SQL you need for common spatial predicates and projections.

On one level, that worked.

It absolutely solved real problems:

  • you did not have to remember the exact SDO_UTIL.FROM_GEOJSON(...) call
  • you did not have to hand-type the common predicate shapes every time
  • you could centralize the default SRID and distance unit

If you already like building SQL with JdbcClient, it was useful.

But there was a catch. The public API was still basically returning strings. And once that got called out in review, it was hard to ignore.

The review comment that changed everything

The most important reviewer comment was not about a syntax error or a missing test. It was about the shape of the API.

The feedback was basically: if this is a Spring Boot starter, why is the main experience a SQL string builder?

That was the right question.

It is one thing to have Spring-managed beans. It is another thing entirely to have a Spring-native programming model.

At that point the starter was Spring-managed, but not really Spring-native. Yes, you could inject the beans, but what you got back were still string fragments that you had to splice together yourself.

That triggered the main redesign.

The moment that made the redesign easier

One thing that helped a lot is that the original API had not been released yet.

That is a huge advantage.

It meant we did not need to protect old method names or preserve an API shape just because it already existed. We were free to ask a better question: if we were designing this from scratch for Spring developers, what should it look like?

Once we reframed it that way, the answer became much clearer.

What we changed

The big change was moving away from public string-builder beans and toward one main Spring JDBC integration bean:

  • OracleSpatialJdbcOperations

Instead of treating Oracle Spatial as a collection of string helper methods, we moved to a design where application code injects one bean and then creates typed spatial query parts:

  • SpatialGeometry
  • SpatialExpression
  • SpatialPredicate
  • SpatialRelationMask

This ended up being a much better fit for the way Spring JDBC code is usually written.

You still write SQL. We did not try to invent a whole DSL. But the spatial pieces now carry more meaning and stay connected to the bind process instead of floating around as anonymous fragments.

That was the key design improvement.

Why this felt better almost immediately

One of the things I liked about the redesign is that the sample application got better almost immediately once the API got better.

That is usually a good sign.

When an API is awkward, the sample tends to look awkward too. You can hide that for a little while, but not for long.

With the redesigned API, the service code started to read much more naturally. In the sample’s findNear flow, for example, we now create:

  • a SpatialGeometry
  • a distance expression
  • a within-distance predicate

and then build the SQL around those named pieces.

That may sound like a small thing, but it makes a big difference when someone is reading the sample for the first time and trying to understand the intended usage pattern.

Instead of “here is some mysterious Oracle SQL,” the code reads more like “here is the geometry we are searching around, here is the distance we want to project, and here is the predicate we want to apply.”

That is a much better teaching story.

The sample app became part of the design process

I think sometimes people talk about sample applications as though they are just an afterthought. In practice, for a starter like this, the sample is part of the design process.

It answers questions like:

  • does the API feel natural in a real service?
  • are the method names understandable?
  • can we explain this to somebody without too much ceremony?
  • does the code read like Spring, or does it read like a thin wrapper over raw SQL?

Our sample app is a simple REST service built around landmarks in San Francisco.

It has endpoints for:

  • creating a landmark
  • fetching a landmark by id
  • finding nearby landmarks
  • finding landmarks inside or interacting with a polygon

We also spent some time improving the seed data because I wanted the sample to be a little more recognizable and useful. So the sample now includes landmarks like:

  • Ferry Building
  • Union Square
  • Golden Gate Park
  • Oracle Park
  • Salesforce Tower
  • Transamerica Pyramid
  • Coit Tower

That may not be the most “architectural” part of the work, but it helps make the sample feel real instead of abstract.

One small enum that mattered more than I expected

Another design decision that turned out to matter a lot was replacing raw relation-mask strings with an enum:

  • SpatialRelationMask

This came directly out of review feedback.

The problem with raw strings is obvious once someone points it out: a developer can type something like "INTERSECTS" and everything looks fine until runtime, when Oracle tells them that is not a valid mask.

That is exactly the kind of thing a good starter should help with.

By introducing an enum, we made that part of the API:

  • safer
  • easier to discover
  • harder to misuse

It is a small API element, but it made the whole thing feel more deliberate.

Distance became first-class because it had to

One of the early reviews also pointed out that if we wanted to serve real spatial use cases, we needed first-class support for distance calculations.

That was absolutely right.

A lot of real applications want some version of:

  • find nearby things
  • return the distance
  • order by distance

If the starter handled filtering but not distance calculation, it would always feel like it stopped just short of the most useful scenario.

So SDO_GEOM.SDO_DISTANCE became part of the main API design rather than something developers would have to improvise themselves.

I think that made the starter much more credible for real use.

Documentation ended up being more important than I expected

I do not mean that in a generic “docs matter” way. I mean that for this starter in particular, the documentation had to explain a mental model, not just list methods.

The most useful documentation change we made was adding a clear distinction between:

  • what gets injected as a Spring bean
  • what gets created per query

That turned out to be the right way to explain the design.

You inject:

  • OracleSpatialJdbcOperations

And then per query you create:

  • SpatialGeometry
  • SpatialExpression
  • SpatialPredicate

Once we started explaining it that way, the docs got much easier to follow.

We also added concrete query pattern examples for:

  • inserts
  • GeoJSON projection
  • filter + relate
  • distance-ordered proximity queries

Those examples matter because they show how the design is actually supposed to be used. For a starter, that is every bit as important as the Javadoc.

Some of the polish came from the less glamorous review comments

Not all of the useful review feedback was high-level architecture. Some of it was the kind of practical feedback that makes software much more solid.

A few examples:

The sample needed better error handling

At one point the sample app would throw an exception and return a 500 if a caller passed an invalid spatial relation mask.

That is a perfectly believable bug in a sample. It is also exactly the kind of thing a reviewer should call out, because the sample is part of the product story.

We fixed it so that invalid masks now produce a proper 400 Bad Request with a useful message.

The tests needed to be more Spring-native too

We got feedback to use @ServiceConnection in the Testcontainers-based tests, and that was good advice. It made the tests more consistent with current Spring Boot style and reduced some manual wiring.

We also adjusted the integration test to run as the app user rather than system, which is a much better representation of how the code should actually work.

The SQL setup needed to be repeatable in CI

This is one of those things that only becomes obvious once CI starts yelling at you.

We hit issues with setup SQL being run more than once and colliding with existing objects or metadata. That led to a round of cleanup to make the test setup more idempotent and more robust.

That is not the glamorous part of building a starter, but it is absolutely part of shipping one.

What we deliberately did not do

There were also some things we chose not to do in this round of work.

I think that is worth talking about because saying “not yet” is often part of good design.

We did not try to eliminate SQL entirely

Even after the redesign, application code still assembles SQL statements with JdbcClient.

That was intentional.

We wanted to make the API more Spring-native, but not disappear into a custom DSL or pretend SQL no longer exists. This is still Spring JDBC. SQL is still the right abstraction level. The important improvement was to stop making the public API itself a string-builder API.

There is still room to improve the ergonomics in the future, especially around making it harder to forget a bind contributor. Reviewers called that out too, and I think they are right. But that felt like a v2 refinement, not something we needed to solve before this version was useful.

We did not add every possible spatial operation

Another good review point was that buffer generation would be useful too. I agree.

But once again, that felt like feature expansion rather than a cleanup item for this iteration.

There is always a temptation to keep adding one more thing once the codebase is open and fresh in your mind. In this case I think the right move was to get the core API shape right, get the sample and docs into good condition, and leave some room for future work.

What I think we ended up with

At the end of all of this, what we have is not just a set of helper methods. It is a small but coherent Spring Boot story for Oracle Spatial.

The final result includes:

  • a starter that auto-configures a Spring JDBC-oriented spatial bean
  • a typed API for spatial query parts
  • safer handling of SDO_RELATE masks
  • first-class distance support
  • a sample REST app that demonstrates realistic usage
  • docs that explain the mental model, not just the method list
  • integration tests that run against Oracle AI Database 26ai Free with Testcontainers

And maybe the biggest thing for me is that it now feels like something I would want another Spring developer to pick up and try.

That was not as true of the first version.

What we are already thinking about for v2

Even though I feel good about where this landed, we have also been pretty careful to write down the things that did not belong in this iteration.

There are at least two clear areas for a possible v2.

The first is improving the ergonomics around binding and query composition.

Right now the design is much better than the original string-builder approach, but there is still a pattern like this:

spatial.bind(
        jdbcClient.sql("select ... " + distance.selection("distance")
                + " from landmarks where " + within.clause()),
        distance, within)

That is a reasonable place to be for a JDBC-oriented starter, but it is not hard to imagine a future version that tightens that up further and makes it harder to forget a bind contributor.

The second is expanding the supported spatial operations.

One of the most obvious candidates there is buffer generation with SDO_GEOM.SDO_BUFFER. That came up during review, and I think it is a very good candidate for a future enhancement. There are also broader questions about whether repository-style integrations or richer mapping options might make sense once we have real experience with how people use the current API.

But I want to be careful here. Just because we can imagine a v2 does not mean we should rush into it. What I would really like at this point is to get feedback from actual users first.

I would much rather learn from people who try the starter on real spatial workloads than guess too aggressively about what the next abstraction should be. Maybe the next thing we need is buffer support. Maybe it is better row mapping. Maybe it is a tighter JdbcClient integration. Maybe it is something we have not even thought about yet.

So yes, we do have a v2 plan taking shape. But before we take that next step, I would love to hear from users and see how this first version holds up in practice.

Working with AI coding assistants

Like many people today, I am doing more and more work with AI Coding assistants.  For this piece of work, these are the participants and the roles they played:

  • me: collaborated with GPT to write the specification, set the standards, conventions, etc., guided the whole process, read and reviewed code myself, helped make, or made design decisions
  • GPT-5.4: acted as the primary developer, wrote most of the code, reflected on its own work, helped write and refine the specification, processed review comments and planned, helped with the design
  • Claude Code: acted as a “senior architect” who reviewed the code with a focus on technical aspects and gave detailed feedback and recommendations
  • Gemini (3 thinking/pro): acted as the “product manager” who reviewed the project from the point of view of how well it addressed the need, if it exposed the right features and capabilities, and how useful it would be for a spatial user
  • two human colleagues: acted as reviewers and developers who provided valuable feedback on the code and the design

As we worked through the project together, I saved a lot of the generated plans and reviews in the .ai directory because I think those are valuable artifacts.  We’re obviously using AI more, and we have no intention of hiding it, so why not save these for future use?  Seems like the right thing to do.  We did have a conversation about this before coming to the conclusion that we should save them and put them in the public repo.

I also want to mention that there were several cycles in the project.  We started discussing the requirements and writing a plan.  We reviewed this and iterated on it two or three times before we ever started writing any code.  By “we”, I mean me and the three AIs.  It was interesting to see the slightly different opinions of the AIs and the different areas they focused their attention on.  Just like human developers, the different opinions and insights were useful to help us get to a better result.

After we started development, we continued to cycle through reviews, feedback, updates.  At one point, as discussed above, we decided to redesign – this was a result of human feedback.  The AIs certainly did a good job, and they are getting better all the time, but I do still feel that they are not as good at doing novel things as they are at doing things that have been done before, and they perhaps don’t consider (or at least mention) the implications of architectural decisions as much as some human developers do, at least in my experience to date.  But I am sure they will continue to evolve rapidly.

I guess I’d just say – if you have not tried working in a team with multiple AI coding assistants yet, give it a go!

Why I wanted to write this up

I wanted to tell this story because I think it is a pretty normal and healthy example of how good API work actually happens.

You start with an idea.

You build something that is useful but not yet quite right.

Somebody asks a question that exposes the weakness in the design.

You resist it for a minute.

Then you realize they are right.

Then the real design work starts.

That is basically what happened here.

And I think the result is better precisely because we were willing to let the reviews change the direction of the code rather than just polish the original design.

Wrapping up

I am really happy with how this turned out.

Not because it is finished forever. It is not. There are still good future directions here. But the core design now feels solid, and the sample, tests, and docs all tell a coherent story.

That is what I wanted from this starter.

If you are working with Spring Boot and Oracle Database, and you have spatial use cases in mind, I think this gives you a pretty nice starting point. And if you are designing your own starter or library, maybe the bigger lesson here is that reviewer feedback is not just something to “address.” Sometimes it is the thing that helps you find the real design.

More on this soon.

Posted in Uncategorized | Tagged , , , , , , , , , , | Leave a comment

Building a Simple Spatial Web App with the New Spring Boot Starter for the Spatial Features in Oracle AI Database 26ai

Hi everyone!

In this post, I will show how to run the new Spring Boot starter for the Spatial features in Oracle AI Database 26ai by using the sample application and connecting it to a simple web application front end.

The starter makes it easier to build Spring Boot applications that work with Oracle Spatial data and expose that data through familiar REST APIs. For this example, I am using Oracle AI Database 26ai Free, the Spatial sample application from the Spring Cloud Oracle repository, and a small React front end that displays landmark data on a map.

The app will do three things:

  • load landmarks inside an initial San Francisco polygon
  • find the nearest landmarks to a point you click on the map
  • search the landmarks inside the current visible map area

The Spring Boot starter for the Spatial features in Oracle AI Database 26ai is a v1 release, and I want to say that clearly up front. If you build spatial applications with Spring Boot and Oracle Database, I would really love feedback on what works well, what feels awkward, and what you would want to see next.

A few important things to note before we get into the code:

  • the work is merged into main
  • the new starter artifacts may still be ahead of the next Maven Central release
  • for this walkthrough, I am running the sample application from a source checkout of the spring-cloud-oracle repository

That last point matters because I want to give you steps you can run today, not steps that depend on a release that may not have shipped yet.

Before You Begin

For this walkthrough, you will need:

  • Java 21 or newer
  • Maven
  • Node.js and npm
  • Docker
  • a local checkout of the spring-cloud-oracle repository
  • a local checkout of this frontend companion repository

You will also need Oracle AI Database 26ai Free running locally.

If you already have Oracle AI Database 26ai Free running and you already have a user/schema you want to use for testing, you can adapt the setup below. I am going to show the exact steps that worked for me from a clean local environment.

Start Oracle AI Database 26ai Free

Before you run the container, sign in to Oracle Container Registry and accept the Oracle terms for the Oracle AI Database 26ai Free image. If you skip that step, the image pull may fail with an authentication or authorization error.

Then start Oracle AI Database 26ai Free in a container:

 docker run -d \
   --name oracle-free \
   -p 1521:1521 \
   -e ORACLE_PWD=Welcome1 \
   container-registry.oracle.com/database/free:latest

The first startup will take a few minutes. You can check the logs to see when startup is complete:

docker logs -f oracle-free

Wait until you see the message DATABASE IS READY TO USE before continuing.

Create the Sample User

For local testing, the sample worked most reliably when I created a dedicated user for it and granted the permissions needed for the spatial objects and index.

Connect as the system user and run:

 create user spatialsample identified by spatialsample;
 grant create session to spatialsample;
 grant create table to spatialsample;
 grant create view to spatialsample;
 grant create sequence to spatialsample;
 grant create procedure to spatialsample;
 grant unlimited tablespace to spatialsample;
 grant create indextype to spatialsample;
 grant create operator to spatialsample;
 grant execute on mdsys.sdo_geometry to spatialsample;
 grant execute on mdsys.sdo_util to spatialsample;
 grant execute on mdsys.sdo_geom to spatialsample;
 grant execute on mdsys.sdo_cs to spatialsample;
 grant execute on mdsys.spatial_index_v2 to spatialsample;

I am calling this out explicitly because this is one of the places where a setup detail can quietly derail the rest of the sample. Creating the user and grants up front made the rest of the walkthrough much more predictable.

The explicit grant execute on mdsys.spatial_index_v2 may look a little unusual at first glance, but it is there for a reason. Oracle’s Spatial index documentation calls out the need for EXECUTE privilege on the index type and its implementation type when creating a spatial index.

Create the Schema and Seed Data

Next, connect as the new spatialsample user and set up the schema and initial sample data:

create table if not exists landmarks (
id number primary key,
name varchar2(200) not null,
category varchar2(100) not null,
geometry mdsys.sdo_geometry not null
);
delete from user_sdo_geom_metadata
where table_name = 'LANDMARKS'
and column_name = 'GEOMETRY';
insert into user_sdo_geom_metadata (table_name, column_name, diminfo, srid)
values (
'LANDMARKS',
'GEOMETRY',
mdsys.sdo_dim_array(
mdsys.sdo_dim_element('LONG', -180, 180, 0.005),
mdsys.sdo_dim_element('LAT', -90, 90, 0.005)
),
4326
);
create index if not exists landmarks_spatial_idx
on landmarks (geometry)
indextype is mdsys.spatial_index_v2;
delete from landmarks;
insert into landmarks (id, name, category, geometry)
values
(1, 'Ferry Building', 'MARKET', sdo_util.from_geojson('{"type":"Point","coordinates":[-122.3933,37.7955]}', null, 4326)),
(2, 'Union Square', 'PLAZA', sdo_util.from_geojson('{"type":"Point","coordinates":[-122.4074,37.7879]}', null, 4326)),
(3, 'Golden Gate Park', 'PARK', sdo_util.from_geojson('{"type":"Polygon","coordinates":[[[-122.511,37.771],[-122.454,37.771],[-122.454,37.768],[-122.511,37.768],[-122.511,37.771]]]}', null, 4326)),
(4, 'Oracle Park', 'STADIUM', sdo_util.from_geojson('{"type":"Point","coordinates":[-122.3893,37.7786]}', null, 4326)),
(5, 'Salesforce Tower', 'SKYSCRAPER', sdo_util.from_geojson('{"type":"Point","coordinates":[-122.3969,37.7897]}', null, 4326));
commit;

At this point, I have:

  • a landmarks table
  • spatial metadata in USER_SDO_GEOM_METADATA
  • a spatial index
  • seeded landmarks to query

This is one of those places where I think being explicit helps. It is very easy to gloss over schema setup in a sample, but for Oracle Spatial this part is important:

  • the table exists
  • the geometry metadata exists
  • the spatial index exists
  • the sample data is already in place

Without those pieces, the rest of the walkthrough is much harder to follow.

I also like this version of the setup script because it is easier to rerun while you are testing locally. It clears the metadata entry and seed data before recreating the parts of the sample that need to be there.

Run the Official Sample Application

The backend for this walkthrough is the official sample application from the Spring Cloud Oracle repository.

The command that worked reliably for me was:

cd ~/spring-cloud-oracle/database/starters/oracle-spring-boot-starter-samples/oracle-spring-boot-sample-spatial
mvn spring-boot:run \
-Dspring-boot.run.arguments="--spring.datasource.url=jdbc:oracle:thin:@//localhost:1521/FREEPDB1
--spring.datasource.username=spatialsample --spring.datasource.password=spatialsample"

Note that I split that string for readability, but you need to put it all on one line!

There are two details here that are worth calling out:

  • I ran the command from the sample module directory
  • passing the datasource properties through spring-boot.run.arguments was the most reliable local approach

That is worth preserving exactly. I tried a couple of other ways to pass the datasource values, and this was the one that consistently worked for me.

If that starts cleanly, the sample backend should now be listening on http://localhost:9002.

What the Sample Already Gives Us

Before I write any frontend code, it is worth looking at what the sample already gives us.

The REST API is small and very easy to explain:

  • POST /landmarks
  • GET /landmarks/{id}
  • GET /landmarks/near
  • POST /landmarks/within

That is a useful shape for a tutorial because it is already enough to demonstrate:

  • GeoJSON in and GeoJSON out
  • nearest-neighbor style point searches
  • polygon-based area searches

Add Local CORS for the Frontend

When I first tried to connect the frontend to the sample application, I hit a CORS problem. For local development, the fix was to add @CrossOrigin to LandmarkController.java in the sample application.

Add this import:

 import org.springframework.web.bind.annotation.CrossOrigin;

Then annotate the controller:

 @CrossOrigin(origins = "http://localhost:5173")
 @RestController
 public class LandmarkController {
 // ...
 }
 

This is just for local development. I would not copy this unchanged into a production application.

Also, if you skip this and go straight to the frontend, the browser error is not especially helpful. You will usually just see a generic “failed to fetch” style error when the request to /landmarks/within gets blocked.

Smoke-Test the REST API

Before building the frontend, I like to make sure the API is behaving the way I expect.

Let’s start with a few direct calls to the sample backend.

A simple lookup by id:

curl http://localhost:9002/landmarks/1

A nearest-neighbor style request using a compact GeoJSON point in the query string:


 curl --get "http://localhost:9002/landmarks/near" \
 --data-urlencode 'geometry={"type":"Point","coordinates":[-122.3933,37.7955]}' \
 --data-urlencode 'distance=2000' \
 --data-urlencode 'limit=3'

A polygon search:


 curl -X POST http://localhost:9002/landmarks/within \
 -H "Content-Type: application/json" \
 -d '{
 "geometry":"{\"type\":\"Polygon\",\"coordinates\":[[[-122.515,37.75],[-122.35,37.75],[-122.35,37.808],[-122.515,37.808],[-122.515,37.75]]]}",
 "mask":"ANYINTERACT"
 }'
 

At this point, it is useful to verify that the backend is returning the kind of data we expect before moving on to the browser. The following example shows the API working from the command line.

If these calls work, the rest of the article gets a lot simpler. At that point, we know the database is up, the sample app is talking to it, and the spatial endpoints are behaving before the browser gets involved.

The Frontend Project

For the UI, I used a separate React application built with Vite, TypeScript, and React Leaflet.

Here is the full layout of the web directory before I walk through the main pieces:


 web/
   src/
     App.tsx
     components/
       MapView.tsx
     lib/
       api.ts
       geo.ts
 

I kept this frontend separate from the sample app because it makes the browser-side behavior easier to explain, and it is a very common setup that most people will recognize immediately.

I also like this split for demo applications because it keeps the browser code and the backend code easy to reason about independently.

Start the Frontend

In this frontend project, start the development server like this:


 cd ~/spatial-starter-v1-blog/web
 VITE_API_BASE_URL=http://localhost:9002 npm run dev
 

By default, Vite runs on http://localhost:5173, which is why that is the origin I allowed in the local CORS annotation above.

The Initial Map Load

For the initial screen, I did not want a generic “list all landmarks” endpoint. Since this is a spatial application, I wanted the initial load to already be spatially scoped.

So instead, I defined an opening polygon for a useful part of San Francisco:

  • north: Fisherman’s Wharf
  • east: Oracle Park
  • south: Mission
  • west: the western edge of Golden Gate Park

In the frontend, that polygon is just a small GeoJSON object:

const INITIAL_WEST = -122.515;
const INITIAL_EAST = -122.35;
const INITIAL_SOUTH = 37.75;
const INITIAL_NORTH = 37.808;
export const initialSearchPolygon = {
type: "Feature",
properties: {
label: "Initial San Francisco search area"
},
geometry: {
type: "Polygon",
coordinates: [[
[INITIAL_WEST, INITIAL_SOUTH],
[INITIAL_EAST, INITIAL_SOUTH],
[INITIAL_EAST, INITIAL_NORTH],
[INITIAL_WEST, INITIAL_NORTH],
[INITIAL_WEST, INITIAL_SOUTH]
]]
}
};

Then I post that polygon to /landmarks/within with the ANYINTERACT mask.

That gives the initial screen a much more natural map behavior. Instead of “show me everything,” the app starts with “show me the landmarks in this area.”

One small helper worth showing here is geometryToString. The sample accepts the geometry field as a JSON string, not as a nested object, so the frontend needs to serialize the GeoJSON before sending it.

export function geometryToString(feature: Feature): string {
return JSON.stringify(feature.geometry);
}

With that helper in place, the API call stays pretty small:

export async function fetchLandmarksWithin(feature: Feature<Geometry>, mask: string): Promise<Landmark[]> {
const response = await fetch(`${API_BASE_URL}/landmarks/within`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
geometry: geometryToString(feature),
mask
})
});
if (!response.ok) {
throw new Error(await response.text());
}
return response.json() as Promise<Landmark[]>;
}

And the initial load in the app looks like this:

useEffect(() => {
void loadInitialArea();
}, []);
async function loadInitialArea() {
setLoading(true);
setError(null);
setActiveFeature(initialSearchPolygon as Feature<Geometry>);
setNearestSearchRadius(null);
try {
const landmarks = await fetchLandmarksWithin(initialSearchPolygon as Feature<Geometry>, "ANYINTERACT");
setSearchResults(makeResultSet("initial-area", landmarks));
mapRef.current?.fitBounds([
[37.75, -122.515],
[37.808, -122.35]
]);
} finally {
setLoading(false);
}
}

Once the initial polygon query is wired up, the application starts with a focused view of San Francisco and only the landmarks that fall inside that opening search area.

Rendering Points and Polygons

One of the nice things about the sample is that the API boundary stays GeoJSON-first. That means the frontend does not need a special Oracle representation. It can just parse the returned GeoJSON and render it.

I render polygon landmarks with GeoJSON, and point landmarks with CircleMarker.

{areaFeatures.map((landmark) => (
<GeoJSON
key={landmark.id}
data={parseLandmarkGeometry(landmark.geometry) as Polygon}
style={() => ({
color: "#0f766e",
weight: 2,
fillColor: "#14b8a6",
fillOpacity: 0.22
})}
/>
))}
{pointFeatures.map((landmark) => {
const geometry = parseLandmarkGeometry(landmark.geometry) as Point;
const [longitude, latitude] = geometry.coordinates;
return (
<CircleMarker
key={landmark.id}
center={[latitude, longitude]}
radius={9}
pathOptions={{
color: "#ffffff",
weight: 2,
fillColor: "#ea580c",
fillOpacity: 0.95
}}
/>
);
})}

That is the core value proposition here. The backend gets to use Oracle Spatial, and the browser gets to stay in familiar GeoJSON territory.

For a web application, that is exactly the split I want.

Nearest Landmarks from a Clicked Point

The next feature is the one I wanted most for the demo: click on the map, send a point to the backend, and get the nearest landmarks back.

On click, I convert the map location into a GeoJSON point:

export function pointFeatureFromCoordinates(longitude: number, latitude: number): Feature<Geometry> {
return {
type: "Feature",
properties: {
label: "Selected point"
},
geometry: {
type: "Point",
coordinates: [longitude, latitude]
}
};
}

Then I send that point to /landmarks/near with distance and limit values:

export async function fetchNearbyLandmarks(point: Feature<Geometry>, distance: number, limit: number): Promise<Landmark[]> {
const params = new URLSearchParams({
geometry: geometryToString(point),
distance: String(distance),
limit: String(limit)
});
const response = await fetch(`${API_BASE_URL}/landmarks/near?${params.toString()}`);
if (!response.ok) {
throw new Error(await response.text());
}
return response.json() as Promise<Landmark[]>;
}

One small UX detail I liked here was keeping the clicked point visible and drawing a circle for the search radius.

{nearestSearchRadius ? (
<Circle
center={[activePoint.geometry.coordinates[1], activePoint.geometry.coordinates[0]]}
radius={nearestSearchRadius}
pathOptions={{
color: "#f97316",
weight: 2,
fillColor: "#fdba74",
fillOpacity: 0.12,
dashArray: "10 6"
}}
/>
) : null}
<CircleMarker
center={[activePoint.geometry.coordinates[1], activePoint.geometry.coordinates[0]]}
radius={8}
pathOptions={{
color: "#ffffff",
weight: 3,
fillColor: "#c2410c",
fillOpacity: 1
}}
/>

That makes the search feel much more obvious when you use the map.

It is a small detail, but it makes the demo much easier to understand visually. You can immediately see both the point you picked and the radius you asked the backend to search within.

The screenshot below shows that interaction in context, with the selected point on the map and the search radius drawn around it.

Search the Visible Area

The other interaction I wanted was the ability to search the current map area.

For that, I turn the map bounds into a polygon and post it to /landmarks/within:

export function boundsToPolygon(bounds: LatLngBounds): Feature<Polygon> {
const west = bounds.getWest();
const east = bounds.getEast();
const south = bounds.getSouth();
const north = bounds.getNorth();
return {
type: "Feature",
properties: {
label: "Visible map area"
},
geometry: {
type: "Polygon",
coordinates: [[
[west, south],
[east, south],
[east, north],
[west, north],
[west, south]
]]
}
};
}

Then the app sends that polygon with the currently selected mask:

async function handleVisibleAreaSearch() {
if (!visibleBounds) {
return;
}
const polygon = boundsToPolygon(visibleBounds) as Feature<Geometry>;
setActiveFeature(polygon);
setNearestSearchRadius(null);
const landmarks = await fetchLandmarksWithin(polygon, mask);
setSearchResults(makeResultSet("area-search", landmarks));
}

I kept the mask choices simple for this version:

  • ANYINTERACT
  • INSIDE

ANYINTERACT returns any landmark that touches or overlaps the search polygon, which is useful when a geometry sits on the edge of the visible area. INSIDE returns only landmarks whose geometry falls entirely within the polygon.

After panning the map and running the visible-area query, the UI updates to reflect the current viewport instead of the original startup polygon.

Why the GeoJSON Boundary Matters

There are a few reasons I like this sample as a starting point.

First, the sample stays GeoJSON-first at the API boundary. That is exactly what I want for browser-facing map work.

Second, the backend makes the spatial queries feel very normal from a Spring developer perspective. You are still building a simple REST application, not learning a whole new framework just to use Oracle Spatial.

Third, the v1 scope feels very reasonable. It gives me enough to build real point and polygon workflows, and it gives me something concrete to build on without trying to solve every spatial use case at once.

That said, this is also why I would really like feedback from people who actively use Oracle Spatial and Spring Boot. The best next steps will probably come from the people doing this work for real.

Wrapping Up

For this example, I used the new Oracle Spatial Spring Boot sample as the backend, then layered a small React + Leaflet application on top of it to:

  • load a meaningful initial area
  • search for nearby landmarks from a clicked point
  • query landmarks inside the current visible map area

The part I like most is that nothing about the frontend had to become Oracle-specific. The browser stayed in GeoJSON and map primitives, which is exactly where I want it.

For a Spring developer, that is one of the most useful parts of this v1 starter. It makes it much easier to work with spatial data in a normal Spring Boot application without having to invent the basic plumbing first.

If you are using Oracle Spatial with Spring Boot, I would genuinely love feedback on what works well, what feels awkward, and what you would want to see next.

Links

Posted in Uncategorized | Tagged , , , , , , , , | Leave a comment

Exploring some new Helidon features – Data Repositories and SE Declarative

With Helidon 4.4.0 right around the corner, I’ve been spending some time playing with the latest milestone release (4.4.0-M2). If you haven’t been following along, Helidon 4 was a major milestone because it was the first framework built from the ground up on Java 21 virtual threads.

Now, with 4.4, we are seeing some really cool incubating features becoming more stable inlcuding Helidon Data Repositories, Helidon SE Declarative and Helidon AI. I am working on a sample project called Helidon-Eats to show how these work together. In this installment, I am looking at the first two.

The “No-Magic” Power of Helidon SE Declarative

If you’ve used Helidon MP (MicroProfile), or if you are more familiar with Spring Boot’s Inversion of Control approach (like me), then you’re used to the convenience of dependency injection and annotations. Helidon SE, on the other hand, has always focused on transparency and avoiding “magic.” While that’s great for performance, it usually meant writing more boilerplate code to register routes and manage services manually.

Helidon SE Declarative changes that. It gives you an annotation-driven model like MP, but here is the trick: it does everything at build-time. Using Java annotation processors, Helidon generates service descriptors during compilation. This means you get the clean, injectable code you want, but without the runtime reflection overhead that slows down startup and eats memory. Benchmarks have even shown performance gains of up to +295% over traditional reflection-based models on modern JDKs. (see this article)

Now, to be completely fair, I will say that I am not completely sold on the build-time piece yet. For example, the @GenerateBinding annotation (if I am not mistaken) causes an ApplicationBinding class to be generated at build time, and that lives in your target/classes directory. I found during refactoring that you have to be careful to mvn clean each time to make sure it stays in sync with your code, just doing a mvn compile or package could get you into trouble. And I am not sure I am happy with it not being checked into the source code repository. But, I’ll withhold judegment until I have worked with it a bit more!

Simplifying Persistence with Helidon Data

The Helidon Data Repository is another big addition. It’s a high-level abstraction that acts as a compile-time alternative to heavy runtime ORM frameworks. Instead of writing JDBC code, you define a Java interface, and Helidon’s annotation processor generates the implementation for you.

It supports standard patterns like CrudRepository and PageableRepository, which I used in this project to handle the recipe collection. The framework can even derive queries directly from your method names (like Spring Data does) – so a method like findById is automatically turned into the correct SQL at build-time.

The Backend: Oracle AI Database 26ai

For this sample, I’m using Oracle AI Database 26ai Free. I sourced some public domain recipe data from Kaggle that comes as a line-by-line JSON file (LDJSON).

Normally, if you want to store hierarchical JSON in relational tables, you have to write complex mapping logic in your application. But I wanted to try a more novel approach using JSON Relational Duality Views (DV).

Duality Views are a game-changer because they decouple how data is stored from how it is accessed. My data is stored in three normalized tables (RECIPE, INGREDIENT, and DIRECTION) which ensures ACID consistency and no data duplication. The database can surface this data to applications as a single, hierarchical JSON document. I am not using that feature in this post, but I will in the future!

GraphQL-Based View Creation and Loading

One of the coolest parts of Oracle AI Database 26ai is that you can define these views using a GraphQL-based syntax . The database engine automatically figures out the joins based on the foreign key relationships.

Here is how I defined the recipe_dv:

CREATE OR REPLACE JSON RELATIONAL DUALITY VIEW recipe_dv AS
recipe @insert @update @delete
{
  recipeId: id,
  recipeTitle: title,
  description: description,
  category: category,
  subcategory: subcategory,
  ingredients: ingredient @insert @update @delete
  [
    {
      id: id,
      item: item
    }
  ],
  directions: direction @insert @update @delete
  [
    {
      id: id,
      step: step
    }
  ]
};

Isn’t that just the cleanest piece of SQL that deals with JSON that you’ve ever seen?

Because the view is “updatable” (@insert, @update), I used it to actually load the data. Instead of a complex ETL process, my startup script just reads the LDJSON file line-by-line and does a simple SQL insert directly into the view. The database engine takes that single JSON object and automatically decomposes it into rows for the three underlying tables.

Modeling the Service

On the Java side, I modeled the Recipe entity to handle the parent-child relationships using standard @OneToMany collections.

One detail I want to highlight is the use of @JsonbTransient. When you build a REST API, you often have internal metadata like database primary keys or sort ordinals that you don’t want messing up the JSON that the end user gets to see. By annotating those fields with @JsonbTransient, they are excluded from the final JSON response. This keeps the API response clean and focused only on the recipe data.

@Entity
@Table(name = "RECIPE")
public class Recipe {
    @Id
    @Column(name = "ID")
    private Long recipeId;
    
    private String recipeTitle;

    @JsonbTransient
    private Long internalId; // Hidden from the API
    
    @OneToMany(mappedBy = "recipe")
    private List<Ingredient> ingredients;
    
    //...
}

In the repository object, you can use the method naming conventions to automatically create queries (like Spring Data) and you can also write your own JPQL (not SQL) queries, as I did in this case (also like Spring Data):

package com.github.markxnelson.helidoneats.recipes.model;

import java.util.Optional;

import io.helidon.data.Data;

@Data.Repository
public interface RecipeRepository extends Data.CrudRepository<Recipe, Integer> {

    @Data.Query("SELECT DISTINCT r FROM Recipe r "
        + "LEFT JOIN FETCH r.ingredients "
        + "LEFT JOIN FETCH r.directions "
        + "WHERE r.recipeId = :recipeId")
    Optional<Recipe> findByRecipeIdWithDetails(Integer recipeId);

}

Wiring and Startup

The configuration is handled in the application.yaml, where I point Helidon to the Oracle instance using syntax that again is very reminiscent of what I’d do in Spring Boot.

server:
  port: 8080
  host: 0.0.0.0
app:
    greeting: "Hello"

data:
  sources:
    sql:
      - name: "food"
        provider.hikari:
          username: "food"
          password: "Welcome12345##"
          url: "jdbc:oracle:thin:@//localhost:1521/freepdb1"
          jdbc-driver-class-name: "oracle.jdbc.OracleDriver"
  persistence-units:
    jakarta:
      - name: "recipe"
        data-source: "food"
        properties:
          hibernate.dialect: "org.hibernate.dialect.OracleDialect"
          jakarta.persistence.schema-generation.database.action: "none"

With Declarative SE, injecting the repository into my endpoint is simple. I just use @Service.Inject on the constructor, which allows me to keep my fields private final.

@Service.Singleton
@Http.Path("/recipe")
public class RecipeEndpoint {
    private final RecipeRepository repository;

    @Service.Inject
    public RecipeEndpoint(RecipeRepository repository) {
        this.repository = repository;
    }

    @Http.GET
    @Http.Path("/{id}")
    public Optional<Recipe> getRecipe(Long id) {
        return repository.findById(id);
    }
}

Finally, the Main class uses @Service.GenerateBinding. This tells the annotation processor to generate the “wiring” code that starts the server and initializes the service registry without needing to scan the classpath at runtime.

@Service.GenerateBinding
public class Main {
    public static void main(String args) {
        LogConfig.configureRuntime();
        ServiceRegistryManager.start(ApplicationBinding.create());
    }
}

In this context, the “service registry” is something in Helidon that keeps track of the services in the application and handles injection and so on. It’s a lot like the way Spring Boot scans for beans and wires/injects them where needed.

The Result

When you hit the service, you get a clean, well structured JSON response that masks all the complexity of the underlying three-table relational join.

Example Response for http://localhost:8080/recipe/22387:

{
  "category": "Appetizers And Snacks",
  "description": "I came up with this rhubarb salsa while trying to figure out what to do with an over-abundance of rhubarb...",
  "directions":,
  "ingredients": [
    "2 cups thinly sliced rhubarb",
    "1 small red onion, coarsely chopped",
    "3 roma (plum) tomatoes, finely diced"
  ],
  "recipeId": 22387,
  "recipeTitle": "Tangy Rhubarb Salsa",
  "subcategory": "Salsa"
}

Wrap Up

Helidon 4.4 is making the SE flavor feel a lot more like a high-productivity framework without sacrificing performance. By shifting the data transformation logic to the database with Duality Views and using build-time code generation for injection, we can build services that are both incredibly fast and easy to maintain.

Now, you may have noticed that I said “like Spring” a lot in this post – and that’s because of two things – I do happen to use Spring a lot more than I use Helidon, and I like it. So I am very happy that Helidon is looking more like Spring, it makes it a lot easier to switch between the two, and I think it lowers the barrier to entry for people who are coming from the Spring world.

Grab the code from the Helidon-Eats repo and let me know what you think – and stay tuned for the next steps as I explore Helidon AI!

Posted in Uncategorized | Tagged , , , , , , , , , , , , , , | Leave a comment

Using Reflection to Help LLMs Write Better SQL

Getting LLMs to write good SQL can be tricky. Sure, they can generate syntactically correct queries, but do those queries actually answer the question you asked? Sometimes an LLM might give you technically valid SQL that doesn’t quite capture what you’re really looking for.

I wanted to experiment with the reflection pattern to see if we could get better results. The idea is simple: after the LLM generates SQL and executes it, have it reflect on whether the query actually answers the original question. If not, let it try again with the benefit of seeing both the question and the initial results.

Let me show you how this works.

Setting up the database

I used an Oracle Autonomous Database on Oracle Cloud for this experiment. First, I created a user with the necessary permissions. Connect as ADMIN and run this:

create user moviestream identified by <password>;
grant connect, resource, unlimited tablespace to moviestream;
grant execute on dbms_cloud to moviestream;
grant execute on dbms_cloud_repo to moviestream;
grant create table to moviestream;
grant create view to moviestream;
grant all on directory data_pump_dir to moviestream;
grant create procedure to moviestream;
grant create sequence to moviestream;
grant create job to moviestream;

Next, let’s load the sample dataset. Still as ADMIN, run this:

declare 
    l_uri varchar2(500) := 'https://objectstorage.us-ashburn-1.oraclecloud.com/n/c4u04/b/building_blocks_utilities/o/setup/workshop-setup.sql';
begin
    dbms_cloud_repo.install_sql(
        content => to_clob(dbms_cloud.get_object(object_uri => l_uri))
    );
end;
/

Then connect as the moviestream user and run this to load the rest of the dataset:

BEGIN
    workshop.add_dataset(tag => 'end-to-end');
END;
/

This takes a few minutes to complete, after which we have a database with customer and sales data to work with.

The approach

The reflection pattern works like this:

  1. Give the LLM the database schema and a natural language question
  2. LLM generates SQL (v1)
  3. Execute the SQL and get results
  4. LLM reflects: “Does this SQL actually answer the question?”
  5. Generate improved SQL (v2) based on the reflection
  6. Execute v2 and provide the final answer

The key insight here is that by seeing the actual results, the LLM can judge whether it interpreted the question correctly. For example, if you ask “who are our top customers?”, the LLM might initially think “highest income” when you actually meant “highest spending”. Seeing the results helps it course-correct.

Setting up the Python environment

I used a Jupyter notebook for this experiment. First, let’s install the libraries we need:

%pip install aisuite oracledb 

I’m using Andrew Ng’s aisuite for a unified interface to different LLM providers, and oracledb to connect to the database.

Now let’s import aisuite:

import aisuite as ai

Connecting to Oracle Autonomous Database

For Oracle Autonomous Database, you’ll need to download the wallet and set up the connection. Here’s how I connected:

import oracledb

username = "moviestream"
password = "<password>"
dsn = "<connection_string>"
wallet = '<path_to_wallet>'

try:
    connection = oracledb.connect(
        user=username, 
        password=password, 
        dsn=dsn,
        config_dir=wallet,
        wallet_location=wallet,
        wallet_password='<wallet_password>')
    print("Connection successful!")
except Exception as e:
    print(e)
    print("Connection failed!")

And set the TNS_ADMIN environment variable:

import os
os.environ['TNS_ADMIN'] = wallet

Configuring the LLM client

Let’s set up the AI client. I used GPT-4o for this experiment:

client = ai.Client()
os.environ['OPENAI_API_KEY']='<your_api_key>'

models = ['openai:gpt-4o']

Getting the database schema

For the LLM to write good SQL, it needs to know what tables and columns are available. Let’s write a function to introspect the schema:

def get_schema():
    stmt = f'''
    SELECT 
        utc.table_name,
        utc.column_name,
        utc.data_type,
        utc.data_length,
        utc.nullable,
        utc.column_id,
        ucc.comments AS column_comment,
        utab.comments AS table_comment
    FROM 
        user_tab_columns utc
    LEFT JOIN 
        user_col_comments ucc 
        ON utc.table_name = ucc.table_name 
        AND utc.column_name = ucc.column_name
    LEFT JOIN 
        user_tab_comments utab 
        ON utc.table_name = utab.table_name
    ORDER BY 
        utc.table_name, 
        utc.column_id;
    '''

    cursor = connection.cursor()
    cursor.execute(stmt)
    rows = cursor.fetchall()

    # Convert to one long string
    result_string = '\n'.join([str(row) for row in rows])

    cursor.close()

    return result_string

This function queries the Oracle data dictionary to get information about all tables and columns, including any comments. It returns everything as a single string that we can pass to the LLM.

Generating SQL from natural language

Now let’s write the function that takes a natural language question and generates SQL:

def generate_sql(question: str, schema: str, model: str):
    prompt = f'''
    You are an SQL assistant for Oracle Database.
    You create Oracle SQL statements to help answer user questions.
    Given the user's question and the schema information, write an SQL
    query to answer the question.

    Schema:
    {schema}

    User question:
    {question}

    Respond with the SQL only.  Do not add any extra characters or delimiters.
    '''
    response = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
    )
    return response.choices[0].message.content.strip()

This function takes the question, the schema information, and the model to use. It constructs a prompt that gives the LLM the context it needs and asks for just the SQL query.

Executing SQL queries

We need a function to actually run the generated SQL:

def execute_sql(stmt):
    cursor = connection.cursor()
    cursor.execute(stmt)
    rows = cursor.fetchall()

    # Convert to one long string
    result_string = '\n'.join([str(row) for row in rows])

    cursor.close()

    return result_string

This executes the query and returns the results as a string.

The reflection step

Here’s where it gets interesting – the function that reviews the SQL and results, and potentially generates improved SQL:

import json

def refine_sql(question, sql_query, output, schema, model):
    prompt = f'''
    You are a SQL reviewer and refiner. 

    User asked:
    {question}

    Original SQL:
    {sql_query}

    SQL Output:
    {output}

    Schema:
    {schema}

    Step 1: Evaluate if the SQL OUTPUT fully answers the user's question.
    Step 2: If improvement is needed, provide a refined SQL query for Oracle.
    If the original SQL is already correct, return it unchanged.

    Return a strict JSON object with two fields:
    - "feedback": brief evaluation and suggestions
    - "refined_sql": the final SQL to run

    Return ONLY the actual JSON document.
    Do NOT add any extra characters or delimiters outside of the actual JSON itself.
    In particular do NOT include backticks before and after the JSON document.
    '''

    response = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
    )

    content = response.choices[0].message.content
    try:
        obj = json.loads(content)
        feedback = str(obj.get("feedback", "")).strip()
        refined_sql = str(obj.get("refined_sql", sql_query)).strip()
        if not refined_sql:
            refined_sql = sql_query
    except Exception:
        # Fallback if model doesn't return valid JSON
        feedback = content.strip()
        refined_sql = sql_query

    return feedback, refined_sql

This is the heart of the reflection pattern. The function:

  1. Shows the LLM the original question, the generated SQL, and the actual results
  2. Asks it to evaluate whether the SQL output really answers the question
  3. If not, asks for an improved query
  4. Returns both the feedback and the refined SQL as JSON

The JSON format makes it easy to parse the response and extract both pieces of information. I had to be fairly pedantic to get gpt-4o to give me just JSON!

Providing a final answer

Finally, let’s write a function to convert the query results into a natural language answer:

def provide_final_answer(question, output, model):
    prompt = f'''
    You are helpful assistant.
    Given a user's question, and the results of a database query
    which has been created, evaluated, improved and executed already
    in order to get the provided output, you should provide an
    answer to the user's question.

    User question:
    {question}

    Query results:
    {output}
    '''
    response = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
    )
    return response.choices[0].message.content.strip()

This takes the final query results and turns them into a friendly, natural language response for the user.

Putting it all together

Now let’s create the main function that orchestrates the entire process:

def generate_and_reflect(question: str):
    
    schema = get_schema()
    print('SCHEMA')
    print(schema)
    print()

    sql_v1 = generate_sql(question, schema, models[0])
    print("SQL V1")
    print(sql_v1)
    print()

    output_v1 = execute_sql(sql_v1)
    print("SQL V1 output")
    print(output_v1)
    print()

    feedback, sql_v2 = refine_sql(question, sql_v1, output_v1, schema, models[0])
    print("FEEDBACK")
    print(feedback)
    print()
    print("SQL V2")
    print(sql_v2)
    print()

    output_v2 = execute_sql(sql_v2)
    print("SQL V2 output")
    print(output_v2)
    print()

    final_answer = provide_final_answer(question, output_v2, models[0])
    print("FINAL ANSWER")
    print(final_answer)
    print()

This function:

  1. Gets the database schema
  2. Generates the first SQL query
  3. Executes it and prints the results
  4. Sends everything to the reflection function for evaluation
  5. Generates and executes the refined SQL
  6. Converts the final results into a natural language answer

Running the experiment

Let’s try it out with a question that could be interpreted multiple ways:

generate_and_reflect('who are our top customers?')

The results

Here’s what happened when I ran this:

First attempt (SQL V1):

SELECT CUST_ID, FIRST_NAME, LAST_NAME, EMAIL, INCOME
FROM CUSTOMER
ORDER BY INCOME DESC
FETCH FIRST 10 ROWS ONLY;

The LLM interpreted “top customers” as customers with the highest income. It returned folks with incomes around $187,000:

(1138797, 'Haruru', 'Takahashi', 'haruru.takahashi6@oraclemail.com', 187168.8)
(1007335, 'Eddie', 'Crawford', 'eddie.crawford@oraclemail.com', 187145.4)
(1404002, 'Yuuto', 'Arai', 'yuuto.arai3@oraclemail.com', 187136.04)
...

Reflection:

The original SQL query retrieves the top 10 customers based on income, which may not 
fully answer the question of 'top customers' as it could be interpreted in terms of 
sales or transactions. To better answer the question, we should consider the total 
sales or transactions made by each customer.

Great! The LLM recognized that “top customers” probably means customers who spend the most, not customers who earn the most.

Second attempt (SQL V2):

SELECT C.CUST_ID, C.FIRST_NAME, C.LAST_NAME, C.EMAIL, SUM(S.ACTUAL_PRICE) AS TOTAL_SALES 
FROM CUSTOMER C 
JOIN CUSTSALES S ON C.CUST_ID = S.CUST_ID 
GROUP BY C.CUST_ID, C.FIRST_NAME, C.LAST_NAME, C.EMAIL 
ORDER BY TOTAL_SALES DESC 
FETCH FIRST 10 ROWS ONLY;

Much better! Now it’s joining with the sales data and calculating total spending per customer.

Final results:

(1234517, 'Tsubasa', 'Nakajima', 'tsubasa.nakajima2@oraclemail.com', 2356.049999999997)
(1280887, 'Steffi', 'Bielvenstram', 'steffi.bielvenstram@oraclemail.com', 2334.7299999999996)
(1017254, 'Guadalupe', 'Zamora', 'guadalupe.zamora@oraclemail.com', 2329.7599999999998)
...

The top customer is Tsubasa Nakajima with $2,356.05 in total sales, followed by Steffi Bielvenstram with $2,334.73, and so on. These are very different customers from the high-income list we got in the first attempt!

Natural language answer:

Our top customers, based on the provided data, are:

1. Tsubasa Nakajima - Email: tsubasa.nakajima2@oraclemail.com, Total: $2356.05
2. Steffi Bielvenstram - Email: steffi.bielvenstram@oraclemail.com, Total: $2334.73
3. Guadalupe Zamora - Email: guadalupe.zamora@oraclemail.com, Total: $2329.76
...

These customers have the highest total amounts associated with them.

What I learned

This reflection approach really does help. The LLM is pretty good at recognizing when its initial SQL doesn’t quite match the intent of the question – especially when it can see the actual results.

The pattern of generate → execute → reflect → regenerate is more expensive (two LLM calls instead of one for generation, plus one more for the final answer), but the quality improvement is noticeable. For production use, you might want to:

  • Cache schema information instead of fetching it every time
  • Add more sophisticated error handling for SQL errors
  • Consider running both queries in parallel and comparing results
  • Track which types of questions benefit most from reflection
  • Use the reflection feedback to build a dataset for fine-tuning

The approach is straightforward to implement and the results speak for themselves – the reflection step caught a subtle but important misinterpretation that would have given technically correct but unhelpful results.

Give it a try with your own database and questions – I think you’ll find the reflection step catches a lot of these subtle misinterpretations that would otherwise lead to valid but wrong answers.

What next? I am going to experiment with some more complex questions, and then compare the performance of a number of different LLMs to see how they go with and without reflection. Stay tuned 🙂

Posted in Uncategorized | Leave a comment

Using Multiple Datasources with Spring Boot and Spring Data JPA

Hi everyone! Today I want to show you how to configure multiple datasources in a Spring Boot application using Spring Data JPA and the Oracle Spring Boot Starter for Universal Connection Pool (UCP).

This is a pattern you’ll need when you have a single application that needs to connect to multiple databases. Maybe you have different domains in separate databases, or you’re working with legacy systems, or you need to separate read and write operations across different database instances. Whatever the reason, Spring Boot makes this pretty straightforward once you understand the configuration pattern.

I’ve put together a complete working example on GitHub at https://github.com/markxnelson/spring-multiple-jpa-datasources, and in this post I’ll walk you through how to build it from scratch.

The Scenario

For this example, we’re going to build a simple application that manages two separate domains:

  • Customers – stored in one database
  • Products – stored in a different database

Each domain will have its own datasource, entity manager, and transaction manager. We’ll use Spring Data JPA repositories to interact with each database, and we’ll show how to use both datasources in a REST controller.

I am assuming you have a database with two users called customer and product and some tables. Here’s the SQL to set that up:

$ sqlplus sys/Welcome12345@localhost:1521/FREEPDB1 as sysdba

alter session set container=freepdb1;
create user customer identified by Welcome12345;
create user product identified by Welcome12345;
grant connect, resource, unlimited tablespace to customer;
grant connect, resource, unlimited tablespace to product;
commit;

$ sqlplus customer/Welcome12345@localhost:1521/FREEPDB1

create table customer (id number, name varchar2(64));
insert into customer (id, name) values (1, 'mark');
commit;

$ sqlplus product/Welcome12345@localhost:1521/FREEPDB1

create table product (id number, name varchar2(64));
insert into product (id, name) values (1, 'coffee machine');
commit;

Step 1: Dependencies

Let’s start with the Maven dependencies. Here’s what you’ll need in your pom.xml:

<dependencies>
    <!-- Spring Boot Starter Web for REST endpoints -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring Boot Starter Data JPA -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <!-- Oracle Spring Boot Starter for UCP -->
    <dependency>
        <groupId>com.oracle.database.spring</groupId>
        <artifactId>oracle-spring-boot-starter-ucp</artifactId>
        <version>25.3.0</version>
    </dependency>

</dependencies>

The key dependency here is the oracle-spring-boot-starter-ucp, which provides autoconfiguration for Oracle’s Universal Connection Pool. UCP is Oracle’s high-performance connection pool implementation that provides features like connection affinity, Fast Connection Failover, and Runtime Connection Load Balancing.

Step 2: Configure the Datasources in application.yaml

Now let’s configure our two datasources in the application.yaml file. We’ll define connection properties for both the customer and product databases:

spring:
  application:
    name: demo

  jpa:
    customer:
      properties:
        hibernate.dialect: org.hibernate.dialect.OracleDialect
        hibernate.hbm2ddl.auto: validate
        hibernate.format_sql: true
        hibernate.show_sql: true
    product:
      properties:
        hibernate.dialect: org.hibernate.dialect.OracleDialect
        hibernate.hbm2ddl.auto: validate
        hibernate.format_sql: true
        hibernate.show_sql: true

  datasource:
    customer:
        url: jdbc:oracle:thin:@localhost:1521/freepdb1
        username: customer
        password: Welcome12345
        driver-class-name: oracle.jdbc.OracleDriver
        type: oracle.ucp.jdbc.PoolDataSourceImpl
        oracleucp:
          connection-factory-class-name: oracle.jdbc.pool.OracleDataSource
          connection-pool-name: CustomerConnectionPool
          initial-pool-size: 15
          min-pool-size: 10
          max-pool-size: 30
          shared: true
    product:
        url: jdbc:oracle:thin:@localhost:1521/freepdb1
        username: product
        password: Welcome12345
        driver-class-name: oracle.jdbc.OracleDriver
        type: oracle.ucp.jdbc.PoolDataSourceImpl
        oracleucp:
          connection-factory-class-name: oracle.jdbc.pool.OracleDataSource
          connection-pool-name: CustomerConnectionPool
          initial-pool-size: 15
          min-pool-size: 10
          max-pool-size: 30
          shared: true

Notice that we’re using custom property prefixes (spring.datasource.customer and product) instead of the default spring.datasource. This is because Spring Boot’s autoconfiguration will only create a single datasource by default. When you need multiple datasources, you need to create them manually and use custom configuration properties.

In this example, both datasources happen to point to the same database server but use different schemas (users). In a real-world scenario, these would typically point to completely different database instances.

Step 3: Configure the Customer Datasource

Now we need to create the configuration classes that will set up our datasources, entity managers, and transaction managers. Let’s start with the customer datasource.

Create a new package called customer and add a configuration class called CustomerDataSourceConfig.java:

package com.example.demo.customer;

import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(entityManagerFactoryRef = "customerEntityManagerFactory", transactionManagerRef = "customerTransactionManager", basePackages = {
        "com.example.demo.customer" })
public class CustomerDataSourceConfig {

    @Bean(name = "customerProperties")
    @ConfigurationProperties("spring.datasource.customer")
    public DataSourceProperties customerDataSourceProperties() {
        return new DataSourceProperties();
    }

    /**
     * Creates and configures the customer DataSource.
     *
     * @param properties the customer datasource properties
     * @return configured DataSource instance
     */
    @Primary
    @Bean(name = "customerDataSource")
    @ConfigurationProperties(prefix = "spring.datasource.customer")
    public DataSource customerDataSource(@Qualifier("customerProperties") DataSourceProperties properties) {
        return properties.initializeDataSourceBuilder().build();
    }

    /**
     * Reads customer JPA properties from application.yaml.
     *
     * @return Map of JPA properties
     */
    @Bean(name = "customerJpaProperties")
    @ConfigurationProperties("spring.jpa.customer.properties")
    public java.util.Map<String, String> customerJpaProperties() {
        return new java.util.HashMap<>();
    }

    /**
     * Creates and configures the customer EntityManagerFactory.
     *
     * @param builder the EntityManagerFactoryBuilder
     * @param dataSource the customer datasource
     * @param jpaProperties the JPA properties from application.yaml
     * @return configured LocalContainerEntityManagerFactoryBean
     */
    @Primary
    @Bean(name = "customerEntityManagerFactory")
    public LocalContainerEntityManagerFactoryBean customerEntityManagerFactory(EntityManagerFactoryBuilder builder,
            @Qualifier("customerDataSource") DataSource dataSource,
            @Qualifier("customerJpaProperties") java.util.Map<String, String> jpaProperties) {

        return builder.dataSource(dataSource)
                .packages("com.example.demo.customer")
                .persistenceUnit("customers")
                .properties(jpaProperties)
                .build();
    }

    @Bean
    @ConfigurationProperties("spring.jpa.customer")
    public PlatformTransactionManager customerTransactionManager(
            @Qualifier("customerEntityManagerFactory") LocalContainerEntityManagerFactoryBean entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory.getObject());
    }

}

Let’s break down what’s happening here:

  1. @EnableJpaRepositories – This tells Spring Data JPA where to find the repositories for this datasource. We specify the base package (com.example.multidatasource.customer), and we reference the entity manager factory and transaction manager beans by name.
  2. @Primary – We mark the customer datasource as the primary one. This means it will be used by default when autowiring a datasource, entity manager, or transaction manager without a @Qualifier. You must have exactly one primary datasource when using multiple datasources.
  3. customerDataSource() – This creates the datasource bean using Spring Boot’s DataSourceBuilder. The @ConfigurationProperties annotation binds the properties from our application.yaml (with the customer.datasource prefix) to the datasource configuration.
  4. customerEntityManagerFactory() – This creates the JPA entity manager factory, which is responsible for creating entity managers. We configure it to scan for entities in the customer package and set up Hibernate properties.
  5. customerTransactionManager() – This creates the transaction manager for the customer datasource. The transaction manager handles transaction boundaries and ensures ACID properties.

Step 4: Configure the Product Datasource

Now let’s create the configuration for the product datasource. Create a new package called product and add ProductDataSourceConfig.java:

package com.example.demo.product;

import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(entityManagerFactoryRef = "productEntityManagerFactory", transactionManagerRef = "productTransactionManager", basePackages = {
        "com.example.demo.product" })
public class ProductDataSourceConfig {

    @Bean(name = "productProperties")
    @ConfigurationProperties("spring.datasource.product")
    public DataSourceProperties productDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean(name = "productDataSource")
    @ConfigurationProperties(prefix = "spring.datasource.product")
    public DataSource productDataSource(@Qualifier("productProperties") DataSourceProperties properties) {
        return properties.initializeDataSourceBuilder().build();
    }

    /**
     * Reads product JPA properties from application.yaml.
     *
     * @return Map of JPA properties
     */
    @Bean(name = "productJpaProperties")
    @ConfigurationProperties("spring.jpa.product.properties")
    public java.util.Map<String, String> productJpaProperties() {
        return new java.util.HashMap<>();
    }

    /**
     * Creates and configures the product EntityManagerFactory.
     *
     * @param builder the EntityManagerFactoryBuilder
     * @param dataSource the product datasource
     * @param jpaProperties the JPA properties from application.yaml
     * @return configured LocalContainerEntityManagerFactoryBean
     */
    @Bean(name = "productEntityManagerFactory")
    public LocalContainerEntityManagerFactoryBean productEntityManagerFactory(@Autowired EntityManagerFactoryBuilder builder,
            @Qualifier("productDataSource") DataSource dataSource,
            @Qualifier("productJpaProperties") java.util.Map<String, String> jpaProperties) {

        return builder.dataSource(dataSource)
                .packages("com.example.demo.product")
                .persistenceUnit("products")
                .properties(jpaProperties)
                .build();
    }

    @Bean
    @ConfigurationProperties("spring.jpa.product")
    public PlatformTransactionManager productTransactionManager(
            @Qualifier("productEntityManagerFactory") LocalContainerEntityManagerFactoryBean entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory.getObject());
    }

}

The product configuration is almost identical to the customer configuration, with a few key differences:

  1. No @Primary annotations – Since we already designated the customer datasource as primary, we don’t mark the product beans as primary.
  2. Different package – The @EnableJpaRepositories points to the product package, and the entity manager factory scans the product package for entities.
  3. Different bean names – All the beans have different names (productDataSource, productEntityManagerFactory, productTransactionManager) to avoid conflicts.

Step 5: Create the Domain Models

Now let’s create the JPA entities for each datasource. First, in the customer package, create Customer.java:

package com.example.demo.customer;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;

@Entity
public class Customer {
    @Id
    public int id;
    public String name;

    public Customer() {
        this.id = 0;
        this.name = "";
    }
}

And in the product package, create Product.java:

package com.example.demo.product;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;

@Entity
public class Product {
    @Id
    public int id;
    public String name;

    public Product() {
        this.id = 0;
        this.name = "";
    }
}

Step 6: Create the Repositories

Now let’s create Spring Data JPA repositories for each entity. In the customer package, create CustomerRepository.java:

package com.example.demo.customer;

import org.springframework.data.jpa.repository.JpaRepository;

public interface CustomerRepository extends JpaRepository<Customer, Integer> {

}

And in the product package, create ProductRepository.java:

package com.example.demo.product;

import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Integer> {

}

Step 7: Create a REST Controller

Finally, let’s create a REST controller that demonstrates how to use both datasources. Create a controller package and add CustomerController.java:

package com.example.demo.controllers;

import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.demo.customer.Customer;
import com.example.demo.customer.CustomerRepository;

@RestController
public class CustomerController {

    final CustomerRepository customerRepository;

    public CustomerController(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
    }

    @GetMapping("/customers")
    public List<Customer> getCustomers() {
        return customerRepository.findAll();
    }

}

A few important things to note about the controller:

  1. Transaction Managers – When you have multiple datasources, you need to explicitly specify which transaction manager to use. Notice the @Transactional("customerTransactionManager") and @Transactional("productTransactionManager") annotations on the write operations. If you don’t specify a transaction manager, Spring will use the primary one (customer) by default.
  2. Repository Autowiring – The repositories are autowired normally. Spring knows which datasource each repository uses based on the package they’re in, which we configured in our datasource configuration classes.
  3. Cross-datasource Operations – The initializeData() method demonstrates working with both datasources in a single method. However, note that these operations are not in a distributed transaction – if one fails, the other won’t automatically roll back. If you need distributed transactions across multiple databases, you would need to use JTA (Java Transaction API).

Let’s also create ProductController.java:

package com.example.demo.controllers;

import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.demo.product.Product;
import com.example.demo.product.ProductRepository;

@RestController
public class ProductController {

    final ProductRepository productRepository;

    public ProductController(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @GetMapping("/products")
    public List<Product> getProducts() {
        return productRepository.findAll();
    }

}

Testing the Application

Now you can run your application! Make sure you have two Oracle database users created (customer and product), or adjust the configuration to point to your specific databases.

Start the application:

mvn spring-boot:run

Then you can test it with some curl commands:

# Get all customers
$ curl http://localhost:8080/customers
[{"id":1,"name":"mark"}]

# Get all products
$ curl http://localhost:8080/products
[{"id":1,"name":"coffee machine"}]

Wrapping Up

And there you have it! We’ve successfully configured a Spring Boot application with multiple datasources using Spring Data JPA and Oracle’s Universal Connection Pool. The key points to remember are:

  1. Custom configuration properties – Use custom prefixes for each datasource in your application.yaml
  2. Manual configuration – Create configuration classes for each datasource with beans for the datasource, entity manager factory, and transaction manager
  3. Primary datasource – Designate one datasource as primary using @Primary
  4. Package organization – Keep entities and repositories for each datasource in separate packages
  5. Explicit transaction managers – Specify which transaction manager to use for write operations with @Transactional

This pattern works great when you need to connect to multiple databases, whether they’re different types of databases or different instances of the same database. Oracle’s Universal Connection Pool provides excellent performance and reliability for your database connections.

I hope this helps you work with multiple datasources in your Spring Boot applications! The complete working code is available on GitHub at https://github.com/markxnelson/spring-multiple-jpa-datasources.

Happy coding!

Posted in Uncategorized | Tagged , , , , , , | Leave a comment

Custom vector distance functions in Oracle (using JavaScript)

In case you missed it, Oracle Database 26ai was announced last week at Oracle AI World, with a heap of new AI features and capabilities like hybrid vector search, MCP server support, acceleration with NVIDIA and much more – check the link for details.

Of course, I wanted to check it out, and I was thinking about what to do first. I remembered this LinkedIn post from Anders Swanson about implementing custom vector distance functions in Oracle using the new JavaScript capabilities, and I thought that could be something interesting to do, so I am going to show you how to implement and use Jaccard distance for dense vector embeddings for similarity searches.

Now, this is a slightly contrived example, because I am more interested in showing you how to add a custom metric than in the actual metric itself. I chose Jaccard because the actual implementation is pretty compact.

Now, Oracle does already include Jaccard distance, but only for the BINARY data type, which is where Jaccard is mostly used. But there is a version that can be used for continuous/real-valued vectors as well (this version is for dense vectors), and that is what we will implement.

This is the formula for Jaccard similarity for continuous vectors. This is also known as the Tanimoto coefficient. It is the intersection divided by the union (or zero if the union is zero):

To get the Jaccard distance, we just subtract the Jaccard similarity from one.

Before we start, let’s look at a two-dimensional example to get a feel for how it works. Of course, the real vectors created by embedding models have many more dimensions, but it is hard for us to visualize more than two or three dimensions without also introducing techniques like dimensionality reduction and projection).

Here we have two vectors A [5 8] and B [7 4]:

The union is calculated using the max values, as you see in the formular above, so in this example it is 7×8, as shown by the area shaded pink. The intersection is calculated with the min values, so it is 5×4, as shown by the green area.

So in this example, the Jaccard similarity is (7×8) / (5×4) = 56 / 20 = 0.6

And so the Jaccard distance is 1 – 0.6 = 0.4

Ok, now that we have some intuition about how this distance metric works, let’s implement it in Oracle.

Start up an Oracle Database

First, let’s fire up Oracle Database Free 26ai in a container:

docker run -d --name db26ai \
    -p 1521:1521 \
    -e ORACLE_PWD=Welcome12345 \
    -v db26ai-volume:/opt/oracle/oradata \
    container-registry.oracle.com/database/free:latest

This will pull the latest image, which at the time of writing is 26ai (version tag 23.26.0.0). You can check the logs to see when startup is complete, you’ll see a message “DATABASE IS READY TO USE”:

docker logs -f db26ai

Let’s create a user called vector with the necessary privileges:

docker exec -i db26ai sqlplus sys/Welcome12345@localhost:1521/FREEPDB1 as sysdba <<EOF
alter session set container=FREEPDB1;
create user vector identified by vector;
grant connect, resource, unlimited tablespace, create credential, create procedure, create mle, create any index to vector;
commit;
EOF

Now you can connect with your favorite client. I am going to use Oracle SQL Developer for VS Code. See the link for install instructions.

Implement the custom distance function

Open up an SQL Worksheet, or run this in your tool of choice:


create or replace function jaccard_distance("a" vector, "b" vector)
return binary_double 
deterministic parallel_enable
as mle language javascript pure {{
    // check the vectors are the same length
    if (a.length !== b.length) {
        throw new Error('Vectors must have same length');
    }

    let intersection = 0;
    let union = 0;

    for (let i = 0; i < a.length; i++) { 
        intersection += Math.min(a[i], b[i]);
        union += Math.max(a[i], b[i]);
    }

    // handle the case where union is zero (all-zero vectors)
    if (union === 0) {
        return 0;
    }

    const similarity = intersection / union;
    return 1 - similarity;

}};
/

Let’s walk throught this. First, you see that we are creating a function called jaccard_distance which accepts two vectors (a and b) as input and returns a binary_double. This function sugnature is required for distance functions. Next we must include the deterministric keyword and we have also included the parallel_enable keyword so that this function could be used with HNSW vector indexes. For the purposes of this example, you can just ignore those or assume that they are just needed as part of the function signature.

Next you see that we mention this will be an MLE function written in JavaScript, and we added the pure keyword to let the database know that this is a pure function – meaning it has no side effects, it will not update any data, and its output will always be the same for a given set of inputs (i.e., that it is memoizable).

Then we have the actual implementation of the function. First, we check that the vectors have the same length (i.e., the same number of dimensions) which is required for this calculation to be applicable.

Then we work through the vectors and collect the minimums and maximums to calculate the intersection and the union.

Next, we check if the union is zero, and if so we return zero to handle that special case. And finally, we calculate the similarity, then subtract it from one to get the distance and return that.

Using our custom distance function

Great, so let’s test our function. We can start by creating a table t1 to store some vectors:

create table t1 (
    id number,
    v vector(2, float32)
);

And let’s add a couple of vectors, including the one we saw in the example above [5 8]:

insert into t1 (id, v) values 
(1, vector('[5, 8]')),
(2, vector('[1, 2]'));

You can so a simple select statement to see the contents of the table:

select * from t1;

This will give these results:

ID     V
1      [5.0E+000,8.0E+000]
2      [1.0E+000,2.0E+000]

Now let’s use our function to see the Jaccard distance for each vector in our table t1 from the other vector we used in the example above [7 4]:

select 
    v,
    jaccard_distance(v, vector('[7, 4]')) distance
from t1
order by distance; 

This returns these results:

V                       DISTANCE
[5.0E+000,8.0E+000]     0.4
[1.0E+000,2.0E+000]     0.7272727272727273

As you can see, the Jaccard distance from [5 8] to [7 4] is 0.4, as we calculated in the example above, and [1 2] to [7 4] is 0.72…

Let’s see how it works with large embeddings

Ok, two dimension vectors are good for simple visualization, but let’s try this out with some ‘real’ vectors.

I am using Visual Studio Code with the Python and Jupyter extensions from Microsoft installed

Create a new Jupyter Notebook using File > New File… then choose Jupyter Notebook as the type of file, and save your new file at jaccard.ipynb.

First, we need to set up the Python runtime environment. Click on the Select Kernel button (its on the top right). Select Python Environment then Create Python Environment. Select the option to create a Venv (Virtual Environment) and choose your Python interpreter. I recommend using at least Python 3.11. This will download all the necessary files and will take a minute or two.

Now, let’s install the libraries we will need – enter this into a cell and run it:

%pip install oracledb sentence-transformers

Now, connect to the same Oracle database (again, enter this into a cell and run it):

import oracledb

username = "vector"
password = "vector"
dsn = "localhost:1521/FREEPDB1"

try:
    connection = oracledb.connect(
        user=username, 
        password=password, 
        dsn=dsn)
    print("Connection successful!")
except Exception as e:
    print("Connection failed!")

Let’s create a table to hold 1024 dimension vectors that we will create with the mxbai-embed-large-v1 embedding model. Back in your SQL Worksheet, run this statement:

create table t2 (
    id number,
    v vector(1024, float32)
);

Ok, now let’s create some embeddings. Back in your notebook, create a new cell with this code:

import oracledb
from sentence_transformers import SentenceTransformer

# Initialize the embedding model
print("Loading embedding model...")
model = SentenceTransformer('mixedbread-ai/mxbai-embed-large-v1')

# Your text data
texts = [
    "The quick brown fox jumps over the lazy dog",
    "Machine learning is a subset of artificial intelligence",
    "Oracle Database 23ai supports vector embeddings",
    "Python is a popular programming language",
    "Embeddings capture semantic meaning of text"
]

# Generate embeddings
print("Generating embeddings...")
embeddings = model.encode(texts)

Let’s discuss what we are doing in this code. First, we are going to download the embedding model usign the SentenceTransformer. Then, we define a few simple texts that we can use for this example and use the embedding model to create the vector embeddings for those texts.

If you want to see what the embeddings look like, just enter “embeddings” in a cell and run it. In the output you can see the shape is 5 (rows) with 1024 dimensions and the type is float32.

Now, let’s insert the embeddings into our new table t2:

import array 
cursor = connection.cursor()

# Insert data
for i in range(len(embeddings)):
    cursor.execute("""
        INSERT INTO t2 (id, v)
        VALUES (:1, :2)
    """, [i, array.array('f', embeddings[i].tolist())])

connection.commit()
print(f"Successfully inserted {len(texts)} records")

You can take a look at the vectors using the simple query (back in your SQL Worksheet):

select * from t2

Which will show you something like this:

And, now let’s try our distance function with these vectors. Back in your notebook, run this cell. I’ve included the built-in cosine distance as well, just for comparison purposes:

query = array.array('f', model.encode("Antarctica is the driest continent").tolist())

cursor = connection.cursor()
cursor.execute("""
    select 
        id,
        jaccard_distance(v, :1),
        vector_distance(v, :2, cosine)
    from t2
    order by id
""", [query, query])

for row in cursor:
    print(f"id: {row[0]} has jaccard distance: {row[1]} and cosine distance: {row[2]}")

cursor.close()

Your output will look something like this:

id: 0 has jaccard distance: 2.0163214889484307 and cosine distance: 0.7859490566650003
id: 1 has jaccard distance: 2.0118706751976925 and cosine distance: 0.6952327173906239
id: 2 has jaccard distance: 2.0152858933816775 and cosine distance: 0.717824211314015
id: 3 has jaccard distance: 2.0216149035530537 and cosine distance: 0.6455277387099003
id: 4 has jaccard distance: 2.0132575761281766 and cosine distance: 0.6962028121886988

Well, there you go! We implemented and used a custom vector distance function. Enjoy!

Posted in Uncategorized | Tagged , , , , , , , , , | Leave a comment

Let’s make a simple MCP tool for Oracle AI Vector Search

In this earlier post, we created a vector store in our Oracle Database 23ai and populated it with some content from Moby Dick. Since MCP is very popular these days, I thought it might be interesting to look how to create a very simple MCP server to expose the similarity search as and MCP tool.

Let’s jump right into it. First we are going to need a requirements.txt file with a list of the dependencies we need:

mcp>=1.0.0
oracledb
langchain-community
langchain-huggingface
sentence-transformers
pydantic

And then go ahead and install these by running:

pip install -r requirements.txt

Note: I used Python 3.12 and a virtual environment.

Now let’s create a file called mcp_server.py and get to work! Let’s start with some imports:

import asyncio
import oracledb
from mcp.server import Server
from mcp.types import Tool, TextContent
from pydantic import BaseModel
from langchain_community.vectorstores import OracleVS
from langchain_community.vectorstores.utils import DistanceStrategy
from langchain_huggingface import HuggingFaceEmbeddings

And we are going to need the details of the database so we can connect to that, so let’s define some variables to hold those parameters:

# Database connection parameters for Oracle Vector Store
DB_USERNAME = "vector"
DB_PASSWORD = "vector"
DB_DSN = "localhost:1521/FREEPDB1" 
TABLE_NAME = "moby_dick_500_30"  

Note: These match the database and vector store used in the previous post.

Let’s create a function to connect to the database, and set up the embedding model and the vector store.

# Global variables for database connection and embedding model
# These are initialized once on server startup for efficiency
embedding_model = None  # HuggingFace sentence transformer model
vector_store = None     # LangChain OracleVS wrapper for vector operations
connection = None       # Oracle database connection

def initialize_db():
    """
    Initialize database connection and vector store

    This function is called once at server startup to establish:
    1. Connection to Oracle database
    2. HuggingFace embedding model (sentence-transformers/all-mpnet-base-v2)
    3. LangChain OracleVS wrapper for vector similarity operations

    The embedding model converts text queries into 768-dimensional vectors
    that can be compared against pre-computed embeddings in the database.
    """
    global embedding_model, vector_store, connection

    # Connect to Oracle database using oracledb driver
    connection = oracledb.connect(
        user=DB_USERNAME,
        password=DB_PASSWORD,
        dsn=DB_DSN
    )

    # Initialize HuggingFace embeddings model
    # This model converts text to 768-dimensional vectors
    # Same model used to create the original embeddings in the database
    embedding_model = HuggingFaceEmbeddings(
        model_name="sentence-transformers/all-mpnet-base-v2"
    )

    # Initialize vector store wrapper
    # OracleVS provides convenient interface for vector similarity operations
    vector_store = OracleVS(
        client=connection,
        table_name=TABLE_NAME,
        embedding_function=embedding_model,
        # Use cosine similarity for comparison
        distance_strategy=DistanceStrategy.COSINE,  
    )

Again, note that I am using the same embedding model that we used to create the vectors in this vector store. This is important because we need to create embedding vectors for the queries using the same model, so that similarity comparisons will be valid. It’s also important that we use the right distance strategy – for text data, cosine is generally agreed to be the best option. For performance reasons, if we had created a vector index, we’d want to use the same algorithm so the index would be used when performing the search. Oracle will default to doing an “exact search” if there is no index and the algorithm does not match.

Now, let’s add a function to perform a query in our Moby Dick vector store, we’ll include a top-k parameter so the caller can specify how many results they want:

def search_moby_dick(query: str, k: int = 4) -> list[dict]:
    """
    Perform vector similarity search on the moby_dick_500_30 table

    This function:
    1. Converts the query text to a vector using the embedding model
    2. Searches the database for the k most similar text chunks
    3. Returns results ranked by similarity (cosine distance)

    Args:
        query: The search query text (natural language)
        k: Number of results to return (default: 4)

    Returns:
        List of dictionaries containing rank, content, and metadata for each result
    """
    if vector_store is None:
        raise RuntimeError("Vector store not initialized")

    # Perform similarity search
    # The query is automatically embedded and compared against database vectors
    docs = vector_store.similarity_search(query, k=k)

    # Format results into structured dictionaries
    results = []
    for i, doc in enumerate(docs):
        results.append({
            "rank": i + 1,  # 1-indexed ranking by similarity
            "content": doc.page_content,  # The actual text chunk
            "metadata": doc.metadata  # Headers from the original HTML structure
        })

    return results

As you can see, this function returns a dictionary containing the rank, the content (chunk) and the metadata.

Ok, now let’s turn this into an MCP server! First let’s create the server instance:

# Create MCP server instance
# The server name "moby-dick-search" identifies this server in MCP client connections
app = Server("moby-dick-search")

Now we want to provide a list-tools method so that MCP clients can find out what kinds of tools this server provides. We are just going to have our search tool, so let’s define that:

@app.list_tools()
async def list_tools() -> list[Tool]:
    """
    MCP protocol handler: returns list of available tools

    Called by MCP clients to discover what capabilities this server provides.
    This server exposes a single tool: search_moby_dick

    Returns:
        List of Tool objects with names, descriptions, and input schemas
    """
    return [
        Tool(
            name="search_moby_dick",
            description="Search the Moby Dick text using vector similarity. Returns relevant passages based on semantic similarity to the query.",
            inputSchema={
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "The search query text"
                    },
                    "k": {
                        "type": "integer",
                        "description": "Number of results to return (default: 4)",
                        "default": 4
                    }
                },
                "required": ["query"]
            }
        )
    ]

And now, the part we’ve all been waiting for – let’s define the actual search tool (and a class to hold the arguments)!

class SearchArgs(BaseModel):
    """
    Arguments for the vector search tool

    Attributes:
        query: The natural language search query
        k: Number of most similar results to return (default: 4)
    """
    query: str
    k: int = 4

@app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
    """
    MCP protocol handler: executes tool calls

    Called when an MCP client wants to use one of the server's tools.
    Validates the tool name, parses arguments, performs the search,
    and returns formatted results.

    Args:
        name: Name of the tool to call
        arguments: Dictionary of tool arguments

    Returns:
        List of TextContent objects containing the formatted search results
    """
    # Validate tool name
    if name != "search_moby_dick":
        raise ValueError(f"Unknown tool: {name}")

    # Parse and validate arguments using Pydantic model
    args = SearchArgs(**arguments)

    # Perform the vector similarity search
    results = search_moby_dick(args.query, args.k)

    # Format response as human-readable text
    response_text = f"Found {len(results)} results for query: '{args.query}'\n\n"

    for result in results:
        response_text += f"--- Result {result['rank']} ---\n"
        response_text += f"Metadata: {result['metadata']}\n"
        response_text += f"Content: {result['content']}\n\n"

    # Return as MCP TextContent type
    return [TextContent(type="text", text=response_text)]

That was not too bad. Finally, let’s set up a main function to start up everything and handle the requests:

async def main():
    """
    Main entry point for the MCP server

    This function:
    1. Initializes the database connection and embedding model
    2. Sets up stdio transport for MCP communication
    3. Runs the server event loop to handle requests

    The server communicates via stdio (stdin/stdout), which allows
    it to be easily spawned by MCP clients as a subprocess.
    """
    # Initialize database connection and models
    initialize_db()

    # Import stdio server transport
    from mcp.server.stdio import stdio_server

    # Run the server using stdio transport
    # The server reads MCP protocol messages from stdin and writes responses to stdout
    async with stdio_server() as (read_stream, write_stream):
        await app.run(
            read_stream,
            write_stream,
            app.create_initialization_options()
        )

if __name__ == "__main__":
    asyncio.run(main())

Ok, that’s it! We can run this with the command:

python mcp_server.py

Now, to test it, we’re groing to need a client! MCP Inspector is the logical place to start, you can get it from here, or (assuming you have node installed) by just running this command:

 npx @modelcontextprotocol/inspector python3.12 mcp_server.py

That’s going to start up a UI that looks like this:

Click on the connect button, and you should see an updated screen in a few seconds that looks like this:

Go ahead and click on List Tools and you will see our Search Moby Dick Tool show up – click on it to try it out.

You should see some results like this:

There you go, it works great! And that’s a super simple, basic MCP server and tool! Enjoy.

Posted in Uncategorized | Tagged , , , , , | 1 Comment

Exploring securing vector similarity searches with Real Application Security

In this post, I want to explore how you can use Real Application Security to provide access controls for vectors in a vector store in Oracle Database 23ai.

I’m going to use the vector store we created in the last post as an example. If you want to follow along, you should follow that one first to create and populate your vector store, then come back here.

You should have a vector store table called MOBY_DICK_500_30 that you created in that previous post. You can connect to Oracle using SQLcl or SQL*Plus or whatever tool you prefer and check the structure of that table:

SQL> describe moby_dick_500_30

Name         Null?       Type
____________ ___________ ____________________________
ID           NOT NULL    RAW(16 BYTE)
TEXT                     CLOB
METADATA                 JSON
EMBEDDING                VECTOR(768,FLOAT32,DENSE)

Let’s observe that that metadata column contains the document structure information from the loaders that we used. If we filter for Chapter 12, we can see there are 13 vectors associated with that chapter:

SQL> select metadata from moby_dick_500_30 where metadata like '%CHAPTER 12.%';

METADATA
__________________________________________________________________________________
{"Header 1":"MOBY-DICK; or, THE WHALE.","Header 2":"CHAPTER 12. Biographical."}
{"Header 1":"MOBY-DICK; or, THE WHALE.","Header 2":"CHAPTER 12. Biographical."}
{"Header 1":"MOBY-DICK; or, THE WHALE.","Header 2":"CHAPTER 12. Biographical."}
{"Header 1":"MOBY-DICK; or, THE WHALE.","Header 2":"CHAPTER 12. Biographical."}
{"Header 1":"MOBY-DICK; or, THE WHALE.","Header 2":"CHAPTER 12. Biographical."}
{"Header 1":"MOBY-DICK; or, THE WHALE.","Header 2":"CHAPTER 12. Biographical."}
{"Header 1":"MOBY-DICK; or, THE WHALE.","Header 2":"CHAPTER 12. Biographical."}
{"Header 1":"MOBY-DICK; or, THE WHALE.","Header 2":"CHAPTER 12. Biographical."}
{"Header 1":"MOBY-DICK; or, THE WHALE.","Header 2":"CHAPTER 12. Biographical."}
{"Header 1":"MOBY-DICK; or, THE WHALE.","Header 2":"CHAPTER 12. Biographical."}
{"Header 1":"MOBY-DICK; or, THE WHALE.","Header 2":"CHAPTER 12. Biographical."}
{"Header 1":"MOBY-DICK; or, THE WHALE.","Header 2":"CHAPTER 12. Biographical."}
{"Header 1":"MOBY-DICK; or, THE WHALE.","Header 2":"CHAPTER 12. Biographical."}

13 rows selected.

We are going to use this metadata to filter access to the vectors.

Set up permissions

Let’s start by setting up the necessary permissions. You will need to run this as the SYS user:

alter session set container=freepdb1;
grant create session, xs_session_admin to vector;
exec sys.xs_admin_util.grant_system_privilege('provision', 'vector', sys.xs_admin_util.ptype_db);
grant create role to vector;
exec sys.xs_admin_util.grant_system_privilege('admin_sec_policy', 'vector', sys.xs_admin_util.ptype_db);
exec sys.xs_admin_util.grant_system_privilege('ADMIN_ANY_SEC_POLICY', 'vector', sys.xs_admin_util.ptype_db);

Great! Now let’s set up Real Application Security. We will run the rest of these commands as the VECTOR user.

Let’s start by creating a RAS role named role1:

exec sys.xs_principal.create_role(name => 'role1', enabled => true);

Now, we will create a user named user1 and add grant them role1 and connect privileges:

exec  sys.xs_principal.create_user(name => 'user1', schema => 'vector');
exec  sys.xs_principal.set_password('user1', 'pwd1');
exec  sys.xs_principal.grant_roles('user1', 'XSCONNECT');
exec  sys.xs_principal.grant_roles('user1', 'role1');

Let’s also create a regular database role and give it access to the vector store table:

create role db_emp;
grant select, insert, update, delete on vector.moby_dick_500_30 to db_emp; 

Grant DB_EMP to the application roles, so they have the required object privileges to access the table:

grant db_emp to role1;

Next, we want to create a security class, and include the predefined DML security class:

begin
  sys.xs_security_class.create_security_class(
    name        => 'moby_privileges',
    parent_list => xs$name_list('sys.dml'),
    priv_list   => xs$privilege_list(xs$privilege('view_moby_dick')));
end;

Now we can create an ACL (access control list) which will grant the privileges for the policy that we will define in a moment:

declare 
  aces xs$ace_list := xs$ace_list(); 
begin
  aces.extend(1);
  aces(1) := xs$ace_type(
     privilege_list => xs$name_list('select'),
     principal_name => 'USER1');
  
  sys.xs_acl.create_acl(
    name  => 'moby_acl',
    ace_list  => aces,
    sec_class => 'moby_privileges');
end;

Ok, nearly there! Finally, let’s define the security policy and apply it to the table:

declare
  realms xs$realm_constraint_list := xs$realm_constraint_list();      
begin  
  realms.extend(1);
 
  -- Filter based on column value
  realms(1) := xs$realm_constraint_type(
    realm    => 'metadata LIKE ''%CHAPTER 12.%''',
    acl_list => xs$name_list('moby_acl'));

  sys.xs_data_security.create_policy(
    name                   => 'moby_policy',
    realm_constraint_list  => realms);
    
  sys.xs_data_security.apply_object_policy(
    policy => 'moby_policy',
    schema => 'vector',
    object =>'moby_dick_500_30');
end;

Ok, that’s it!

Now, you may have noticed we did not give ourselves any permissions, so if we try to query that vector store table now, you’ll see it appears empty!

SQL> select count(*) from moby_dick_500_30;

   COUNT(*)
___________
          0

But, if we reconnect with the application user (user1) that we defined, and do the same query, we will see those 13 records for Chapter 12:

SQL> connect user1/pwd1
Connected.
SQL> select count(*) from moby_dick_500_30;

   COUNT(*)
___________
         13

So there you have it! We can define policies to easily control access to vectors. In this example we used the metadata to create the filtering rules, of course you could create whatever kind of rules you need.

This allows you to have a vector store which can be easily filtered for different users (or roles), essentially creating a virtual private vector store. You might want to allow ‘customer-support’ role access a certain subset of vectors for example, but your ‘supervisor’ role to access a larger set (or all) of the vectors.

What’s great about this, is that the security is enforced in the database itself. When an AI Assistant, chatbot, MCP client, etc., performs a vector search, they will only ever be able to get back results from the vectors that the user is allowed to see. The database will never send vectors to users which they are not allowed to see. So you don’t have to worry about trusting the LLM not to make a mistake and give out the wrong data, because it will literally never see the data in the first place.

Posted in Uncategorized | Tagged , , , , , | Leave a comment