diff --git a/retrieve-auditlog-data/.gitignore b/retrieve-auditlog-data/.gitignore new file mode 100644 index 00000000..d60eff7c --- /dev/null +++ b/retrieve-auditlog-data/.gitignore @@ -0,0 +1 @@ +servicebinding.json \ No newline at end of file diff --git a/retrieve-auditlog-data/README.md b/retrieve-auditlog-data/README.md new file mode 100644 index 00000000..2b278e5c --- /dev/null +++ b/retrieve-auditlog-data/README.md @@ -0,0 +1,87 @@ +# Retrieve Adit Log Data Using Audit Log CLI + +Audit log CLI is a command-line tool to retrieve audit logs from the [SAP Audit Log Service](https://help.sap.com/docs/btp/sap-business-technology-platform/audit-log-retrieval-api-usage-for-subaccounts-in-cloud-foundry-environment) on SAP Business Technology Platform (BTP). + +## What It Does + +The tool connects to your SAP BTP Audit Log Service instance and downloads audit log records as JSON. You can filter by time range to narrow down what you retrieve. If the result is large, all pages are fetched and printed automatically. + +## Prerequisites + +- [Go 1.21+](https://go.dev/dl/) installed +- A service binding file for the `auditlog-management` service instance (see [Audit Log Retrieval API for Global Accounts in the Cloud Foundry Environment](https://help.sap.com/docs/btp/sap-business-technology-platform/audit-log-retrieval-api-for-global-accounts-in-cloud-foundry-environment)) + +## Setup + +Place your service binding file in the project directory as `servicebinding.json`. It should look like this: + +```json +{ + "url": "https://.cfapps..hana.ondemand.com", + "tokenUrl": "https://.authentication..hana.ondemand.com", + "clientId": "", + "clientSecret": "" +} +``` + +## Usage + +### Get Logs with the Service Default Time Range + +```bash +go run main.go get +``` + +### Get Logs for a Specific Time Range + +All times are in UTC, format `YYYY-MM-DDTHH:MM:SS`. + +```bash +go run main.go get --time-from 2026-06-01T00:00:00 --time-to 2026-06-22T23:59:59 +``` + +### Get Logs for the Last 15 Minutes + +```bash +go run main.go get \ + --time-from $(date -u -v-15M '+%Y-%m-%dT%H:%M:%S') \ + --time-to $(date -u '+%Y-%m-%dT%H:%M:%S') +``` + +### Use a Different Binding File + +```bash +go run main.go get --bindingFile /path/to/my-binding.json +``` + +### Save Output to a File + +```bash +go run main.go get --time-from 2026-06-01T00:00:00 --time-to 2026-06-22T23:59:59 > logs.json +``` + +## Flags + +| Flag | Description | Default | +|---|---|---| +| `--time-from` | Start of the time range (UTC) | Service default | +| `--time-to` | End of the time range (UTC) | Service default | +| `--bindingFile` / `-b` | Path to the service binding file | `./servicebinding.json` | + +## Output + +Records are printed as pretty-printed JSON, one page at a time. Each record contains fields such as **message_uuid**, **time**, **category**, **user**, and a **message** field with the full event detail. + +You may see the following categories: + +- `audit.security-events` — login attempts, token issuance +- `audit.configuration` — configuration changes +- `audit.data-access` — data read operations +- `audit.data-modification` — data write or delete operations + +## Notes + +- The service returns up to 500 records per page. The tool follows pagination automatically so you always get the full result. +- Logs are not immediately available after an event occurs — there may be a short delay before they appear. +- The API rate limit is 4–8 requests per second, depending on your region. For large time ranges, this is handled transparently. + diff --git a/retrieve-auditlog-data/cmd/get.go b/retrieve-auditlog-data/cmd/get.go new file mode 100644 index 00000000..ac5aea52 --- /dev/null +++ b/retrieve-auditlog-data/cmd/get.go @@ -0,0 +1,160 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + + "github.com/spf13/cobra" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" +) + +// newGetCmd creates the "get" subcommand that fetches and prints audit log records +// from the SAP Audit Log Service v2 API as pretty-printed JSON. +// +// Supported flags: +// +// --time-from Start of the time range (format: 2006-01-02T15:04:05, UTC). Defaults to 30 days back. +// --time-to End of the time range (format: 2006-01-02T15:04:05, UTC). Defaults to now. +// +// When the result set exceeds 500 records the API paginates via a Paging response header. +// All pages are fetched automatically and printed in order. +func newGetCmd() *cobra.Command { + var timeFrom, timeTo string + + cmd := &cobra.Command{ + Use: "get", + Short: "Get audit log data", + Long: `Retrieve audit log records from the SAP Audit Log Service v2 API. + +If no time filter is provided the API returns the last 30 days of logs. +Times must be in UTC using the format: 2006-01-02T15:04:05 + +Example: + auditlog get --time-from 2024-01-01T00:00:00 --time-to 2024-01-31T23:59:59`, + Run: func(cmd *cobra.Command, args []string) { + client, err := newClient() + if err != nil { + fmt.Println("Error creating OAuth2 client:", err) + return + } + + if err := fetchAllPages(client, timeFrom, timeTo); err != nil { + fmt.Println("Error fetching audit logs:", err) + } + }, + } + + cmd.Flags().StringVar(&timeFrom, "time-from", "", "Start of time range in UTC (format: 2006-01-02T15:04:05)") + cmd.Flags().StringVar(&timeTo, "time-to", "", "End of time range in UTC (format: 2006-01-02T15:04:05)") + + return cmd +} + +// fetchAllPages retrieves all pages of audit log records, following the server-side +// paging handle returned in the Paging response header until no further pages exist. +func fetchAllPages(client *http.Client, timeFrom, timeTo string) error { + baseURL := config.serviceBinding.URL + "/auditlog/v2/auditlogrecords" + handle := "" + + for { + pageURL, err := buildURL(baseURL, timeFrom, timeTo, handle) + if err != nil { + return fmt.Errorf("building request URL: %w", err) + } + + resp, err := client.Get(pageURL) + if err != nil { + return fmt.Errorf("making request: %w", err) + } + + body, err := readResponse(resp) + resp.Body.Close() + if err != nil { + return err + } + + var prettyJSON bytes.Buffer + if err = json.Indent(&prettyJSON, body, "", "\t"); err != nil { + log.Println("JSON formatting failed:", err) + return err + } + fmt.Println(prettyJSON.String()) + + // The API signals more pages via the "Paging" response header: handle=. + handle = extractHandle(resp.Header.Get("Paging")) + if handle == "" { + break + } + } + return nil +} + +// buildURL constructs the request URL with optional time_from, time_to, and handle parameters. +func buildURL(baseURL, timeFrom, timeTo, handle string) (string, error) { + u, err := url.Parse(baseURL) + if err != nil { + return "", err + } + + q := u.Query() + if timeFrom != "" { + q.Set("time_from", timeFrom) + } + if timeTo != "" { + q.Set("time_to", timeTo) + } + if handle != "" { + q.Set("handle", handle) + } + u.RawQuery = q.Encode() + return u.String(), nil +} + +// extractHandle parses the handle value from the Paging response header (e.g. "handle=abc123"). +func extractHandle(pagingHeader string) string { + const prefix = "handle=" + if len(pagingHeader) > len(prefix) && pagingHeader[:len(prefix)] == prefix { + return pagingHeader[len(prefix):] + } + return "" +} + +// readResponse reads and validates the HTTP response body. +// Returns an error for non-200/204 status codes. +func readResponse(resp *http.Response) ([]byte, error) { + if resp.StatusCode == http.StatusNoContent { + return []byte("[]"), nil + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + return body, nil +} + +// newClient creates an OAuth2 HTTP client using client credentials from the service binding. +// The client automatically fetches and refreshes the bearer token as needed. +func newClient() (*http.Client, error) { + cfg := &clientcredentials.Config{ + ClientID: config.serviceBinding.ClientID, + ClientSecret: config.serviceBinding.ClientSecret, + TokenURL: config.serviceBinding.TokenURL + "/oauth/token", + } + + tokenSource := cfg.TokenSource(context.TODO()) + client := oauth2.NewClient(context.TODO(), tokenSource) + + return client, nil +} diff --git a/retrieve-auditlog-data/cmd/get_test.go b/retrieve-auditlog-data/cmd/get_test.go new file mode 100644 index 00000000..08cf514a --- /dev/null +++ b/retrieve-auditlog-data/cmd/get_test.go @@ -0,0 +1,264 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +// auditLogRecord mirrors the structure returned by the SAP Audit Log Service v2 API. +// The message field is a JSON-encoded string containing the full event detail. +type auditLogRecord struct { + MessageUUID string `json:"message_uuid"` + Time string `json:"time"` + Tenant string `json:"tenant"` + OrgID string `json:"org_id"` + SpaceID string `json:"space_id"` + AppOrServiceID string `json:"app_or_service_id"` + AlsServiceID string `json:"als_service_id"` + User string `json:"user"` + Category string `json:"category"` + FormatVersion string `json:"format_version"` + Message string `json:"message"` +} + +// auditLogMessage mirrors the structure of the JSON-encoded message field. +type auditLogMessage struct { + UUID string `json:"uuid"` + User string `json:"user"` + Time string `json:"time"` + IP string `json:"ip"` + Data string `json:"data"` + Attributes []any `json:"attributes"` + ID string `json:"id"` + Category string `json:"category"` + Tenant string `json:"tenant"` + CustomDetails map[string]any `json:"customDetails"` +} + +// newTestRecord builds a synthetic audit log record with the given category and timestamp. +// All IDs and values are generic placeholders — no real data from test-data.json. +func newTestRecord(category, timestamp string) auditLogRecord { + msg := auditLogMessage{ + UUID: "00000000-0000-0000-0000-000000000001", + User: "test-user", + Time: timestamp, + IP: "192.0.2.1", + Data: `{"level":"INFO","message":"test event"}`, + Attributes: []any{}, + ID: "00000000-0000-0000-0000-000000000002", + Category: category, + Tenant: "00000000-0000-0000-0000-000000000003", + CustomDetails: map[string]any{}, + } + msgBytes, _ := json.Marshal(msg) + + return auditLogRecord{ + MessageUUID: "00000000-0000-0000-0000-000000000001", + Time: timestamp, + Tenant: "00000000-0000-0000-0000-000000000003", + OrgID: "00000000-0000-0000-0000-000000000004", + SpaceID: "00000000-0000-0000-0000-000000000005", + AppOrServiceID: "00000000-0000-0000-0000-000000000006", + AlsServiceID: "00000000-0000-0000-0000-000000000007", + User: "test-user", + Category: category, + FormatVersion: "", + Message: string(msgBytes), + } +} + +func TestBuildURL_WithTimestamps(t *testing.T) { + base := "https://auditlog.example.com/auditlog/v2/auditlogrecords" + + result, err := buildURL(base, "2024-01-01T00:00:00", "2024-01-31T23:59:59", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + u, _ := url.Parse(result) + q := u.Query() + + if got := q.Get("time_from"); got != "2024-01-01T00:00:00" { + t.Errorf("time_from = %q, want %q", got, "2024-01-01T00:00:00") + } + if got := q.Get("time_to"); got != "2024-01-31T23:59:59" { + t.Errorf("time_to = %q, want %q", got, "2024-01-31T23:59:59") + } +} + +func TestBuildURL_WithoutTimestamps(t *testing.T) { + base := "https://auditlog.example.com/auditlog/v2/auditlogrecords" + + result, err := buildURL(base, "", "", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + u, _ := url.Parse(result) + q := u.Query() + + if q.Has("time_from") { + t.Error("time_from should not be present when empty") + } + if q.Has("time_to") { + t.Error("time_to should not be present when empty") + } +} + +func TestBuildURL_WithHandle(t *testing.T) { + base := "https://auditlog.example.com/auditlog/v2/auditlogrecords" + handle := "dGVzdC1oYW5kbGU=" + + result, err := buildURL(base, "2024-01-01T00:00:00", "2024-01-31T23:59:59", handle) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + u, _ := url.Parse(result) + q := u.Query() + + if got := q.Get("handle"); got != handle { + t.Errorf("handle = %q, want %q", got, handle) + } +} + +func TestExtractHandle(t *testing.T) { + tests := []struct { + header string + want string + }{ + {"handle=dGVzdA==", "dGVzdA=="}, + {"handle=", ""}, + {"", ""}, + {"something-else", ""}, + } + + for _, tt := range tests { + got := extractHandle(tt.header) + if got != tt.want { + t.Errorf("extractHandle(%q) = %q, want %q", tt.header, got, tt.want) + } + } +} + +func TestReadResponse_OK(t *testing.T) { + records := []auditLogRecord{newTestRecord("audit.security-events", "2024-01-15T10:00:00.000Z")} + body, _ := json.Marshal(records) + + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusOK) + rec.Write(body) + + got, err := readResponse(rec.Result()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var parsed []auditLogRecord + if err := json.Unmarshal(got, &parsed); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + if len(parsed) != 1 { + t.Errorf("expected 1 record, got %d", len(parsed)) + } + if parsed[0].Category != "audit.security-events" { + t.Errorf("category = %q, want %q", parsed[0].Category, "audit.security-events") + } +} + +func TestReadResponse_NoContent(t *testing.T) { + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusNoContent) + + got, err := readResponse(rec.Result()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != "[]" { + t.Errorf("body = %q, want %q", string(got), "[]") + } +} + +func TestReadResponse_ErrorStatus(t *testing.T) { + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(rec, "unauthorized") + + _, err := readResponse(rec.Result()) + if err == nil { + t.Fatal("expected error for 401, got nil") + } +} + +func TestFetchAllPages_WithTimestamps(t *testing.T) { + records := []auditLogRecord{ + newTestRecord("audit.security-events", "2024-01-10T08:00:00.000Z"), + newTestRecord("audit.configuration", "2024-01-15T12:30:00.000Z"), + } + body, _ := json.Marshal(records) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + if got := q.Get("time_from"); got != "2024-01-01T00:00:00" { + http.Error(w, fmt.Sprintf("unexpected time_from: %q", got), http.StatusBadRequest) + return + } + if got := q.Get("time_to"); got != "2024-01-31T23:59:59" { + http.Error(w, fmt.Sprintf("unexpected time_to: %q", got), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(body) + })) + defer server.Close() + + config.serviceBinding.URL = server.URL + + if err := fetchAllPages(server.Client(), "2024-01-01T00:00:00", "2024-01-31T23:59:59"); err != nil { + t.Fatalf("fetchAllPages returned error: %v", err) + } +} + +func TestFetchAllPages_Pagination(t *testing.T) { + page1 := []auditLogRecord{newTestRecord("audit.security-events", "2024-01-10T08:00:00.000Z")} + page2 := []auditLogRecord{newTestRecord("audit.data-access", "2024-01-20T14:00:00.000Z")} + body1, _ := json.Marshal(page1) + body2, _ := json.Marshal(page2) + + page := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page++ + w.Header().Set("Content-Type", "application/json") + + if page == 1 { + w.Header().Set("Paging", "handle=dGVzdC1oYW5kbGU=") + w.WriteHeader(http.StatusOK) + w.Write(body1) + return + } + + if got := r.URL.Query().Get("handle"); got != "dGVzdC1oYW5kbGU=" { + http.Error(w, fmt.Sprintf("missing or wrong handle: %q", got), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) + w.Write(body2) + })) + defer server.Close() + + config.serviceBinding.URL = server.URL + + if err := fetchAllPages(server.Client(), "2024-01-01T00:00:00", "2024-01-31T23:59:59"); err != nil { + t.Fatalf("fetchAllPages returned error: %v", err) + } + if page != 2 { + t.Errorf("expected 2 pages fetched, got %d", page) + } +} diff --git a/retrieve-auditlog-data/cmd/model.go b/retrieve-auditlog-data/cmd/model.go new file mode 100644 index 00000000..e3683e96 --- /dev/null +++ b/retrieve-auditlog-data/cmd/model.go @@ -0,0 +1,9 @@ +package cmd + +// serviceBinding holds the connection details parsed from the SAP Audit Log Service binding file. +type serviceBinding struct { + URL string `json:"url"` + TokenURL string `json:"tokenUrl"` + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` +} diff --git a/retrieve-auditlog-data/cmd/root.go b/retrieve-auditlog-data/cmd/root.go new file mode 100644 index 00000000..5da7a5e2 --- /dev/null +++ b/retrieve-auditlog-data/cmd/root.go @@ -0,0 +1,60 @@ +// Package cmd implements the CLI commands for the auditlog tool. +package cmd + +import ( + "encoding/json" + "os" + + "github.com/spf13/cobra" +) + +// config holds the global CLI configuration shared across all commands. +var config = struct { + ServiceBindingFile string + serviceBinding serviceBinding +}{} + +// newRootCmd creates and returns the root cobra command. +// It registers the --bindingFile flag and loads the service binding before any subcommand runs. +func newRootCmd() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "auditlog", + Short: "Access audit logs from SAP Audit Log Service v2", + Long: `This CLI tool helps you to access + audit logs from SAP Audit Log Service v2. + You can use it to query and retrieve audit logs based on various criteria.`, + } + rootCmd.PersistentFlags().StringVarP(&config.ServiceBindingFile, "bindingFile", "b", "./servicebinding.json", "Path to the service binding file") + + // Load the service binding file before executing any subcommand. + rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + if err := readServiceBinding(); err != nil { + return err + } + return nil + } + + rootCmd.AddCommand(newGetCmd()) + return rootCmd +} + +// Execute is the entry point called by main. It builds the command tree and runs the CLI. +func Execute() { + err := newRootCmd().Execute() + if err != nil { + os.Exit(1) + } +} + +// readServiceBinding reads and parses the JSON service binding file into the global config. +func readServiceBinding() error { + data, err := os.ReadFile(config.ServiceBindingFile) + if err != nil { + return err + } + + if err := json.Unmarshal(data, &config.serviceBinding); err != nil { + return err + } + return err +} diff --git a/retrieve-auditlog-data/go.mod b/retrieve-auditlog-data/go.mod new file mode 100644 index 00000000..4f5a4509 --- /dev/null +++ b/retrieve-auditlog-data/go.mod @@ -0,0 +1,13 @@ +module github.com/kyma/kyma-runtime-samples/retrieve-auditlog-data + +go 1.26.2 + +require ( + github.com/spf13/cobra v1.10.2 + golang.org/x/oauth2 v0.36.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/retrieve-auditlog-data/go.sum b/retrieve-auditlog-data/go.sum new file mode 100644 index 00000000..a2d200e9 --- /dev/null +++ b/retrieve-auditlog-data/go.sum @@ -0,0 +1,12 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/retrieve-auditlog-data/main.go b/retrieve-auditlog-data/main.go new file mode 100644 index 00000000..a1e72998 --- /dev/null +++ b/retrieve-auditlog-data/main.go @@ -0,0 +1,8 @@ +// Package main is the entry point for the auditlog CLI tool. +package main + +import "github.com/kyma/kyma-runtime-samples/retrieve-auditlog-data/cmd" + +func main() { + cmd.Execute() +}