APPLICATION_COMMIT_1

This commit is contained in:
2026-07-17 14:58:24 -03:00
parent 7583ef25a4
commit 87c7990d89
15 changed files with 352 additions and 1 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
target/
.idea/
.vscode/
*.iml
.env
*.log

View File

@@ -18,11 +18,14 @@ Set these as deployment secrets, not in source control:
- `PDB_PORTAL_CDB_OCID`: the Container Database OCID (the `ocid1.database...` value). - `PDB_PORTAL_CDB_OCID`: the Container Database OCID (the `ocid1.database...` value).
- `PDB_PORTAL_CDB_ADMIN_PASSWORD`: supplied at runtime by OCI Vault or the deployment secret manager. - `PDB_PORTAL_CDB_ADMIN_PASSWORD`: supplied at runtime by OCI Vault or the deployment secret manager.
- `PDB_PORTAL_TDE_WALLET_PASSWORD` (when required by the CDB).
- `PDB_PORTAL_ENCRYPTION_KEY`: a Base64-encoded 256-bit AES key used to protect the submitted PDB password while approval is pending. - `PDB_PORTAL_ENCRYPTION_KEY`: a Base64-encoded 256-bit AES key used to protect the submitted PDB password while approval is pending.
- `PDB_PORTAL_JDBC_URL_TEMPLATE`: e.g. `jdbc:oracle:thin:@//scan-host:1521/{pdbName}`. - `PDB_PORTAL_JDBC_URL_TEMPLATE` (optional): override only if needed, e.g. `jdbc:oracle:thin:@//scan-host:1521/{pdbName}`. By default the portal reads the PDB connection string returned by OCI.
When deployed on OCI, grant a dynamic group containing the application runtime permission to manage PDBs in the target compartment. The backend uses a Resource Principal; a personal OCI API key must only be used for local development, never embedded in the portal. When deployed on OCI, grant a dynamic group containing the application runtime permission to manage PDBs in the target compartment. The backend uses a Resource Principal; a personal OCI API key must only be used for local development, never embedded in the portal.
For a local OCI test, set `PDB_PORTAL_OCI_AUTH_MODE=api_key`. The SDK then reads your usual OCI config file (`%USERPROFILE%\\.oci\\config`, profile `DEFAULT`) and its `key_file` entry. You may instead set `PDB_PORTAL_OCI_CONFIG_PATH` and `PDB_PORTAL_OCI_PROFILE`. The private key stays outside this repository.
## Run locally ## Run locally
Install JDK 21 and Maven, then set the variables above (a non-production local key is acceptable only for development): Install JDK 21 and Maven, then set the variables above (a non-production local key is acceptable only for development):

29
pom.xml Normal file
View File

@@ -0,0 +1,29 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.8</version>
<relativePath/>
</parent>
<groupId>com.oracle.pdbportal</groupId>
<artifactId>pdb-self-service-portal</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>21</java.version>
<oci.sdk.version>3.89.1</oci.sdk.version>
</properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</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-security</artifactId></dependency>
<dependency><groupId>com.oracle.oci.sdk</groupId><artifactId>oci-java-sdk-database</artifactId><version>${oci.sdk.version}</version></dependency>
<dependency><groupId>com.oracle.database.jdbc</groupId><artifactId>ojdbc11</artifactId><version>23.8.0.25.04</version></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build>
<plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins>
</build>
</project>

View File

@@ -0,0 +1,36 @@
package com.oracle.pdbportal;
import org.springframework.stereotype.Service;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
@Service
class CryptoService {
private final byte[] key;
CryptoService(PortalProperties properties) {
if (properties.encryptionKey() == null || properties.encryptionKey().isBlank())
throw new IllegalStateException("PDB_PORTAL_ENCRYPTION_KEY is required");
key = Base64.getDecoder().decode(properties.encryptionKey());
if (key.length != 32) throw new IllegalStateException("PDB_PORTAL_ENCRYPTION_KEY must be a 256-bit Base64 key");
}
String encrypt(String clearText) { return crypt(Cipher.ENCRYPT_MODE, clearText.getBytes(StandardCharsets.UTF_8), null); }
String decrypt(String payload) { return new String(Base64.getDecoder().decode(crypt(Cipher.DECRYPT_MODE, Base64.getDecoder().decode(payload), null)), StandardCharsets.UTF_8); }
private String crypt(int mode, byte[] input, byte[] ignored) {
try {
byte[] iv = new byte[12];
if (mode == Cipher.ENCRYPT_MODE) new SecureRandom().nextBytes(iv);
else { iv = java.util.Arrays.copyOfRange(input, 0, 12); input = java.util.Arrays.copyOfRange(input, 12, input.length); }
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(mode, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, iv));
byte[] value = cipher.doFinal(input);
if (mode == Cipher.DECRYPT_MODE) return Base64.getEncoder().encodeToString(value);
byte[] combined = new byte[iv.length + value.length];
System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(value, 0, combined, iv.length, value.length);
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) { throw new IllegalStateException("Could not protect a request secret", e); }
}
}

View File

@@ -0,0 +1,57 @@
package com.oracle.pdbportal;
import com.oracle.bmc.auth.ResourcePrincipalAuthenticationDetailsProvider;
import com.oracle.bmc.auth.ConfigFileAuthenticationDetailsProvider;
import com.oracle.bmc.database.DatabaseClient;
import com.oracle.bmc.database.model.CreatePluggableDatabaseDetails;
import com.oracle.bmc.database.requests.CreatePluggableDatabaseRequest;
import com.oracle.bmc.database.requests.GetPluggableDatabaseRequest;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.UUID;
@Service
class OciPdbProvisioner {
private final PortalProperties properties;
OciPdbProvisioner(PortalProperties properties) { this.properties = properties; }
ProvisioningResult create(String pdbName, String pdbAdminPassword) {
requireConfigured();
try (var client = client()) {
var builder = CreatePluggableDatabaseDetails.builder()
.containerDatabaseId(properties.cdbOcid()).pdbName(pdbName).pdbAdminPassword(pdbAdminPassword)
.containerDatabaseAdminPassword(properties.cdbAdminPassword().toCharArray())
.shouldPdbAdminAccountBeLocked(false).shouldCreatePdbBackup(false);
if (properties.tdeWalletPassword() != null && !properties.tdeWalletPassword().isBlank()) builder.tdeWalletPassword(properties.tdeWalletPassword());
var details = builder.build();
var response = client.createPluggableDatabase(CreatePluggableDatabaseRequest.builder()
.createPluggableDatabaseDetails(details).opcRetryToken(UUID.randomUUID().toString()).build());
return new ProvisioningResult(response.getPluggableDatabase().getId(), response.getOpcWorkRequestId());
}
}
boolean isAvailable(String pdbOcid) {
requireConfigured();
try (var client = client()) {
return "AVAILABLE".equals(client.getPluggableDatabase(GetPluggableDatabaseRequest.builder()
.pluggableDatabaseId(pdbOcid).build()).getPluggableDatabase().getLifecycleState().toString());
}
}
String pdbDefaultConnectionString(String pdbOcid) {
try (var client = client()) {
var pdb = client.getPluggableDatabase(GetPluggableDatabaseRequest.builder().pluggableDatabaseId(pdbOcid).build()).getPluggableDatabase();
if (pdb.getConnectionStrings() == null || pdb.getConnectionStrings().getPdbDefault() == null) throw new IllegalStateException("OCI did not return a PDB default connection string");
return pdb.getConnectionStrings().getPdbDefault();
}
}
private void requireConfigured() {
if (properties.cdbOcid() == null || properties.cdbOcid().isBlank() || properties.cdbAdminPassword() == null || properties.cdbAdminPassword().isBlank())
throw new IllegalStateException("CDB OCID and CDB admin password must be supplied by runtime secrets");
}
private DatabaseClient client() {
if (!"api_key".equalsIgnoreCase(properties.ociAuthMode())) return DatabaseClient.builder().build(ResourcePrincipalAuthenticationDetailsProvider.builder().build());
try {
if (properties.ociConfigPath() == null || properties.ociConfigPath().isBlank()) return DatabaseClient.builder().build(new ConfigFileAuthenticationDetailsProvider(properties.ociProfile()));
return DatabaseClient.builder().build(new ConfigFileAuthenticationDetailsProvider(properties.ociConfigPath(), properties.ociProfile()));
} catch (IOException e) { throw new IllegalStateException("OCI config file could not be loaded", e); }
}
record ProvisioningResult(String pdbOcid, String workRequestId) { }
}

View File

@@ -0,0 +1,15 @@
package com.oracle.pdbportal;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@EnableConfigurationProperties(PortalProperties.class)
public class PdbPortalApplication {
public static void main(String[] args) {
SpringApplication.run(PdbPortalApplication.class, args);
}
}

View File

@@ -0,0 +1,39 @@
package com.oracle.pdbportal;
import jakarta.persistence.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "pdb_requests", uniqueConstraints = @UniqueConstraint(columnNames = "pdbName"))
public class PdbRequest {
@Id @GeneratedValue private UUID id;
@Column(nullable = false, length = 30) private String pdbName;
@Column(nullable = false) private int storageGb;
@Column(nullable = false) private String requestedBy;
@Column(nullable = false) @Enumerated(EnumType.STRING) private RequestStatus status;
@Lob @Column(nullable = false) private String encryptedPdbPassword;
private String approvedBy;
private String pdbOcid;
private String workRequestId;
@Lob private String failureReason;
@Column(nullable = false) private Instant createdAt = Instant.now();
private Instant approvedAt;
protected PdbRequest() { }
PdbRequest(String pdbName, int storageGb, String requestedBy, String encryptedPdbPassword) {
this.pdbName = pdbName; this.storageGb = storageGb; this.requestedBy = requestedBy;
this.encryptedPdbPassword = encryptedPdbPassword; this.status = RequestStatus.PENDING;
}
public UUID getId() { return id; } public String getPdbName() { return pdbName; }
public int getStorageGb() { return storageGb; } public String getRequestedBy() { return requestedBy; }
public RequestStatus getStatus() { return status; } public String getPdbOcid() { return pdbOcid; }
public String getWorkRequestId() { return workRequestId; } public String getFailureReason() { return failureReason; }
String password() { return encryptedPdbPassword; }
void approve(String user) { status = RequestStatus.APPROVED; approvedBy = user; approvedAt = Instant.now(); }
void provisioning(String pdbOcid, String workRequestId) { status = RequestStatus.PROVISIONING; this.pdbOcid = pdbOcid; this.workRequestId = workRequestId; }
void completed() { status = RequestStatus.COMPLETED; encryptedPdbPassword = null; }
void failed(String reason) { status = RequestStatus.FAILED; failureReason = reason; }
}
enum RequestStatus { PENDING, APPROVED, PROVISIONING, COMPLETED, FAILED, REJECTED }

View File

@@ -0,0 +1,20 @@
package com.oracle.pdbportal;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController @RequestMapping("/api/requests")
class PdbRequestController {
private final PdbRequestService service;
PdbRequestController(PdbRequestService service) { this.service = service; }
@PostMapping @ResponseStatus(HttpStatus.CREATED) @PreAuthorize("hasRole('DEV')")
PdbRequest create(@RequestBody CreatePdbRequest request, Authentication authentication) { return service.submit(request, authentication.getName()); }
@GetMapping @PreAuthorize("hasAnyRole('DEV','ADMIN')") List<PdbRequest> list() { return service.list(); }
@PostMapping("/{id}/approve") @PreAuthorize("hasRole('ADMIN')")
PdbRequest approve(@PathVariable UUID id, Authentication authentication) { return service.approve(id, authentication.getName()); }
@ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class}) @ResponseStatus(HttpStatus.BAD_REQUEST)
Map<String,String> badRequest(Exception e) { return Map.of("error", e.getMessage()); }
}

View File

@@ -0,0 +1,9 @@
package com.oracle.pdbportal;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.*;
interface PdbRequestRepository extends JpaRepository<PdbRequest, UUID> {
List<PdbRequest> findByStatus(RequestStatus status);
boolean existsByPdbNameIgnoreCase(String pdbName);
}

View File

@@ -0,0 +1,50 @@
package com.oracle.pdbportal;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.DriverManager;
import java.util.*;
@Service
class PdbRequestService {
private final PdbRequestRepository repository; private final CryptoService crypto;
private final OciPdbProvisioner provisioner; private final PortalProperties properties;
PdbRequestService(PdbRequestRepository r, CryptoService c, OciPdbProvisioner p, PortalProperties props) { repository=r; crypto=c; provisioner=p; properties=props; }
@Transactional PdbRequest submit(CreatePdbRequest input, String user) {
String name = input.pdbName().trim().toUpperCase(Locale.ROOT);
if (!name.matches("[A-Z][A-Z0-9]{0,29}")) throw new IllegalArgumentException("PDB name must begin with a letter and contain at most 30 alphanumeric characters");
if (repository.existsByPdbNameIgnoreCase(name)) throw new IllegalArgumentException("A request already exists for this PDB name");
if (input.storageGb() < properties.minStorageGb() || input.storageGb() > properties.maxStorageGb()) throw new IllegalArgumentException("Storage request is outside the permitted range");
if (!input.initialPassword().matches("(?=(?:.*[A-Z]){2})(?=(?:.*[a-z]){2})(?=(?:.*\\d){2})(?=(?:.*[_#-]){2}).{9,}")) throw new IllegalArgumentException("Initial password does not meet OCI PDB password rules");
return repository.save(new PdbRequest(name, input.storageGb(), user, crypto.encrypt(input.initialPassword())));
}
@Transactional PdbRequest approve(UUID id, String admin) {
PdbRequest request = find(id); if (request.getStatus()!=RequestStatus.PENDING) throw new IllegalStateException("Only pending requests can be approved");
request.approve(admin);
try { var result = provisioner.create(request.getPdbName(), crypto.decrypt(request.password())); request.provisioning(result.pdbOcid(), result.workRequestId()); }
catch (RuntimeException e) { request.failed("OCI provisioning request could not be submitted"); throw e; }
return request;
}
@Scheduled(fixedDelayString = "${PDB_PORTAL_RECONCILE_MS:60000}") @Transactional
void reconcile() {
repository.findByStatus(RequestStatus.PROVISIONING).forEach(request -> {
try { if (provisioner.isAvailable(request.getPdbOcid())) createDataTablespace(request); }
catch (RuntimeException ignored) { /* OCI may still be processing; retry on the next cycle. */ }
});
}
private void createDataTablespace(PdbRequest request) {
String serviceConnection = properties.jdbcUrlTemplate()==null || properties.jdbcUrlTemplate().isBlank()
? provisioner.pdbDefaultConnectionString(request.getPdbOcid())
: properties.jdbcUrlTemplate().replace("{pdbName}", request.getPdbName());
String url = serviceConnection.startsWith("jdbc:") ? serviceConnection : "jdbc:oracle:thin:@" + serviceConnection;
try (var connection=DriverManager.getConnection(url, properties.pdbAdminUser(), crypto.decrypt(request.password())); var statement=connection.createStatement()) {
statement.execute("CREATE TABLESPACE DATA DATAFILE SIZE " + request.getStorageGb() + "G AUTOEXTEND OFF");
request.completed();
} catch (Exception e) { request.failed("Tablespace creation failed; inspect secure application logs"); }
}
List<PdbRequest> list() { return repository.findAll(); }
private PdbRequest find(UUID id) { return repository.findById(id).orElseThrow(() -> new NoSuchElementException("Request not found")); }
}
record CreatePdbRequest(String pdbName, int storageGb, String initialPassword) { }

View File

@@ -0,0 +1,11 @@
package com.oracle.pdbportal;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "pdb-portal")
public record PortalProperties(String cdbOcid, String cdbAdminPassword, String tdeWalletPassword, String encryptionKey,
String jdbcUrlTemplate, String pdbAdminUser, int minStorageGb,
int maxStorageGb, String ociAuthMode, String ociConfigPath,
String ociProfile, DevUsers devUsers) {
public record DevUsers(String developerPassword, String adminPassword) { }
}

View File

@@ -0,0 +1,26 @@
package com.oracle.pdbportal;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration @EnableMethodSecurity
class SecurityConfiguration {
@Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
@Bean UserDetailsService users(PortalProperties p, PasswordEncoder encoder) {
return new InMemoryUserDetailsManager(
User.withUsername("developer").password(encoder.encode(p.devUsers().developerPassword())).roles("DEV").build(),
User.withUsername("admin").password(encoder.encode(p.devUsers().adminPassword())).roles("ADMIN").build());
}
@Bean SecurityFilterChain security(HttpSecurity http) throws Exception {
return http.csrf(csrf -> csrf.disable()).authorizeHttpRequests(registry -> registry
.requestMatchers("/", "/index.html", "/app.js").permitAll().anyRequest().authenticated()).httpBasic(basic -> {}).build();
}
}

View File

@@ -0,0 +1,25 @@
spring:
datasource:
url: ${PDB_PORTAL_REPOSITORY_URL:jdbc:h2:mem:pdbportal;MODE=Oracle;DB_CLOSE_DELAY=-1}
username: ${PDB_PORTAL_REPOSITORY_USER:sa}
password: ${PDB_PORTAL_REPOSITORY_PASSWORD:}
jpa:
hibernate:
ddl-auto: update
open-in-view: false
pdb-portal:
cdb-ocid: ${PDB_PORTAL_CDB_OCID:}
cdb-admin-password: ${PDB_PORTAL_CDB_ADMIN_PASSWORD:}
tde-wallet-password: ${PDB_PORTAL_TDE_WALLET_PASSWORD:}
encryption-key: ${PDB_PORTAL_ENCRYPTION_KEY:}
jdbc-url-template: ${PDB_PORTAL_JDBC_URL_TEMPLATE:}
pdb-admin-user: ${PDB_PORTAL_PDB_ADMIN_USER:PDBADMIN}
oci-auth-mode: ${PDB_PORTAL_OCI_AUTH_MODE:resource_principal}
oci-config-path: ${PDB_PORTAL_OCI_CONFIG_PATH:}
oci-profile: ${PDB_PORTAL_OCI_PROFILE:DEFAULT}
min-storage-gb: ${PDB_PORTAL_MIN_STORAGE_GB:10}
max-storage-gb: ${PDB_PORTAL_MAX_STORAGE_GB:200}
dev-users:
developer-password: ${PDB_PORTAL_DEV_PASSWORD:change-me}
admin-password: ${PDB_PORTAL_ADMIN_PASSWORD:change-me}

View File

@@ -0,0 +1,18 @@
const message = document.querySelector('#message');
async function api(path, options = {}) {
const response = await fetch(path, {headers: {'Content-Type': 'application/json'}, ...options});
if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || `HTTP ${response.status}`);
return response.json();
}
async function refresh() {
try {
const items = await api('/api/requests');
document.querySelector('#rows').innerHTML = items.map(item => `<tr><td>${item.pdbName}</td><td>${item.storageGb}</td><td>${item.requestedBy}</td><td>${item.status}</td><td>${item.status === 'PENDING' ? `<button onclick="approve('${item.id}')">Aprovar</button>` : ''}</td></tr>`).join('');
} catch (error) { message.textContent = error.message; }
}
async function approve(id) { try { await api(`/api/requests/${id}/approve`, {method: 'POST'}); message.textContent = 'Provisionamento submetido à OCI.'; refresh(); } catch (error) { message.textContent = error.message; } }
document.querySelector('#request').addEventListener('submit', async event => {
event.preventDefault(); const data = Object.fromEntries(new FormData(event.target)); data.storageGb = Number(data.storageGb);
try { await api('/api/requests', {method: 'POST', body: JSON.stringify(data)}); event.target.reset(); message.textContent = 'Solicitação enviada para aprovação.'; refresh(); } catch (error) { message.textContent = error.message; }
});
document.querySelector('#refresh').addEventListener('click', refresh); refresh();

View File

@@ -0,0 +1,7 @@
<!doctype html>
<html lang="pt-BR"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Portal PDB</title>
<style>body{font:16px system-ui;max-width:900px;margin:3rem auto;padding:0 1rem}form,table{width:100%;margin:1rem 0}label,input,button{display:block;margin:.5rem 0}input{padding:.5rem;width:24rem;max-width:100%}th,td{padding:.6rem;text-align:left;border-bottom:1px solid #ddd}button{padding:.5rem .8rem}#message{min-height:1.5rem}</style></head>
<body><h1>Solicitação de PDB</h1><p>Autentique-se pelo navegador: <code>developer</code> para solicitar ou <code>admin</code> para aprovar.</p>
<form id="request"><label>Nome do PDB <input name="pdbName" required maxlength="30" pattern="[A-Za-z][A-Za-z0-9]*"></label><label>Storage inicial da DATA (GB) <input name="storageGb" required type="number" min="10"></label><label>Senha inicial do PDBADMIN <input name="initialPassword" required type="password"></label><button>Enviar para aprovação</button></form>
<p id="message"></p><h2>Solicitações</h2><button id="refresh">Atualizar</button><table><thead><tr><th>PDB</th><th>GB</th><th>Solicitante</th><th>Status</th><th>Ação</th></tr></thead><tbody id="rows"></tbody></table>
<script src="/app.js"></script></body></html>