はじめに
企業活動の透明性とセキュリティを担保する上で、監査ログ(Audit Log)の管理は重要です。SAP Business Technology Platform (BTP)では、セキュリティ関連のイベントを記録し、「誰が、いつ、何を行ったのか」という証跡を残すAudit Log Serviceが提供されています 。しかしAudit Logの標準機能には、デフォルトで90日間というログの保持期間の制約があります 。多くの社内コンプライアンス要件では、より長期間のデータ保持が求められるため、このままでは不十分なケースが少なくありません 。
本記事では、この「90日問題」を解決するため、BTPの各種サービスを使いながら、独自作成のJavaアプリケーションを用いた長期的な監査ログの保存の自動化プロセスについて解説します。
今回使用するSAPサービスについて
構成図は以下の通りです。

Audit Log Management Service
BTP環境におけるセキュリティ上重要な「イベント」(例:設定変更、ログイン試行)を記録するサービスです 。誰が、いつ、どのような操作を行ったかを追跡するときに役に立ちます。ただし、前述の通り、標準プランではログの保持期間が90日間に限定されているため、長期保管が必要な場合はAPI経由でデータを取得し、別のところに保存するか、 拡張機能を使用する必要があります。
Job Scheduling Service
バッチ処理の「トリガー」の役割を担うサービスです。「毎日深夜1時に実行する」といったスケジュールをcron形式などで定義し、指定した時刻にアプリケーションを自動的に起動させることができます。これにより運用を自動化し、深夜帯など誰も操作していない時間にログの取得・保存を行うことができます。
Object Store Service on SAP BTP
作成、アップロード、ダウンロード、および削除など、オブジェクトの保管および管理を行うサービスです(以下Object Storeと記載)。Azure Blob Storage、Amazon Web サービス、 Google Cloud Platform などの IaaS レイヤに固有で、SAP BTPのリージョンによって使用するベンダーが決まります(本記事では弊社環境の関係からAmazon Web サービスをベンダーとした場合を例に記載しています)。
また、Object Store単体では保管状況をGUIで確認することができないため、Postmanやファイル転送ソフト(winSCPなど)のツールを使用する必要があります。
Alert Notification
バッチ処理の実行結果を通知するサービスです 。処理が成功した場合や、エラーが発生した場合に、メールやチャットツール(Slackなど)にリアルタイムで通知することができます 。
Audit Log 取得・保存用カスタムアプリ
今回Audit Log の取得・保存を行うために、独自に作成したJavaアプリケーションです。Job Scheduling Serviceによって起動され、以下の処理を実行します。
- Audit Log Serviceへの認証とAPI呼び出し
- 取得したログデータの加工(CSV形式への変換)と圧縮
- Object Store Serviceへのファイルアップロード
- 処理結果の通知
各サービスの設定
Audit Log Management Service
- Service MarketplaceからAudit Log Management Serviceを選択して作成する。
- インスタンスが作成されたら、Servicekeyを作成しておく。
Job Scheduling Service
- Service MarketplaceからJob Scheduling Serviceを選択して作成する。
- インスタンスが作成されたら、作成したインスタンスをクリックし、Job Scheduling Service Dashboardを開く。

- タスクを以下のように作成する。
- 実行コマンドは以下のような形式で作成しました。
- [Java実行環境へのパス]/java -cp “[クラスパス]” [実行するメインクラスの完全修飾名]
- 実行コマンドは以下のような形式で作成しました。
/home/vcap/app/META-INF/.sap_java_buildpack/sap_machine_jre.default/bin/java -cp '/home/vcap/app/BOOT-INF/classes:/home/vcap/app/BOOT-INF/lib/*' com.sap.cds.cdsServicesArchetype.batch.AuditLogSample

- 作成したタスクを選択し、実行させたいスケジュールを作成する。

- 例 毎日日本時間午前1時に実行させる
- Pattern Recurring – Cron
- Value * * * * 16 0 0
詳しいスケジュールの設定方法はこちら

Object Store
- Service MarketplaceからObject Storeを選択してインスタンスを作成する(デフォルトのままでOK)。
- 自動削除の設定を更新する(必要な場合のみ)。
自動削除について
{
"autoExpiration": [
{
"id": "ExpireNonCurrentVersions",
"status": "Enabled",
"filter": {},
"expirationInDays": -1,
"expiredObjectDeleteMarker": false,
"noncurrentVersionExpiration": {
"days": 14,
"newerNoncurrentVersions": -1
}
}
]
}
上記のようにObject Storeはデフォルトで、バージョニングされたオブジェクトの非現行バージョンが14日後に削除されるように設定されています。保管する日数はnoncurrentVersionExpiration.daysに任意の日数を指定することで設定できます。
また、非現行バージョンの削除ではなくアップロードからの日数で削除されるようにしたい場合は、expirationInDaysの値を任意の日数にすることで設定が可能です。
こちらの設定はインスタンス作成時には指定できないため、インスタンス作成後にパラメータの更新が必要です。更新の際は上記のautoExpirationのみを記載して保存します。
保管状況の確認方法
先述の通り、ObjectStore単体ではGUIでの保管状況確認ができないため、別ツールの使用が必要です。
以下は例としてポストマンを使用した保管状況確認方法を記載しています。

Object StoreインスタンスのView Credentialsから確認できる情報を入力します。
Auth TypeはAWS署名を選択し、認可データの追加先はリクエストヘッダーを選択します。
- URL:https://<host>/<bucket>/<objectName>
- アクセスキー:access_key_id
- シークレットアクセスキー:secret_access_key
- AWSリージョン:region
- サービス名:s3(Object StoreはS3のみを使用するためs3が定数)
Alert Notification
- Service MarketplaceからAlert Notification Serviceを選択して作成する。
- 作成したインスタンスをクリックし、設定画面を開く。
- Conditions、Actions、Subscriptionsを作成する。
Condition設定内容
- Nameは任意の名前
- KeyはeventType、Predicateはis EQUALSを選択
- conditionsのValueはJavaアプリで設定したイベントタイプに準拠(スクショの例ではAuditLogBatchError)

Actions設定内容(メールの場合)
- Create Actionボタンを押し、Emailを選択する。

- 任意のAction名と通知を送りたい送り先のEmailアドレスを入力する。

Subscriptions設定内容
- Create Subscriptionのボタンを押し、Nameに任意のsubscription名を入力する。

- Select Conditionsで先ほど作成したConditionを選択する。

- Select Actionsで先ほど作成したActionを選択してSaveボタンを押して作成する。

処理フロー
それでは、実際にこれらのサービスがどのように連携して動作するのか、処理フローをサンプルコードを交えながら見ていきましょう。
ジョブの実行 (Job Scheduler)
- Job Schedulerで作成したスケジュールでアプリケーションをHTTPリクエストで呼び出し、バッチ処理が開始されます。
初期処理 (Javaアプリケーション)
- まず環境変数(VCAP_SERVICES)から、連携する各サービスの接続情報を読み込みます。
- ログを取得する対象期間を決定します。本記事の実装では、実行日の前日1日分(実行日の前日 00:00:00 〜 実行日 00:00:00)を対象としています。
- 保存するファイル名(例: AuditLog_20250626.csv.gz)もこの時点で決定しています。
サンプルコードはこちらをクリック
/**
* 環境変数 VCAP_SERVICES からサービス接続情報を読み込むメソッド(サンプル版)
*/
private void loadConfigFromVcapServices() throws Exception {
String vcapServicesEnv = System.getenv("VCAP_SERVICES");
if (Objects.isNull(vcapServicesEnv) || vcapServicesEnv.isEmpty()) {
throw new Exception("環境変数 VCAP_SERVICES が設定されていません。");
}
JSONObject vcapJson = new JSONObject(vcapServicesEnv);
// AuditLog Management Service の情報を取得
// ※実際のサービス名に合わせてキー("auditlog-management")を変更してください
JSONObject auditLogCreds = vcapJson.getJSONArray("auditlog-management")
.getJSONObject(0)
.getJSONObject("credentials");
this.auditLogServiceApiEndpointUrl = auditLogCreds.getString("url") + "/auditlog/v2/auditlogrecords";
JSONObject uaaCreds = auditLogCreds.getJSONObject("uaa");
this.oauthClientId = uaaCreds.getString("clientid");
this.oauthClientSecret = uaaCreds.getString("clientsecret");
this.oauthTokenUrl = uaaCreds.getString("url") + "/oauth/token";
// Amazon S3 (ObjectStore) の情報を取得
// ※実際のサービス名に合わせてキー("objectstore")を変更してください
JSONObject s3Creds = vcapJson.getJSONArray("objectstore")
.getJSONObject(0)
.getJSONObject("credentials");
this.s3AccessKey = s3Creds.getString("access_key_id");
this.s3SecretKey = s3Creds.getString("secret_access_key");
this.s3BucketName = s3Creds.getString("bucket");
this.s3Region = s3Creds.getString("region");
}
/**
* ログ取得の対象期間と、保存ファイル名の基準となる日付文字列を決定するメソッド
*/
private void determineLogPeriod() {
// タイムゾーンを定義
ZoneId jstZoneId = ZoneId.of("Asia/Tokyo");
// JSTでの「今日」を取得し、ログの対象日である「昨日」を計算
LocalDate todayJst = LocalDate.now(jstZoneId);
LocalDate targetLogDateJst = todayJst.minusDays(1);
// 1. ファイル名などに使う日付文字列を決定 (例: 20250630)
DateTimeFormatter fileNameDateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd");
this.baseDateForFileName = targetLogDateJst.format(fileNameDateFormatter);
// 2. APIで問い合わせる期間をJSTで定義
LocalDateTime periodStartJst = targetLogDateJst.atStartOfDay(); // 開始: 昨日の 00:00:00
LocalDateTime periodEndJst = todayJst.atStartOfDay(); // 終了: 今日の 00:00:00
// 3. APIリクエスト用にJSTからUTCへ変換
this.logPeriodStartUtc = periodStartJst.atZone(jstZoneId)
.withZoneSameInstant(ZoneOffset.UTC)
.toLocalDateTime();
this.logPeriodEndUtc = periodEndJst.atZone(jstZoneId)
.withZoneSameInstant(ZoneOffset.UTC)
.toLocalDateTime();
}
AuditLogの取得・データ処理 (Javaアプリケーション)
- AuditLog のAPIを呼び出すために、OAuth2のアクセストークンを取得します。
- APIを呼び出す際に一度にすべての情報が呼び出せない可能性があるので、「ページネーション」で全件取得を行います。
- 取得したログデータ(JSON形式)は、一時ファイルに追記していきます。
サンプルコードはこちらをクリック
/**
* アクセストークンを取得サンプルコード
*/
private void acquireAccessToken() throws Exception {
URL url = new URL(this.oauthTokenUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
conn.setDoOutput(true);
// リクエストボディを作成
String params = String.format("grant_type=client_credentials&client_id=%s&client_secret=%s",
URLEncoder.encode(this.oauthClientId, StandardCharsets.UTF_8.name()),
URLEncoder.encode(this.oauthClientSecret, StandardCharsets.UTF_8.name()));
// リクエストを送信
try (OutputStream os = conn.getOutputStream()) {
os.write(params.getBytes(StandardCharsets.UTF_8));
}
// レスポンスを処理
if (conn.getResponseCode() == HttpURLConnection.HTTP_OK) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
String response = br.lines().collect(Collectors.joining("\n"));
JSONObject jsonResponse = new JSONObject(response);
this.accessToken = jsonResponse.getString("access_token");
}
} else {
// エラー処理 (ここでは省略)
}
}
/**
* AuditLog APIをページネーションで全件取得し、一時的なCSVファイルに書き込みます。
*/
private void fetchAndStoreAuditLogs() throws Exception {
// データを書き込むための一時ファイルを作成
this.tempCsvFilePath = Files.createTempFile("auditlog_data_", ".csv");
List<String> csvHeaders = null; // CSVのヘッダー情報を保持するリスト
// ページネーションのためのループ
boolean hasMorePages = true;
String nextHandle = null; // 次のページを取得するための目印
try (BufferedWriter csvWriter = Files.newBufferedWriter(this.tempCsvFilePath, StandardCharsets.UTF_8)) {
while (hasMorePages) {
// ■ APIリクエストURLを構築
String currentRequestUrl;
if (nextHandle != null) {
// 2ページ目以降: handleパラメータで次のページを指定
currentRequestUrl = String.format("%s?handle=%s",
this.auditLogServiceApiEndpointUrl,
URLEncoder.encode(nextHandle, StandardCharsets.UTF_8.name()));
} else {
// 1ページ目: time_fromとtime_toで期間を指定
String startTime = this.logPeriodStartUtc.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
String endTime = this.logPeriodEndUtc.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
currentRequestUrl = String.format("%s?time_from=%s&time_to=%s",
this.auditLogServiceApiEndpointUrl,
URLEncoder.encode(startTime, StandardCharsets.UTF_8.name()),
URLEncoder.encode(endTime, StandardCharsets.UTF_8.name()));
}
// APIを呼び出し
URL url = new URL(currentRequestUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setRequestProperty("Authorization", "Bearer " + this.accessToken); // 取得したトークンを設定
// レスポンスを処理
if (conn.getResponseCode() != HttpURLConnection.HTTP_OK) {
throw new RuntimeException("AuditLog APIの呼び出しに失敗しました。 HTTP Status: " + conn.getResponseCode());
}
// レスポンスボディ(JSON配列)を読み込む
String responseJsonString;
try (BufferedReader apiReader = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
responseJsonString = apiReader.lines().collect(Collectors.joining("\n"));
}
JSONArray logDataArray = new JSONArray(responseJsonString);
// CSVファイルへの書き込み
if (!logDataArray.isEmpty()) {
// 最初のデータからヘッダーを動的に作成
if (csvHeaders == null) {
JSONObject firstRecord = logDataArray.getJSONObject(0);
csvHeaders = new ArrayList<>(firstRecord.keySet());
csvWriter.write(String.join(",", csvHeaders));
csvWriter.newLine();
}
// 各レコードをCSV形式で追記
for (int i = 0; i < logDataArray.length(); i++) {
JSONObject record = logDataArray.getJSONObject(i);
List<String> values = new ArrayList<>();
for (String header : csvHeaders) {
values.add(escapeCsvValue(record.optString(header, "")));
}
csvWriter.write(String.join(",", values));
csvWriter.newLine();
}
}
// ページネーションの判定
// レスポンスヘッダーの "Paging" から次のページのハンドルを取得
String pagingHeader = conn.getHeaderField("Paging");
if (pagingHeader != null && pagingHeader.toLowerCase().startsWith("handle=")) {
nextHandle = pagingHeader.substring("handle=".length()).trim();
hasMorePages = true; // 次のページがあるのでループ継続
} else {
hasMorePages = false; // 次のページがないのでループ終了
}
}
}
}
保存処理
- 全てのログデータを取得し終えたら、一時ファイルをCSV形式に整形します。
- 次に、このCSVファイルをGZip形式で圧縮し、ファイルサイズを削減します。これにより、ストレージコストと転送時間を節約します。
- 最後に、圧縮したファイル(AuditLog_YYYYMMDD.csv.gz)をObject Store Serviceにアップロードします。
サンプルコードはこちらをクリック
/**
* 一時CSVファイルをGZip形式で圧縮します。
*/
private void compressCsvToGzip() throws Exception {
// 圧縮元のCSVファイルが存在しない、または空の場合は処理を中断
if (this.tempCsvFilePath == null || !Files.exists(this.tempCsvFilePath) || Files.size(this.tempCsvFilePath) == 0) {
// 対象ファイルがないので、後続のアップロード処理も行われない
this.tempGzipFilePath = null;
return;
}
// 圧縮後のGZipファイル用の新しい一時ファイルパスを作成
this.tempGzipFilePath = Files.createTempFile("auditlog_final_", ".csv.gz");
// try-with-resourcesでストリームを自動的に閉じる
try (
FileInputStream fis = new FileInputStream(this.tempCsvFilePath.toFile());
FileOutputStream fos = new FileOutputStream(this.tempGzipFilePath.toFile());
GZIPOutputStream gzos = new GZIPOutputStream(fos)
) {
byte[] buffer = new byte[8192]; // 8KBのバッファ
int len;
// CSVファイルを読み込み、GZipストリームに書き込む
while ((len = fis.read(buffer)) > 0) {
gzos.write(buffer, 0, len);
}
}
// 元の一時CSVファイルを削除
Files.delete(this.tempCsvFilePath);
this.tempCsvFilePath = null; // パスをクリア
}
/**
* 圧縮されたGZipファイルをObject Store (S3) にアップロード
*/
private void uploadGzipFileToObjectStore() throws Exception {
// アップロードするGZipファイルがなければ処理を中断
if (this.tempGzipFilePath == null || !Files.exists(this.tempGzipFilePath)) {
return;
}
// S3クライアントを構築
try (S3Client s3Client = S3Client.builder()
.region(Region.of(this.s3Region))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(this.s3AccessKey, this.s3SecretKey)
))
.build()) {
// アップロード先のパス(オブジェクトキー)を決定
LocalDate date = LocalDate.parse(this.baseDateForFileName, DateTimeFormatter.ofPattern("yyyyMMdd"));
String year = String.valueOf(date.getYear());
String month = String.format("%02d", date.getMonthValue());
String fileName = String.format("AuditLog_%s.csv.gz", this.baseDateForFileName);
String objectKey = String.format("my-project/%s/%s/%s", year, month, fileName);
// アップロードリクエストを作成
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(this.s3BucketName)
.key(objectKey)
.build();
// ファイルを指定してアップロードを実行
s3Client.putObject(putObjectRequest, RequestBody.fromFile(this.tempGzipFilePath));
}
// ローカルの一時GZipファイルを削除
Files.delete(this.tempGzipFilePath);
this.tempGzipFilePath = null; // パスをクリア
}
終了処理(Javaアプリケーション、Alert Notification Service)
- Object Storeへのアップロードが成功したら、処理完了を示す成功通知をAlert Notification Serviceへ送信します。
- もし、いずれかのステップでエラーが発生した場合は、その時点で処理を中断し、エラー内容(スタックトレースなど)を含む失敗通知をAlert Notification Serviceへ送信します。
- 最後に、ローカルに作成した一時ファイルを削除します。
サンプルコードはこちらをクリック
/**
* Alert Notification Service クライアントを初期化するサンプルコード
*/
private void initializeAlertNotificationClient() throws Exception {
// VCAP_SERVICESから読み込んだANSの認証情報を使ってクライアントを構築
if (this.ansClientId == null) {
// ANSの設定がなければクライアントはnullのまま。通知はスキップされる。
return;
}
this.alertNotificationClient = new AlertNotificationClientBuilder(this.ansServiceUrl)
.withRetryCount(3) // Optional: add a retry policy
.withAuthentication(this.ansClientId, this.ansClientSecret, this.ansTokenUrl)
.build();
}
/**
* 処理の成功を通知するイベントをANSへ送信するサンプルコード
*/
private void sendSuccessNotification(String summary) {
if (this.alertNotificationClient == null) return; // クライアントがなければ何もしない
try {
CustomerResourceEvent successEvent = new CustomerResourceEvent.Builder()
.withType("AuditLogBatchSuccess") // ANSで設定したイベントタイプ
.withSeverity(EventSeverity.INFO)
.withSubject("Audit Log Batch Completed")
.withBody(summary)
.build();
this.alertNotificationClient.sendEvent(successEvent);
} catch (Exception e) {
}
}
/**
* 処理の失敗を通知するイベントをANSへ送信するサンプルコード
*/
private void sendFailureNotification(String summary, Exception exception) {
if (this.alertNotificationClient == null) return; // クライアントがなければ何もしない
// スタックトレースを文字列に変換して、通知内容に含める
StringWriter sw = new StringWriter();
exception.printStackTrace(new PrintWriter(sw));
String stackTrace = sw.toString();
String detailedBody = summary + "\n\nException: " + exception.getMessage() + "\n\nStack Trace:\n" + stackTrace;
try {
CustomerResourceEvent failureEvent = new CustomerResourceEvent.Builder()
.withType("AuditLogBatchError") // ANSで設定したイベントタイプ
.withSeverity(EventSeverity.FATAL)
.withSubject("CRITICAL: Audit Log Batch Failed")
.withBody(detailedBody)
.build();
this.alertNotificationClient.sendEvent(failureEvent);
} catch (Exception e) {
System.err.println("CRITICAL: Failed to send failure notification: " + e.getMessage());
}
}
補足:SAPサービスとJavaアプリケーションの認証情報読み込み
VCAP_SERVICESは、アプリケーションにバインド(接続)された各サービスの接続情報(URL、クライアントID、シークレットキーなど)が自動的に書き込まれる、JSON形式の特別な環境変数です。
これを使うことによって、コード内に認証情報のような機微な情報をハードコーディングする必要がなくなり、安全な設計が可能となります。
今回はmta.yaml内でバインドさせて、2.AuditLogの取得のサンプルコードのように使用しています。
サンプルコードはこちらをクリック
_schema-version: "3.2"
ID: auditlog-batch-app
version: 1.0.0
modules:
// Javaバッチアプリケーションの定義
- name: auditlog-batch-processor
type: java
path: srv
parameters:
// このモジュールが必要とするサービスを列挙
// `requires`セクションに書かれたサービスが、このアプリケーションにバインドされます。
requires:
- name: auditlog-service # 1. 監査ログサービス
- name: objectstore-service # 2. オブジェクトストア
- name: alert-notification-service # 3. アラート通知サービス
resources:
// アプリケーションが利用するサービスの定義
// `requires`で指定した名前と、実際のリソースをここで紐付けます。
- name: auditlog-service
type: org.cloudfoundry.existing-service // すでに存在しているインスタンス
parameters:
service-name: my-auditlog-instance
- name: objectstore-service
type: org.cloudfoundry.existing-service
parameters:
service-name: my-objectstore-instance
- name: alert-notification-service
type: org.cloudfoundry.existing-service
parameters:
service-name: my-alert-notification-instance
最後に
本記事では、SAP BTPにおける監査ログの「90日制限」を回避し、長期保管を自動化するための「AuditLogの自動取得・保存方法」について、解説しました。
設定手順や使用するサービスは多いですが、本記事のように作成・設定いただければ比較的簡単に自動化することができます。
また、今回はObject Storeを保管先としましたが、アップロード処理を改修すれば、SAP HANA Cloudへログを保管することもできます。これにより、監査データを分析に活用するなど、活用の幅が広がります。
Javaアプリについては、コード量が多いため全文載せることはできませんでしたが、作成の足掛かりになれば幸いです。
最後までご覧いただきありがとうございます。
投稿者プロフィール

-
2023年にIT未経験で入社。
AWS、Vue.js、Javaを使用した社内向けシステム開発に1年間ほど携わり、現在はSAPサービスの調査やFioriを用いたアプリ開発に取り組んでいます。
業務を通して得た知識やつまづきやすいポイントを中心にブログ記事としてアウトプットしていきたいと思っています。