initial commit

This commit is contained in:
allard
2025-11-23 18:58:51 +01:00
commit 376a944abc
1553 changed files with 314731 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
**/target
!.keep
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
/build/
### VS Code ###
.vscode/
## Local configuration files
/local/config/*
*.swo
*.swp

View File

@@ -0,0 +1,10 @@
FROM adoptopenjdk:8-jre-openj9
USER root
RUN apt-get update && apt-get upgrade -y e2fsprogs libgnutls30 libgcrypt20 libsasl2-2
RUN mkdir -p /opt/app/lib
USER 1001
COPY target/user-cleanup-utility-1.0-SNAPSHOT.jar /opt/app
COPY target/lib/* /opt/app/lib/
CMD ["java", "-jar", "/opt/app/user-cleanup-utility-1.0-SNAPSHOT.jar"]

View File

@@ -0,0 +1,15 @@
## Build
```
mvn package
docker build -t bank-user-cleanup-utility:1.0-SNAPSHOT .
```
### Secrets
```
kubectl create secret generic bank-db-secret --from-literal=DB_SERVERNAME=48f106c1-94cb-4133-b99f-20991c91cb1a.bn2a2vgd01r3l0hfmvc0.databases.appdomain.cloud --from-literal=DB_PORTNUMBER=30389 --from-literal=DB_DATABASENAME=ibmclouddb --from-literal=DB_USER=ibm_cloud_0637cd24_8ac9_4dc7_b2d4_ebd080633f7f --from-literal=DB_PASSWORD=<password>
kubectl create secret generic bank-iam-secret --from-literal=IAM_APIKEY=<apikey> --from-literal=IAM_SERVICE_URL=https://iam.cloud.ibm.com/identity/token
kubectl create secret generic bank-appid-secret --from-literal=APPID_TENANTID=3d17f53d-4600-4f32-bb2c-207f4e2f6060 --from-literal=APPID_SERVICE_URL=https://us-south.appid.cloud.ibm.com
```

View File

@@ -0,0 +1,28 @@
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: bank-user-cleanup-utility
labels:
app: bank-user-cleanup-utility
spec:
schedule: "@hourly"
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: bank-user-cleanup-utility
image: ykoyfman/bank-cleanup:1.0
imagePullPolicy: Always
envFrom:
- secretRef:
name: bank-db-secret
- secretRef:
name: bank-iam-secret
- secretRef:
name: bank-appid-secret
env:
- name: LAST_LOGIN_HOURS
value: "24"
backoffLimit: 0

View File

@@ -0,0 +1,94 @@
<?xml version='1.0' encoding='utf-8'?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ibm.codey.loyalty</groupId>
<artifactId>user-cleanup-utility</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.2.10</version>
</dependency>
<!-- JSON-B API -->
<dependency>
<groupId>jakarta.json.bind</groupId>
<artifactId>jakarta.json.bind-api</artifactId>
<version>1.0.2</version>
</dependency>
<!-- JSON-B implementation -->
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-json-binding-provider</artifactId>
<version>4.4.2.Final</version>
</dependency>
<!-- Microprofile rest client API -->
<dependency>
<groupId>org.eclipse.microprofile.rest.client</groupId>
<artifactId>microprofile-rest-client-api</artifactId>
<version>1.3.3</version>
</dependency>
<!-- Microprofile rest client implementation -->
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>resteasy-client-microprofile</artifactId>
<version>4.4.2.Final</version>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.16</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.1.1</version>
<executions>
<execution>
<phase>compile</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<includeScope>runtime</includeScope>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.0.2</version>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
<mainClass>com.ibm.codey.loyalty.AccountDeletionProcessor</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,269 @@
package com.ibm.codey.loyalty;
import java.net.MalformedURLException;
import java.net.URL;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.StringJoiner;
import java.util.logging.Level;
import java.util.logging.Logger;
import com.ibm.codey.loyalty.external.appid.AppIDService;
import com.ibm.codey.loyalty.external.appid.AppIDServiceGetUserRoleResponse;
import com.ibm.codey.loyalty.external.appid.AppIDServiceGetUsersResponse;
import com.ibm.codey.loyalty.external.iam.IAMTokenService;
import com.ibm.codey.loyalty.external.iam.IAMTokenServiceResponse;
import org.eclipse.microprofile.rest.client.RestClientBuilder;
// This code deletes any App ID user who is no longer registered for the loyalty program.
public class AccountDeletionProcessor {
private static final Logger log = Logger.getLogger(AccountDeletionProcessor.class.getName());
private static final String PROVIDER = "cloud_directory";
private static final int USERS_COUNT = 20;
private static URL IAM_SERVICE_URL;
private static String IAM_APIKEY;
private static URL APPID_SERVICE_URL;
private static String APPID_TENANTID;
private static String DB_SERVERNAME;
private static String DB_PORTNUMBER;
private static String DB_DATABASENAME;
private static String DB_USER;
private static String DB_PASSWORD;
private static int LAST_LOGIN_HOURS;
private Connection con;
private AppIDService appIdService;
private String authHeader;
public static void main(String[] args) {
// Gather environment variables
try {
IAM_SERVICE_URL = new URL(getEnvVar("IAM_SERVICE_URL"));
APPID_SERVICE_URL = new URL(getEnvVar("APPID_SERVICE_URL"));
} catch(MalformedURLException mue) {
mue.printStackTrace();
System.exit(1);
}
IAM_APIKEY = getEnvVar("IAM_APIKEY");
APPID_TENANTID = getEnvVar("APPID_TENANTID");
DB_SERVERNAME = getEnvVar("DB_SERVERNAME");
DB_PORTNUMBER = getEnvVar("DB_PORTNUMBER");
DB_DATABASENAME = getEnvVar("DB_DATABASENAME");
DB_USER = getEnvVar("DB_USER");
DB_PASSWORD = getEnvVar("DB_PASSWORD");
LAST_LOGIN_HOURS = Integer.valueOf(getEnvVar("LAST_LOGIN_HOURS"));
new AccountDeletionProcessor().run();
}
public void run() {
// Connect to database
getDBConnection();
// Set up auth header for App Id with IAM token.
authHeader = "Bearer " + getIamToken();
// Set up client proxy to App Id service.
appIdService = RestClientBuilder.newBuilder().baseUrl(APPID_SERVICE_URL).build(AppIDService.class);
try {
// Iterate through all App Id users a page at a time. Identify and collect unregistered users by provider id.
Set<String> unregisteredUserProviderIds = new HashSet<String>();
int startIndex = 0;
AppIDServiceGetUsersResponse usersResponse;
do {
// Get a page of users. Collect the user's profile id and corresponding provider id.
Map<String, String> profileIdToProviderIdMap = new HashMap<String,String>(USERS_COUNT);
log.log(Level.INFO, "Obtaining a page of user data");
usersResponse = appIdService.getUsers(authHeader, APPID_TENANTID, AppIDService.DATASCOPE_FULL, startIndex, USERS_COUNT);
int numberOfUsersOnThisPage = usersResponse.getItemsPerPage();
for (int i=0; i<usersResponse.getItemsPerPage() ; i++) {
AppIDServiceGetUsersResponse.User user = usersResponse.getUsers()[i];
AppIDServiceGetUsersResponse.Identity[] identities = user.getIdentities();
if (identities != null && identities.length == 1 && identities[0].getProvider().equals(PROVIDER)) {
// If the user hasn't recently logged in, save the profile id and provider id for further examination.
if (!isRecentlyModified(identities[0].getIdpUserInfo().getMeta().getLastModified())) {
profileIdToProviderIdMap.put(user.getProfileId(), identities[0].getProviderId());
}
}
}
startIndex += numberOfUsersOnThisPage;
log.log(Level.INFO, "App Id users: " + profileIdToProviderIdMap.toString());
// If there are no users on this page that weren't recently modified, continue to next page.
if (profileIdToProviderIdMap.isEmpty()) {
continue;
}
// Query users table for subjects matching these profile ids.
Set<String> registeredProfileIds = queryUsers(profileIdToProviderIdMap.keySet());
log.log(Level.INFO, "Registered users: " + registeredProfileIds.toString());
// Remove from the map those users who are still registered in the users table.
for(String profileId : registeredProfileIds) {
profileIdToProviderIdMap.remove(profileId);
}
// Remove from the map those users who are admins.
Iterator<Map.Entry<String, String>> iter = profileIdToProviderIdMap.entrySet().iterator();
while (iter.hasNext()) {
Map.Entry<String,String> entry = iter.next();
String profileId = entry.getKey();
if (isAdmin(profileId)) {
log.log(Level.INFO, "Admin: " + profileId);
iter.remove();
}
}
// Whatever is left is an unregistered user. Save for deletion after completing the paged scan.
unregisteredUserProviderIds.addAll(profileIdToProviderIdMap.values());
} while(startIndex < usersResponse.getTotalResults());
// Remove all unregistered users.
if (unregisteredUserProviderIds.isEmpty()) {
log.log(Level.INFO, "No App ID users need to be removed");
} else {
for(String providerId : unregisteredUserProviderIds) {
log.log(Level.INFO, "Removing user: " + providerId);
appIdService.removeUser(authHeader, APPID_TENANTID, providerId);
}
}
} finally {
try {
appIdService.close();
} catch(Exception e) {
e.printStackTrace();
}
closeDBConnection();
}
}
private static String getEnvVar(String name) {
String s = System.getenv(name);
if (s == null) {
throw new RuntimeException("Missing environment variable " + name);
}
return s;
}
private void getDBConnection() {
try {
// Load the driver
log.log(Level.INFO, "Loading the JDBC driver");
Class.forName("org.postgresql.Driver");
// Create the connection
String url = "jdbc:postgresql://" + DB_SERVERNAME + ":" + DB_PORTNUMBER + "/" + DB_DATABASENAME;
log.log(Level.INFO, "Creating a JDBC connection to " + url);
Properties props = new Properties();
props.setProperty("user", DB_USER);
props.setProperty("password", DB_PASSWORD);
props.setProperty("sslfactory","org.postgresql.ssl.NonValidatingFactory");
con = DriverManager.getConnection(url, props);
} catch (ClassNotFoundException e) {
System.err.println("Could not load JDBC driver");
e.printStackTrace();
throw new RuntimeException(e);
} catch(SQLException sqlex) {
System.err.println("SQLException information");
System.err.println ("Error msg: " + sqlex.getMessage());
System.err.println ("SQLSTATE: " + sqlex.getSQLState());
System.err.println ("Error code: " + sqlex.getErrorCode());
sqlex.printStackTrace();
throw new RuntimeException(sqlex);
}
}
private Set<String> queryUsers(Set<String> profileIds) {
Set<String> registeredProfileIds = new HashSet<String>();
try {
// Create query statement
StringJoiner sj = new StringJoiner(",", "(", ")");
for(String id : profileIds) {
sj.add("?");
}
String query = "SELECT SUBJECT FROM BANK.USERS WHERE SUBJECT IN " + sj.toString();
// Execute query statement
log.log(Level.INFO, "Querying database");
PreparedStatement ps = con.prepareStatement(query);
int index = 1;
for(String id : profileIds) {
ps.setString(index, id);
index++;
}
ResultSet rs = ps.executeQuery();
while(rs.next()) {
registeredProfileIds.add(rs.getString("subject"));
}
// Close the ResultSet
rs.close();
// Close the PreparedStatement
ps.close();
}
catch(SQLException sqlex) {
System.err.println("SQLException information");
System.err.println ("Error msg: " + sqlex.getMessage());
System.err.println ("SQLSTATE: " + sqlex.getSQLState());
System.err.println ("Error code: " + sqlex.getErrorCode());
sqlex.printStackTrace();
throw new RuntimeException(sqlex);
}
return registeredProfileIds;
}
private void closeDBConnection() {
try {
con.close();
}
catch(SQLException sqlex) {
System.err.println("SQLException information");
System.err.println ("Error msg: " + sqlex.getMessage());
System.err.println ("SQLSTATE: " + sqlex.getSQLState());
System.err.println ("Error code: " + sqlex.getErrorCode());
sqlex.printStackTrace();
throw new RuntimeException(sqlex);
}
}
private String getIamToken() {
// Get an IAM token for authentication to App ID API.
log.log(Level.INFO, "Obtaining IAM access token");
IAMTokenServiceResponse tokenResponse;
try ( IAMTokenService iamTokenService = RestClientBuilder.newBuilder().baseUrl(IAM_SERVICE_URL).build(IAMTokenService.class) ) {
tokenResponse = iamTokenService.getIAMTokenFromAPIKey(IAMTokenService.GRANT_TYPE_APIKEY, IAM_APIKEY);
} catch(Exception e) {
throw new RuntimeException(e);
}
return tokenResponse.getAccessToken();
}
private boolean isRecentlyModified(String lastModifiedString) {
ZonedDateTime now = ZonedDateTime.now();
ZonedDateTime lastModified = ZonedDateTime.parse(lastModifiedString);
Duration duration = Duration.between(lastModified, now);
long diffHours = (long) duration.getSeconds() / (60*60);
return (diffHours < LAST_LOGIN_HOURS);
}
private boolean isAdmin(String profileId) {
boolean admin = false;
AppIDServiceGetUserRoleResponse userProfileResponse = appIdService.getUserRoles(authHeader, APPID_TENANTID, profileId);
for (AppIDServiceGetUserRoleResponse.Role role : userProfileResponse.getRoles()) {
if (role.getName().equals("admin")) {
admin = true;
break;
}
}
return admin;
}
}

View File

@@ -0,0 +1,44 @@
package com.ibm.codey.loyalty.external.appid;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.QueryParam;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
public interface AppIDService extends AutoCloseable {
public static String DATASCOPE_FULL = "full";
@GET
@Path("/management/v4/{tenantId}/users")
@Produces({MediaType.APPLICATION_JSON})
public AppIDServiceGetUsersResponse getUsers(
@HeaderParam("Authorization") String authorizationHeader,
@PathParam("tenantId") String tenantId,
@QueryParam("dataScope") String dataScope,
@QueryParam("startIndex") int startIndex,
@QueryParam("count") int count
);
@GET
@Path("/management/v4/{tenantId}/users/{id}/roles")
@Produces({MediaType.APPLICATION_JSON})
public AppIDServiceGetUserRoleResponse getUserRoles(
@HeaderParam("Authorization") String authorizationHeader,
@PathParam("tenantId") String tenantId,
@PathParam("id") String profileId
);
@DELETE
@Path("/management/v4/{tenantId}/cloud_directory/remove/{userId}")
public void removeUser(
@HeaderParam("Authorization") String authorizationHeader,
@PathParam("tenantId") String tenantId,
@PathParam("userId") String userId
);
}

View File

@@ -0,0 +1,22 @@
package com.ibm.codey.loyalty.external.appid;
import javax.json.bind.annotation.JsonbProperty;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class AppIDServiceGetUserRoleResponse {
@JsonbProperty("roles")
private Role[] roles;
@Getter @Setter
public static class Role {
@JsonbProperty("name")
private String name;
}
}

View File

@@ -0,0 +1,61 @@
package com.ibm.codey.loyalty.external.appid;
import javax.json.bind.annotation.JsonbProperty;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class AppIDServiceGetUsersResponse {
@JsonbProperty("totalResults")
private int totalResults;
@JsonbProperty("itemsPerPage")
private int itemsPerPage;
@JsonbProperty("users")
private User[] users;
@Getter @Setter
public static class User {
@JsonbProperty("id")
private String profileId;
@JsonbProperty("identities")
private Identity[] identities;
}
@Getter @Setter
public static class Identity {
@JsonbProperty("provider")
private String provider;
@JsonbProperty("id")
private String providerId;
@JsonbProperty("idpUserInfo")
private IdpUserInfo idpUserInfo;
}
@Getter @Setter
public static class IdpUserInfo {
@JsonbProperty("meta")
private Meta meta;
}
@Getter @Setter
public static class Meta {
@JsonbProperty("lastModified")
private String lastModified;
}
}

View File

@@ -0,0 +1,21 @@
package com.ibm.codey.loyalty.external.iam;
import javax.ws.rs.Consumes;
import javax.ws.rs.FormParam;
import javax.ws.rs.POST;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
public interface IAMTokenService extends AutoCloseable {
public final static String GRANT_TYPE_APIKEY = "urn:ibm:params:oauth:grant-type:apikey";
@POST
@Consumes({MediaType.APPLICATION_FORM_URLENCODED})
@Produces({MediaType.APPLICATION_JSON})
public IAMTokenServiceResponse getIAMTokenFromAPIKey(
@FormParam("grant_type") String grantType,
@FormParam("apikey") String apiKey
);
}

View File

@@ -0,0 +1,17 @@
package com.ibm.codey.loyalty.external.iam;
import javax.json.bind.annotation.JsonbProperty;
import lombok.Getter;
import lombok.Setter;
@Getter @Setter
public class IAMTokenServiceResponse {
@JsonbProperty("access_token")
public String accessToken;
@JsonbProperty
public long expiration;
}